# UI patches For patch authors, and for fixing patches after a Steam update. User-level overview: README, [UI patches](../README.md#ui-patches-uipatchespatches); options `steamFrame.uiPatches.patches` and `steamFrame.uiPatches.lib`. 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](steamvr-debugger.md) 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](launcher-menu.md), [dashboard](dashboard.md) and [VR keyboard](keyboard.md) features are such patches (the extra keys have their own injector, `steam-keyboard-patch`), and you can add your own. Don't expose these ports: see [DevTools on the LAN](../README.md#devtools-on-the-lan). **Caveat:** patches depend on Steam UI internals and can break with an update; find modules by signature, not id ([below](#finders-and-signatures)). ## 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](#persistent-state). | ```nix 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/.json` (`$XDG_STATE_HOME`): - before each evaluation it defines `window.__sfuiStore.get(name)` / `.set(name, value)` in the page and fills `get` from the file only while the page has no value yet (a fresh page after a SteamVR restart, reboot or reload); - `set` goes through the DevTools binding `window.__sfuiStoreSave`; the service writes the file atomically, only on change, only for that page's `state` patches, at most 64 KiB. Used by the [window control bar](dashboard.md#window-control-bar) and the [Steam close button](dashboard.md#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](../README.md#changes-outside-nix-exceptions). ## 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`): ```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; } ]; ``` ```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](dashboard.md#dashboard-windows) and [window curvature](dashboard.md#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. ```js 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](dashboard.md#contract-for-other-patches) (`sfui-curv-*`). ## After a Steam update Check the signatures offline (Steam need not run) from a checkout: ```sh 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`.