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

+113 -6
View File
@@ -177,6 +177,7 @@ menu list every desktop entry, including terminals such as Konsole.
| `steamFrame.keyboardVariant` | null or str | `null` | XKB variant for the Steam session. | | `steamFrame.keyboardVariant` | null or str | `null` | XKB variant for the Steam session. |
| `steamFrame.steamKeyboardPatch.enable` | bool | `false` | Runtime patch of Steam's on-screen keyboard: Esc/Ctrl/Alt, separate arrows, real Ctrl/Alt chords and hold, AltGr/non-ASCII characters. | | `steamFrame.steamKeyboardPatch.enable` | bool | `false` | Runtime patch of Steam's on-screen keyboard: Esc/Ctrl/Alt, separate arrows, real Ctrl/Alt chords and hold, AltGr/non-ASCII characters. |
| `steamFrame.uiPatches.patches` | list of submodules | `[ ]` | Runtime patches of Steam's web UIs over their local DevTools ports, see [UI patches](#ui-patches-uipatchespatches). | | `steamFrame.uiPatches.patches` | list of submodules | `[ ]` | Runtime patches of Steam's web UIs over their local DevTools ports, see [UI patches](#ui-patches-uipatchespatches). |
| `steamFrame.uiPatches.lib` | attrs, read-only | | Helpers for patches: `mkPatch` (wraps a patch with the finder library and its signatures), see [Finders and signatures](#finders-and-signatures). |
| `steamFrame.launcherMenu.sort` | bool | `false` | Sort the VR "+" menu alphabetically. | | `steamFrame.launcherMenu.sort` | bool | `false` | Sort the VR "+" menu alphabetically. |
| `steamFrame.launcherMenu.pinDesktop` | null or `"top"` / `"bottom"` | `null` | Pin "Desktop" above or below the "+" menu's scrolling list (always visible). `null`: a normal list entry. | | `steamFrame.launcherMenu.pinDesktop` | null or `"top"` / `"bottom"` | `null` | Pin "Desktop" above or below the "+" menu's scrolling list (always visible). `null`: a normal list entry. |
| `steamFrame.launcherMenu.closeOnLaunch` | bool | `false` | Close the "+" menu as soon as a program in it is clicked. | | `steamFrame.launcherMenu.closeOnLaunch` | bool | `false` | Close the "+" menu as soon as a program in it is clicked. |
@@ -287,10 +288,15 @@ allowlist: Ctrl/Alt chords with a single key, the extra keys (Esc, Del, Home,
End, Page Up/Down, arrows), hold/release of Ctrl/Alt, and single non-ASCII or AltGr End, Page Up/Down, arrows), hold/release of Ctrl/Alt, and single non-ASCII or AltGr
characters. It cannot type plain ASCII text or press Enter on its own. characters. It cannot type plain ASCII text or press Enter on its own.
**Caveat:** the patch depends on Steam UI internals, including internal **Caveat:** the patch depends on Steam UI internals: the keyboard layouts
webpack module ids (e.g. `40222` for layouts, `5363` for the keyboard module, the VR keyboard status and the keyboard manager. They are found by
manager). A Steam update can break it; the keys then just don't appear. signature (content and shape, see [Finders and signatures](#finders-and-signatures)),
Tested with Steam client 1790377368. not by webpack module id or minified export name, so ordinary Steam updates
don't break it. If a signature stops matching, the patch leaves Steam
untouched (stock keyboard) and logs which one
(`journalctl --user -u steam-keyboard-patch`); see
[After a Steam update](#after-a-steam-update). Tested with Steam client
1790377368 (UI build 11041156).
**Remove when** Steam's VR keyboard gets these keys itself. **Remove when** Steam's VR keyboard gets these keys itself.
@@ -343,7 +349,106 @@ recommended; it is a system-level change, outside Home Manager (e.g. with
`127.0.0.1`. `127.0.0.1`.
**Caveat:** patches depend on Steam UI internals and can break with a Steam **Caveat:** patches depend on Steam UI internals and can break with a Steam
update. update. Find modules by signature rather than by id (below).
### Finders and signatures
Steam's UI is a webpack bundle: module ids (`40222`) and export names (`G$`,
`r_`) are generated anew by every Steam UI build. The patches therefore
never use them. `modules/lib/finders.js` (in the spirit of Decky Loader's
`findModule`/`findModuleChild`/`findInReactTree` and Vencord's `find`)
locates what they need by *signature*:
- a **module** by strings (or regexes) in its factory's source, e.g. the
keyboard layouts module contains `name:"qwerty"`, `rgLayout:` and
`GetKeyboardLayoutSettings`;
- an **export** of that module by its shape: type, source of a function
(`GetKeyboardLayoutSettings` + `currentLayout`, without `selectedLayouts`),
arity, data properties (`{ key: "ArrowLeft" }`), prototype methods
(`HandleVirtualKeyDown`, `SendClientPasteCommand`) or getters
(`VRKeyboardStatus`);
- **React** fibers by props (`findFiberUp`, `findFiberDown`,
`findInReactTree`).
Every signature must match exactly once, otherwise the patch changes nothing
and reports the failing signature (e.g. `signature not found, Steam left
unpatched: layouts.currentLayout (module 40222): ambiguous export, candidates
r_, xy`). Results are cached per page, so re-injection every 15 s is cheap.
The signatures are data, in `modules/lib/signatures.json`, shared by the
patches and the offline checker. Besides `module`/`exports`, an entry can
list `expects` (strings the patch relies on, e.g. internal property names,
only checked offline) or be `checkOnly` (anchors a patch uses without the
finder, e.g. the React prop names of the "+" menu, checked offline only).
For your own patches, `steamFrame.uiPatches.lib.mkPatch` wraps a patch
written as a function expression with the library, its signatures and
options:
```nix
steamFrame.uiPatches.patches = [ {
name = "my-patch";
target.title = "SharedJSContext";
patch = config.steamFrame.uiPatches.lib.mkPatch {
name = "my-patch";
src = ./my-patch/patch.js; # ((find, sigs, opts) => { … })
signatures.thing = {
module.includes = [ "SomeUniqueString" ];
exports.Thing = { type = "class"; protoMethods = [ "DoIt" ]; };
};
opts.factor = 2;
};
unpatch = ./my-patch/unpatch.js;
} ];
```
```js
((find, sigs, opts) => {
let mods;
try { mods = find.resolveAll(find.getWebpackRequire('webpackChunksteamui'), sigs); }
catch (e) { return `not patched: ${e.message}`; }
const Thing = mods.thing.exports.Thing; // SteamVR dashboard: 'webpackChunkvrwebui'
…
})
```
### After a Steam update
The patches keep working as long as their signatures match. To check
without touching the running UI (no browser, Steam need not run), run the
offline checker from a checkout of this repository:
```sh
nix shell nixpkgs#nodejs -c node scripts/check-signatures.mjs
```
It reads the bundles Steam's UI page loads (`~/.local/share/Steam/steamui`:
the scripts of `index.html` and every chunk its webpack runtime can load),
extracts all webpack module factories and evaluates every signature of
`modules/lib/signatures.json` against them with the same finder code the
patches use (exports are checked by running the matched module in an inert
sandbox). Per patch it prints `found` (with the current module id and export
name), `ambiguous` (all candidates) or `missing`, and warnings for missing
`expects`; exit status 1 if anything is missing or ambiguous:
```
bundle steamui: 2827 modules in /home/deck/.local/share/Steam/steamui (build 11041156)
steam-keyboard-patch (steamui)
layouts found module 40222 (chunk~2dcc5aaf7.js)
.currentLayout found export r_
…
OK: all signatures match exactly once
```
If something is missing, look at the module that used to match (the new
bundle's module sources: `scripts/webpack-modules.mjs`) and adjust the
signature in `signatures.json` (bump the patch's `VERSION` if its code
changes). Options: `--signatures FILE` adds your own signatures (same
format; the bundle `vrwebui-systemui` is SteamVR's dashboard),
`--dir steamui=DIR` checks another copy, `--patch NAME`, `--strict` (fail on
warnings), `--json`. Live, `journalctl --user -u steam-keyboard-patch -u
steam-ui-patches` shows what each patch reported.
### Launcher menu (`launcherMenu.*`) ### Launcher menu (`launcherMenu.*`)
@@ -382,7 +487,9 @@ the program twice.
re-injection, i.e. within 15 s). Consequence: a program that exits right re-injection, i.e. within 15 s). Consequence: a program that exits right
away can only be started again once the time is up. away can only be started again once the time is up.
All are reverted when the options are turned off (next switch). All are reverted when the options are turned off (next switch). These
patches use Steam APIs (`SteamClient.Apps`) and React props rather than
webpack modules; the offline checker verifies those anchors too.
Tested with Steam client 1790377368. Tested with Steam client 1790377368.
### Hidden apps (`hiddenApps`) ### Hidden apps (`hiddenApps`)
+15 -8
View File
@@ -13,10 +13,13 @@
# the menu stays open until the program's window appears, inviting double # the menu stays open until the program's window appears, inviting double
# launches). Options are passed by calling the patch's function. # launches). Options are passed by calling the patch's function.
# All are reverted when turned off (next switch). # 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, ... }: { config, lib, pkgs, ... }:
let let
cfg = config.steamFrame.launcherMenu; cfg = config.steamFrame.launcherMenu;
inherit (import ./lib { inherit pkgs; }) mkPatch;
sharedJSContext = { title = "SharedJSContext"; }; sharedJSContext = { title = "SharedJSContext"; };
in { in {
imports = [ ./steam-ui-patches.nix ]; imports = [ ./steam-ui-patches.nix ];
@@ -78,20 +81,24 @@ in {
++ lib.optional (cfg.pinDesktop != null) { ++ lib.optional (cfg.pinDesktop != null) {
name = "launcher-menu-pinned-desktop"; name = "launcher-menu-pinned-desktop";
target = sharedJSContext; target = sharedJSContext;
patch = pkgs.writeText "launcher-menu-pinned-desktop.js" '' patch = mkPatch {
(${builtins.readFile ./launcher-menu/pinned-desktop/patch.js})(${builtins.toJSON { position = cfg.pinDesktop; }}) name = "launcher-menu-pinned-desktop";
''; src = ./launcher-menu/pinned-desktop/patch.js;
opts.position = cfg.pinDesktop;
};
unpatch = ./launcher-menu/pinned-desktop/unpatch.js; unpatch = ./launcher-menu/pinned-desktop/unpatch.js;
} }
++ lib.optional (cfg.closeOnLaunch || cfg.launchDebounce > 0) { ++ lib.optional (cfg.closeOnLaunch || cfg.launchDebounce > 0) {
name = "launcher-menu-launch"; name = "launcher-menu-launch";
target = sharedJSContext; target = sharedJSContext;
patch = pkgs.writeText "launcher-menu-launch.js" '' patch = mkPatch {
(${builtins.readFile ./launcher-menu/launch/patch.js})(${builtins.toJSON { name = "launcher-menu-launch";
src = ./launcher-menu/launch/patch.js;
opts = {
inherit (cfg) closeOnLaunch; inherit (cfg) closeOnLaunch;
debounceSeconds = cfg.launchDebounce; debounceSeconds = cfg.launchDebounce;
}}) };
''; };
unpatch = ./launcher-menu/launch/unpatch.js; 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 // launch: what happens when a program of the VR dashboard's "+" menu
// (#VRDashboard_LaunchNonSteamApp) is activated. // (#VRDashboard_LaunchNonSteamApp) is activated.
// //
// This file is a function expression; launcher-menu.nix calls it with the // This file is a function expression, called by the file lib/default.nix
// options: (<this file>)({ closeOnLaunch: true, debounceSeconds: 10 }). // (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 // Stock, an item's onActivate only calls
// SteamClient.Apps.LaunchNonSteamApp(strCmdline) (plus the nav sound); the // SteamClient.Apps.LaunchNonSteamApp(strCmdline) (plus the nav sound); the
@@ -15,15 +17,18 @@
// in the steam-ui-patches journal on the next re-injection); // in the steam-ui-patches journal on the next re-injection);
// - closeOnLaunch: after a launch (ignored or not), the "+" popup is closed // - closeOnLaunch: after a launch (ignored or not), the "+" popup is closed
// through the bar button's own popup handle (closePopup(), what stock does // 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 // after adding a desktop window). The handle is found through React props,
// document: the button's React fiber ancestors include the "+" component // not minified names: in any popup document (the dashboard bar's), a
// (prop allowLaunchProgram) and the bar button (prop refBarPopopHandle). // .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 // Works for every activation path (pointer, controller, the pinned Desktop
// copy, which clicks the stock item). The original is kept as __sfuiOrig // copy, which clicks the stock item). The original is kept as __sfuiOrig
// (unpatch.js restores it). Idempotent; bump VERSION when changing the // (unpatch.js restores it). Idempotent; bump VERSION when changing the
// wrapper. // wrapper.
((opts) => { ((find, sigs, opts) => {
const VERSION = 1; const VERSION = 2;
const NAME = 'launcher-menu-launch'; const NAME = 'launcher-menu-launch';
const CLOSE = opts.closeOnLaunch === true; const CLOSE = opts.closeOnLaunch === true;
const DEBOUNCE_MS = Math.max(0, Number(opts.debounceSeconds) || 0) * 1000; const DEBOUNCE_MS = Math.max(0, Number(opts.debounceSeconds) || 0) * 1000;
@@ -39,22 +44,18 @@
} }
const orig = cur.__sfuiPatch === NAME ? cur.__sfuiOrig : cur; 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). // 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 plusHandles = () => {
const hs = []; const hs = [];
for (const p of window.g_PopupManager?.GetPopups() ?? []) { for (const p of window.g_PopupManager?.GetPopups() ?? []) {
if (!/gamepadui\.bar\./.test(p.m_strName ?? '')) continue;
let d; let d;
try { d = p.window?.document; } catch { continue; } try { d = p.window?.document; } catch { continue; }
for (const el of d?.querySelectorAll('.VRDashboardBarSmallButton') ?? []) { for (const el of d?.querySelectorAll('.VRDashboardBarSmallButton') ?? []) {
let h = null; const button = find.findFiberUp(el, (f) => hasProp(f, 'refBarPopopHandle'), 20);
for (let f = fiberOf(el), i = 0; f && i < 20; f = f.return, i++) { if (!button || !find.findFiberUp(button, (f) => hasProp(f, 'allowLaunchProgram'), 20)) continue;
const mp = f.memoizedProps; const h = button.memoizedProps.refBarPopopHandle?.current ?? null;
if (!mp || typeof mp !== 'object') continue; if (h && !hs.includes(h)) hs.push(h);
if ('refBarPopopHandle' in mp) h = mp.refBarPopopHandle?.current ?? null;
if ('allowLaunchProgram' in mp) { if (h && !hs.includes(h)) hs.push(h); break; }
}
} }
} }
return hs; return hs;
+17 -17
View File
@@ -3,8 +3,10 @@
// pinned above or below the scrolling program list instead of scrolling with // pinned above or below the scrolling program list instead of scrolling with
// it. // it.
// //
// This file is a function expression; launcher-menu.nix calls it with the // This file is a function expression, called by the file lib/default.nix
// options: (<this file>)({ position: "top" }) or ({ position: "bottom" }). // (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): // DOM patch, evaluated in SharedJSContext (the bar popups share its realm):
// a timer attaches a MutationObserver to every dashboard bar popup document // a timer attaches a MutationObserver to every dashboard bar popup document
@@ -18,10 +20,14 @@
// own handler runs (nav sound + SteamClient.Apps.LaunchNonSteamApp). The // own handler runs (nav sound + SteamClient.Apps.LaunchNonSteamApp). The
// list container is a flex column with a max-height, so the scroll region // list container is a flex column with a max-height, so the scroll region
// shrinks to make room and the menu keeps its size. // 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(), // unpatch.js (or a new VERSION/position) calls __sfuiPinnedDesktop.stop(),
// which removes the pinned blocks, CSS, markers, observers and the timer. // which removes the pinned blocks, CSS, markers, observers and the timer.
((opts) => { ((find, sigs, opts) => {
const VERSION = 1; const VERSION = 2;
const POS = opts.position === 'top' ? 'top' : 'bottom'; const POS = opts.position === 'top' ? 'top' : 'bottom';
const KEY = 'steamos-nested-desktop'; const KEY = 'steamos-nested-desktop';
const HIDDEN = 'data-sfui-desktop-hidden'; const HIDDEN = 'data-sfui-desktop-hidden';
@@ -40,25 +46,19 @@
if (!window.g_PopupManager) return 'g_PopupManager missing'; if (!window.g_PopupManager) return 'g_PopupManager missing';
const docs = new Map(); // popup document -> MutationObserver 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 stock Desktop item: role=button element whose fiber ancestors include
// the list entry keyed by the program's exe path. // the list entry keyed by the program's exe path.
const isDesktop = (el) => { const isDesktop = (el) => {
for (let f = fiberOf(el), i = 0; f && i < 12; f = f.return, i++) const f = find.findFiberUp(el, (x) => x.key != null, 12);
if (f.key != null) return f.key === KEY || String(f.key).endsWith('/' + KEY); return !!f && (f.key === KEY || String(f.key).endsWith('/' + KEY));
return false;
}; };
// The list container (host div of the section component, which has a `header` prop). // The list container (host div of the section component, which has a `header` prop).
const listOf = (el) => { const listOf = (el) => {
for (let f = fiberOf(el), i = 0; f && i < 60; f = f.return, i++) { const f = find.findFiberUp(el, (x) => x.memoizedProps && typeof x.memoizedProps === 'object' &&
if (f.memoizedProps && 'header' in f.memoizedProps && typeof f.type === 'function') { 'header' in x.memoizedProps && typeof x.type === 'function', 60);
let c = f.child; let c = f?.child;
while (c && typeof c.type !== 'string') c = c.child; while (c && typeof c.type !== 'string') c = c.child;
return c?.stateNode ?? null; return c?.stateNode ?? null;
}
}
return null;
}; };
// Child of the list container that holds the (scrolling) items. // Child of the list container that holds the (scrolling) items.
const scrollerOf = (orig, list) => { 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 # performs those requests with xdotool on :0 (X focus follows the window
# selected in VR) and reverts the patch when stopped (unpatch.js). Its # selected in VR) and reverts the patch when stopped (unpatch.js). Its
# allowlist can't type ASCII text or press Enter. # allowlist can't type ASCII text or press Enter.
# Depends on Steam UI internals; tested with Steam client 1790377368. # Depends on Steam UI internals, found by signature (lib/finders.js, entry
{ config, pkgs, lib, ... }: { # "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 ]; imports = [ ./session.nix ];
options.steamFrame.steamKeyboardPatch.enable = lib.mkEnableOption '' options.steamFrame.steamKeyboardPatch.enable = lib.mkEnableOption ''
@@ -31,7 +40,7 @@
ExecStart = lib.escapeShellArgs [ ExecStart = lib.escapeShellArgs [
"${pkgs.nodejs}/bin/node" "${pkgs.nodejs}/bin/node"
"${./steam-keyboard-patch/helper.mjs}" "${./steam-keyboard-patch/helper.mjs}"
"${./steam-keyboard-patch/patch.js}" "${patch}"
"${./steam-keyboard-patch/unpatch.js}" "${./steam-keyboard-patch/unpatch.js}"
"${pkgs.xdotool}/bin/xdotool" "${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. // 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 // On SIGTERM/SIGINT it reverts the patch (unpatch.js), so stopping the service
// restores Steam's stock keyboard without restarting Steam. // restores Steam's stock keyboard without restarting Steam.
@@ -72,10 +73,12 @@ async function session() {
const call = (method, params = {}) => new Promise((res) => { const call = (method, params = {}) => new Promise((res) => {
const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); 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 inject = async () => {
const r = await call('Runtime.evaluate', { expression: PATCH, returnByValue: true }); const r = await call('Runtime.evaluate', { expression: PATCH, returnByValue: true });
const v = r.result?.result?.value ?? r.result?.exceptionDetails?.exception?.description; 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. // New JS contexts (UI reload, popups) come in bursts: inject once after them.
let soon = null; let soon = null;
+32 -18
View File
@@ -14,15 +14,30 @@
// before (Steam then labels it "Search" and closes the keyboard instead) // before (Steam then labels it "Search" and closes the keyboard instead)
// Everything goes through the CDP binding window.__vrkbdKey("<op>:<arg>"), // Everything goes through the CDP binding window.__vrkbdKey("<op>:<arg>"),
// executed by helper.mjs with xdotool on :0. Every replaced function keeps its // 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. // Idempotent: safe to evaluate repeatedly.
(() => { ((find, sigs) => {
const VERSION = 9; const VERSION = 10;
const send = (msg) => window.__vrkbdKey && window.__vrkbdKey(msg); const send = (msg) => window.__vrkbdKey && window.__vrkbdKey(msg);
const wr = window.__vrkbdWr || let mods;
(webpackChunksteamui.push([[Symbol('vrkbd')], {}, (r) => { window.__vrkbdWr = r; }]), window.__vrkbdWr); try {
const Layouts = wr(40222); // keyboard layouts (exports G$: enabled, r_: current) mods = find.resolveAll(find.getWebpackRequire('webpackChunksteamui'), sigs);
const Status = wr(58508).qL; // .VRKeyboardStatus } 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; let changed = false;
// Replace obj[name] with make(original), once per VERSION. // Replace obj[name] with make(original), once per VERSION.
@@ -48,7 +63,7 @@
const same = (x) => [x, null, x]; const same = (x) => [x, null, x];
const MODS = [k('VKX_Escape', 'Esc'), k('Control', 'Ctrl'), k('Alt', 'Alt')]; 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 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]]); .map((a, i) => [{ ...a, type: HALF }, null, ALTGR_ARROWS[i]]);
const first = (x) => (Array.isArray(x) ? x.find((y) => y) : x); 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')); const isArrow = (x) => (Array.isArray(x) ? x : [x]).some((y) => y?.key?.startsWith?.('Arrow'));
@@ -64,7 +79,7 @@
return out; return out;
}; };
window.__vrkbdLayouts ??= new Set(); // for unpatch.js, incl. layouts disabled since 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; if (typeof l?.rgLayout !== 'function') continue;
window.__vrkbdLayouts.add(l); window.__vrkbdLayouts.add(l);
wrap(l, 'rgLayout', (orig) => (opts) => { wrap(l, 'rgLayout', (orig) => (opts) => {
@@ -100,9 +115,9 @@
// onEnterKeyPress returning "VKClose", so when the keyboard is then opened // onEnterKeyPress returning "VKClose", so when the keyboard is then opened
// for an app window, Enter shows "Suchen", runs the Steam search and hides // for an app window, Enter shows "Suchen", runs the Steam search and hides
// the keyboard instead of typing Return. While the keyboard serves // the keyboard instead of typing Return. While the keyboard serves
// something other than this Steam UI (Steam's own test, L() in module // something other than this Steam UI (the same test Steam uses for its
// 5363), ignore those props and the dismiss-on-Enter flag. // text dispatch, in the VirtualKeyboardManager's module), ignore those
const Manager = wr(5363).PE.prototype; // props and the dismiss-on-Enter flag.
const forOther = (m) => { const forOther = (m) => {
const s = Status.VRKeyboardStatus, ui = m.m_Instance; const s = Status.VRKeyboardStatus, ui = m.m_Instance;
return !!s?.bIsOpen && !(s.sOverlayKey && s.sOverlayKey === ui?.GetVROverlayKey?.()) && return !!s?.bIsOpen && !(s.sOverlayKey && s.sOverlayKey === ui?.GetVROverlayKey?.()) &&
@@ -126,10 +141,9 @@
.find((p) => p.window?.document.querySelector('[data-key]')); .find((p) => p.window?.document.querySelector('[data-key]'));
if (!kbPopup) return 'no keyboard popup yet'; if (!kbPopup) return 'no keyboard popup yet';
const doc = kbPopup.window.document; const doc = kbPopup.window.document;
const el = doc.querySelector('[data-key]'); // The keyboard component: nearest fiber above a key whose instance has TypeKeyInternal.
let fiber = el[Object.keys(el).find((x) => x.startsWith('__reactFiber'))]; const inst = find.findFiberUp(doc.querySelector('[data-key]'),
while (fiber && !fiber.stateNode?.TypeKeyInternal) fiber = fiber.return; (f) => typeof f.stateNode?.TypeKeyInternal === 'function', 200)?.stateNode;
const inst = fiber?.stateNode;
if (!inst) return 'no keyboard component'; if (!inst) return 'no keyboard component';
let proto = Object.getPrototypeOf(inst); let proto = Object.getPrototypeOf(inst);
while (!Object.prototype.hasOwnProperty.call(proto, 'TypeKeyInternal')) proto = Object.getPrototypeOf(proto); 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). // Re-render with the new row (only when something changed or it's a new instance).
if (changed || inst.__vrkbd !== VERSION) { if (changed || inst.__vrkbd !== VERSION) {
inst.__vrkbd = VERSION; inst.__vrkbd = VERSION;
inst.setState({ standardLayout: Layouts.r_() }); inst.setState({ standardLayout: Layouts.currentLayout() });
inst.forceUpdate(); inst.forceUpdate();
} }
return 'patched'; return 'patched';
})() })
+9 -7
View File
@@ -1,8 +1,11 @@
// Reverts patch.js in Steam's SharedJSContext; evaluated by helper.mjs // Reverts patch.js in Steam's SharedJSContext; evaluated by helper.mjs
// when it stops (service stopped, module disabled). Every patched function // 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 unwrap = (obj, name) => {
const f = obj?.[name]; const f = obj?.[name];
if (f?.__vrkbdOrig) obj[name] = f.__vrkbdOrig; if (f?.__vrkbdOrig) obj[name] = f.__vrkbdOrig;
@@ -10,18 +13,17 @@
for (const l of window.__vrkbdLayouts || []) unwrap(l, 'rgLayout'); for (const l of window.__vrkbdLayouts || []) unwrap(l, 'rgLayout');
unwrap(SteamClient.Input, 'ControllerKeyboardSendText'); unwrap(SteamClient.Input, 'ControllerKeyboardSendText');
unwrap(window.__vrkbdProto, 'TypeKeyInternal'); unwrap(window.__vrkbdProto, 'TypeKeyInternal');
const Manager = window.__vrkbdWr(5363).PE.prototype; unwrap(refs?.Manager, 'GetEnterKeyLabel');
unwrap(Manager, 'GetEnterKeyLabel'); unwrap(refs?.Manager, 'HandleVirtualKeyDown');
unwrap(Manager, 'HandleVirtualKeyDown');
clearInterval(window.__vrkbdHoldTimer); clearInterval(window.__vrkbdHoldTimer);
const inst = window.__vrkbdInst; const inst = window.__vrkbdInst;
if (inst) { if (inst) {
delete inst.__vrkbd; delete inst.__vrkbd;
inst.setState({ standardLayout: window.__vrkbdWr(40222).r_() }); if (refs?.currentLayout) inst.setState({ standardLayout: refs.currentLayout() });
inst.forceUpdate(); inst.forceUpdate();
} }
for (const p of g_PopupManager.GetPopups?.() || []) p.window?.document.getElementById('vrkbd-style')?.remove(); 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'; return 'unpatched';
})() })()
+26 -1
View File
@@ -12,6 +12,12 @@
# non-empty. It is restarted on every switch (changed patches are re-injected, # 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 # 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. # 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, ... }: { config, pkgs, lib, ... }:
let let
cfg = config.steamFrame.uiPatches; cfg = config.steamFrame.uiPatches;
@@ -58,7 +64,8 @@ let
Runtime.evaluate, awaited). Must be idempotent: it is re-evaluated Runtime.evaluate, awaited). Must be idempotent: it is re-evaluated
after page reloads and every 15 s. Its result value is logged when it after page reloads and every 15 s. Its result value is logged when it
changes; return e.g. "patched", and "unchanged" (never logged) when 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 { unpatch = mkOption {
@@ -88,6 +95,24 @@ let
in { in {
imports = [ ./session.nix ]; 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 { options.steamFrame.uiPatches.patches = mkOption {
type = types.listOf patchType; type = types.listOf patchType;
default = [ ]; default = [ ];
+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;
}