mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 06:00:22 +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
@@ -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`)
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
# 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"
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
})()
|
})
|
||||||
@@ -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';
|
||||||
})()
|
})()
|
||||||
@@ -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 = [ ];
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user