One page per feature group (sessions, keyboard, launcher menu, dashboard, SteamVR debugger, Firefox, Jellyfin, UI patches, changes outside Nix) and the full options table in docs/options.md.
7.8 KiB
UI patches
For patch authors, and for fixing patches after a Steam update. Options: options.md#ui-patches.
Steam's UI and SteamVR's dashboard (vrwebhelper) are CEF web pages with a
local DevTools port: 127.0.0.1:8080 for Steam (SteamOS passes
-cef-enable-debugging), 127.0.0.1:8087 for SteamVR once
its debugger is on. The steam-ui-patches user
service (injector.mjs) patches the running pages through them; Steam's
files are never modified. The launcher menu,
dashboard and VR keyboard
features are such patches, and you can add your own.
Caveat: patches depend on Steam UI internals and can break with an update; find modules by signature, not id (below).
Defining a patch
steamFrame.uiPatches.patches entries:
| Attribute | Default | Description |
|---|---|---|
name |
Unique name (log). | |
endpoint |
"http://127.0.0.1:8080" |
DevTools base URL; /json/list is polled every 5 s. |
target.title / target.titleRegex / target.urlRegex |
null |
Pages to patch; all given criteria must match (JS regexes). |
patch |
JS file evaluated in every matching page (awaited). | |
unpatch |
null |
JS file evaluated when the service stops. |
state |
false |
Give the patch one persistent JSON value. |
steamFrame.uiPatches.patches = [ {
name = "my-patch";
target.title = "SharedJSContext"; # Steam's main JS context
patch = ./my-patch/patch.js;
unpatch = ./my-patch/unpatch.js;
} ];
Patches are evaluated on attach, after new JS contexts (reloads) and every
15 s, so they must be idempotent: return e.g. "patched" once, then
"unchanged" (not logged); other results are logged when they change
(journalctl --user -u steam-ui-patches). On stop, unpatch restores the
stock UI without a Steam restart. The service exists only while the list is
non-empty and is restarted on every switch.
Persistent state
With state = true: a patch's choices made in the UI can't be kept in the
page's own storage, since SteamOS's steamvr.service runs
rm -rf ~/.cache/SteamVR (vrwebhelper's browser profile, incl.
localStorage) on every SteamVR start. So the service keeps one JSON value
per such patch in ~/.local/state/steam-frame-nix/ui-patches/<name>.json
($XDG_STATE_HOME):
- before each evaluation it defines
window.__sfuiStore.get(name)/.set(name, value)in the page and fillsgetfrom the file only while the page has no value yet (a fresh page after a SteamVR restart, reboot or reload); setgoes through the DevTools bindingwindow.__sfuiStoreSave; the service writes the file atomically, only on change, only for that page'sstatepatches, at most 64 KiB.
Used by the window control bar and the
Steam close button. The file is user
data, not generated by Nix: it is kept when the patch is disabled or removed
(the choices come back when you enable it again) and removed only by
steam-frame-nix-cleanup --all or install.sh uninstall; see
changes outside Nix.
DevTools on the LAN
Steam's Developer Mode enables steam-web-debug-portforward
(0.0.0.0:8081 → 8080) and steamvr-web-debug-portforward
(0.0.0.0:8088 → 8087), and firewalld allows ports 1024-65535, so anyone
on the network could run code in Steam's UI. No patch needs Developer Mode,
so keep it off. If it was on while those units were masked, they may stay
enabled: check with
systemctl is-enabled steam-web-debug-portforward steamvr-web-debug-portforward
and sudo systemctl disable them.
Finders and signatures
Webpack module ids and export names change with every Steam UI build, so
patches never use them. modules/lib/finders.js (like Decky Loader's
findModule/findInReactTree or Vencord's find) locates by signature:
- a module by strings/regexes in its factory source;
- an export by shape: type, function source, arity, data properties, prototype methods or getters;
- React fibers by props (
findFiberUp,findFiberDown,findInReactTree).
Every signature must match exactly once, otherwise the patch changes nothing
and reports it (e.g. signature not found, Steam left unpatched: layouts.currentLayout (module 40222): ambiguous export, candidates r_, xy).
Results are cached per page.
Signatures live in modules/lib/signatures.json, shared by patches and the
offline checker. An entry can also list expects (strings the patch relies
on, checked offline only) or be checkOnly (anchors not used through the
finder, checked offline only; with stylesheet instead of module it is
matched against the bundle's CSS).
mkPatch
steamFrame.uiPatches.lib.mkPatch wraps a patch file — a function
expression (find, sigs, opts, hooks) => … returning a status string — with
the finder library, its signatures, options and the shared hooks (details in
modules/lib/default.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, hooks) => { … })
signatures.thing = {
module.includes = [ "SomeUniqueString" ];
exports.Thing = { type = "class"; protoMethods = [ "DoIt" ]; };
};
opts.factor = 2;
};
unpatch = ./my-patch/unpatch.js;
} ];
((find, sigs, opts, hooks) => {
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'
…
})
Shared method hooks
modules/lib/hooks.js, argument hooks, also window.__sfuiHooks: patches
intercepting the same method (e.g. the dashboard mailbox's SendMessage,
used by dashboard windows and
window curvature) register named hooks; one
wrapper per method runs them in registration order, so patches can be
injected, upgraded and reverted in any order.
hooks.before(Mailbox.prototype, 'SendMessage', 'my-patch', (args) => {
if (args[1]?.type === 'update_scene_graph') rewrite(args[1].scene_graph);
});
hooks.remove(Mailbox.prototype, 'SendMessage', 'my-patch'); // in unpatch
Patches reacting to presses on the curvature controls follow the
window curvature contract (sfui-curv-*).
After a Steam update
Check the signatures offline (Steam need not run) from a checkout:
nix shell nixpkgs#nodejs -c node scripts/check-signatures.mjs
It loads Steam's UI bundle (~/.local/share/Steam/steamui) and SteamVR's
dashboard (/opt/steamvr/resources/webinterface/dashboard/systemui.html),
evaluates every signature with the patches' finder code (exports in an inert
sandbox) and prints per patch found (module id, export name), ambiguous
or missing, plus 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
To fix a signature, inspect the new module sources
(scripts/webpack-modules.mjs), adjust signatures.json and bump the
patch's VERSION if its code changes. Flags: --signatures FILE (your own,
bundles steamui / vrwebui-systemui), --dir steamui=DIR,
--patch NAME, --strict (fail on warnings), --json. Live:
journalctl --user -u steam-keyboard-patch -u steam-ui-patches.