Add steam-ui-patches (generic Steam UI runtime patches) and launcher-menu

steamFrame.uiPatches.patches registers JS patches that the
steam-ui-patches user service keeps injected into Steam's (or SteamVR's)
web UI over the local CEF DevTools ports and reverts when it stops.

steamFrame.launcherMenu uses it for the VR "+" menu: sort = true sorts
the programs by name; pinDesktop = "top" / "bottom" pins Desktop above or
below the scrolling list.
This commit is contained in:
Pierre Kisters committed 2026-09-27 19:36:12 +02:00
1 parent f7eadb9ac1
commit 24d6504f30
10 files changed
+628 -2

No files matched your search

+84 -2
View File
@@ -3,7 +3,8 @@
[Home Manager](https://github.com/nix-community/home-manager) modules for the
Valve Steam Frame: SteamOS on `aarch64-linux`, standalone home-manager on a
non-NixOS system. They work around quirks of the Frame's two graphical
sessions (portal config, keyboard layout, VR keyboard, clipboard, Firefox)
sessions (portal config, keyboard layout, VR keyboard, "+" menu, clipboard,
Firefox)
declaratively, so every change can be reverted by activating an older
home-manager generation.
@@ -126,6 +127,7 @@ the two files below).
steamFrame = {
keyboardLayout = "de";
steamKeyboardPatch.enable = true;
launcherMenu = { sort = true; pinDesktop = "bottom"; };
firefox.enable = true;
# Only relevant with Steam Developer Mode on (see hidden-apps below).
hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
@@ -149,7 +151,7 @@ home-manager switch --flake .#steamos
```
Individual modules are available as
`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,clipboard-sync,firefox}`;
`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,clipboard-sync,firefox}`;
`default` imports all of them.
**Steam Developer Mode** (a Steam setting, not managed here) makes the "+"
@@ -169,6 +171,9 @@ menu list every desktop entry, including terminals such as Konsole.
| `steamFrame.keyboardLayout` | null or str | `null` | XKB layout for the Steam session, e.g. `"de"`. `null` = no drop-in (US). |
| `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.uiPatches.patches` | list of submodules | `[ ]` | Runtime patches of Steam's web UIs over their local DevTools ports, see [UI patches](#ui-patches-uipatchespatches). |
| `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.hiddenApps` | list of str | `[ ]` | Desktop entry ids (without `.desktop`) to hide from the "+" and KDE menus. |
| `steamFrame.clipboardSync.enable` | bool | `true` | Clipboard bridge between the Steam session and the nested desktop. |
| `steamFrame.clipboardSync.package` | package | built from `dnut/clipboard-sync` | The clipboard-sync package. |
@@ -282,6 +287,83 @@ Tested with Steam client 1790377368.
**Remove when** Steam's VR keyboard gets these keys itself.
### UI patches (`uiPatches.patches`)
Steam's client UI (and SteamVR's dashboard, `vrwebhelper`) are web pages in
CEF with a local DevTools port: `127.0.0.1:8080` for Steam (SteamOS starts
it with `-cef-enable-debugging`), `127.0.0.1:8087` for SteamVR when its
debugger is enabled (`VRWebHelper/DebuggerEnabled` in
`steamvr.vrsettings`). The `steam-ui-patches` user service (`injector.mjs`,
Node) uses them to patch the running UI; Steam's files are never modified.
Each entry of `steamFrame.uiPatches.patches`:
| Attribute | Default | Description |
|---|---|---|
| `name` | | Unique name (log). |
| `endpoint` | `"http://127.0.0.1:8080"` | DevTools base URL; its `/json/list` is polled every 5 s. |
| `target.title` / `target.titleRegex` / `target.urlRegex` | `null` | Pages to patch: all given criteria must match (regexes are JavaScript). |
| `patch` | | JS file evaluated in every matching page (awaited). |
| `unpatch` | `null` | JS file evaluated when the service stops, reverting the patch. |
The injector keeps one DevTools session per matching page and evaluates its
patches right away, after the page creates new JS contexts (reloads,
debounced) and every 15 s, so patches must be idempotent: return e.g.
`"patched"` once and `"unchanged"` (not logged) afterwards; other results
are logged when they change (`journalctl --user -u steam-ui-patches`). On
stop it evaluates the `unpatch` files, so the UI is stock again without a
Steam restart. The service only exists while the list is non-empty; it is
restarted on every switch (changed patches re-injected, removed ones
reverted) and stopped once the list is empty.
```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;
} ];
```
**DevTools on the LAN:** SteamOS images also forward these ports to all
interfaces: `steam-web-debug-portforward.service` (`0.0.0.0:8081` →
`8080`) and `steamvr-web-debug-portforward.service` (`0.0.0.0:8088` →
`8087`), and firewalld's `public` zone allows ports 1024-65535. Anyone on
the same network can then run code in Steam's UI. Masking both units is
recommended; it is a system-level change, outside Home Manager (e.g. with
[system-manager](https://github.com/numtide/system-manager): links
`/etc/systemd/system/<unit>` → `/dev/null`). The injector itself only uses
`127.0.0.1`.
**Caveat:** patches depend on Steam UI internals and can break with a Steam
update.
### Launcher menu (`launcherMenu.*`)
**Problem:** the VR dashboard's "+" menu (non-Steam programs) lists programs
in the order `SteamClient.Apps.ScanForInstalledNonSteamApps()` returns them:
GLib hash-table order, effectively random and changing with installed apps,
with "Desktop" (the nested Plasma session) somewhere in the middle of a
scrolling list.
**Fix:** UI patches (see above) in Steam's `SharedJSContext`:
- `sort = true`: a wrapper around `ScanForInstalledNonSteamApps` sorts the
programs by name (case-insensitive), Desktop included.
- `pinDesktop = "top"` / `"bottom"`: Desktop is hidden in the scrolling list
and pinned above it (right below the menu heading) or below it, separated
by a thin line, so it is always visible without scrolling. The menu keeps
its size (the scrolling list gets shorter). Clicking the pinned copy clicks
the hidden original, so Steam's own launch handler runs. `null` (default)
leaves Desktop a normal list entry.
**Limitation:** the pinned copy is a plain DOM element, not part of Steam's
controller navigation: it works with the laser pointer, but thumbstick /
D-pad focus can't reach it.
Both are reverted when the options are turned off (next switch).
Tested with Steam client 1790377368.
### Hidden apps (`hiddenApps`)
**Problem:** with Steam Developer Mode on, the "+" menu lists every desktop
+2
View File
@@ -17,6 +17,8 @@
keyboard-layout = ./modules/keyboard-layout.nix;
steam-keyboard-patch = ./modules/steam-keyboard-patch.nix;
hidden-apps = ./modules/hidden-apps.nix;
steam-ui-patches = ./modules/steam-ui-patches.nix;
launcher-menu = ./modules/launcher-menu.nix;
# Built with the consumer's pkgs; only the source comes from our input.
# The key lets the module system deduplicate it when it is imported both
# directly and through `default`.
+58
View File
@@ -0,0 +1,58 @@
# The VR dashboard's "+" menu (non-Steam programs, #VRDashboard_LaunchNonSteamApp)
# lists programs in the order SteamClient.Apps.ScanForInstalledNonSteamApps()
# returns them: GLib hash-table order, effectively random, with "Desktop" (the
# nested Plasma session) somewhere in a scrolling list. Two runtime patches of
# Steam's UI (steam-ui-patches.nix, evaluated in SharedJSContext) fix that:
# - order/: wraps ScanForInstalledNonSteamApps to sort the list by name;
# - pinned-desktop/: hides Desktop in the scrolling list and pins a copy above
# or below it, with a separator; clicking the copy clicks the hidden
# original. The position is passed by calling the patch's function.
# Both are reverted when turned off (next switch).
# Depends on Steam UI internals; tested with Steam client 1790377368.
{ config, lib, pkgs, ... }:
let
cfg = config.steamFrame.launcherMenu;
sharedJSContext = { title = "SharedJSContext"; };
in {
imports = [ ./steam-ui-patches.nix ];
options.steamFrame.launcherMenu = {
sort = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Sort the VR dashboard's "+" menu (non-Steam programs) alphabetically
instead of Steam's random-looking order. Runtime patch of Steam's UI
(steamFrame.uiPatches).
'';
};
pinDesktop = lib.mkOption {
type = lib.types.nullOr (lib.types.enum [ "top" "bottom" ]);
default = null;
example = "bottom";
description = ''
Pin "Desktop" (the nested Plasma session) above ("top", below the
menu heading) or below ("bottom") the scrolling program list of the
"+" menu, separated by a thin line, so it is always visible; it is
hidden in the list itself. null leaves it a normal list entry. Runtime
patch of Steam's UI (steamFrame.uiPatches).
'';
};
};
config.steamFrame.uiPatches.patches =
lib.optional cfg.sort {
name = "launcher-menu-order";
target = sharedJSContext;
patch = ./launcher-menu/order/patch.js;
unpatch = ./launcher-menu/order/unpatch.js;
}
++ lib.optional (cfg.pinDesktop != null) {
name = "launcher-menu-pinned-desktop";
target = sharedJSContext;
patch = pkgs.writeText "launcher-menu-pinned-desktop.js" ''
(${builtins.readFile ./launcher-menu/pinned-desktop/patch.js})(${builtins.toJSON { position = cfg.pinDesktop; }})
'';
unpatch = ./launcher-menu/pinned-desktop/unpatch.js;
};
}
+27
View File
@@ -0,0 +1,27 @@
// launcher-menu order: sorts the VR dashboard's "+" menu (non-Steam programs)
// alphabetically (case-insensitive), Desktop included.
// Steam renders SteamClient.Apps.ScanForInstalledNonSteamApps() in the order
// it returns (GLib hash-table order, i.e. random-ish); its hook looks the
// function up at call time, so wrapping it here in SharedJSContext is enough.
// The original is kept as __sfuiOrig (unpatch.js restores it). Idempotent;
// bump VERSION when changing the wrapper.
(() => {
const VERSION = 3;
const NAME = 'launcher-menu-order';
const Apps = window.SteamClient?.Apps;
const cur = Apps?.ScanForInstalledNonSteamApps;
if (typeof cur !== 'function') return 'SteamClient.Apps.ScanForInstalledNonSteamApps missing';
if (cur.__sfuiPatch === NAME && cur.__sfuiVersion === VERSION) return 'unchanged';
const orig = cur.__sfuiPatch === NAME ? cur.__sfuiOrig : cur;
const name = (a) => String(a?.strAppName ?? '');
const sort = (list) => Array.isArray(list)
? [...list].sort((a, b) => name(a).localeCompare(name(b), undefined, { sensitivity: 'base' }))
: list;
const f = function ScanForInstalledNonSteamApps(...args) {
return Promise.resolve(orig.apply(this, args)).then(sort);
};
f.__sfuiPatch = NAME; f.__sfuiVersion = VERSION; f.__sfuiOrig = orig;
Apps.ScanForInstalledNonSteamApps = f;
return 'patched';
})()
+9
View File
@@ -0,0 +1,9 @@
// Reverts order/patch.js (restores the original
// SteamClient.Apps.ScanForInstalledNonSteamApps). Safe when not patched.
(() => {
const Apps = window.SteamClient?.Apps;
const f = Apps?.ScanForInstalledNonSteamApps;
if (f?.__sfuiPatch !== 'launcher-menu-order') return 'not patched';
Apps.ScanForInstalledNonSteamApps = f.__sfuiOrig;
return 'unpatched';
})()
@@ -0,0 +1,163 @@
// pinned-desktop: in the VR dashboard's "+" menu (section
// #VRDashboard_LaunchNonSteamApp), "Desktop" (the nested Plasma session) is
// pinned above or below the scrolling program list instead of scrolling with
// it.
//
// This file is a function expression; launcher-menu.nix calls it with the
// options: (<this file>)({ position: "top" }) or ({ position: "bottom" }).
//
// DOM patch, evaluated in SharedJSContext (the bar popups share its realm):
// a timer attaches a MutationObserver to every dashboard bar popup document
// (g_PopupManager); whenever the menu renders, the stock Desktop item (found
// through its React fiber: list key == strExePath "steamos-nested-desktop")
// is hidden by CSS and a pinned block is inserted into the list container:
// "bottom" appends it after the scroll region (1px separator, then a clone of
// the item), "top" inserts it right before the scroll region, i.e. below the
// menu heading (clone, then separator). The clone keeps the item's classes,
// icon and CSS :hover; clicking it clicks the hidden stock item, so Steam's
// own handler runs (nav sound + SteamClient.Apps.LaunchNonSteamApp). The
// list container is a flex column with a max-height, so the scroll region
// shrinks to make room and the menu keeps its size.
// unpatch.js (or a new VERSION/position) calls __sfuiPinnedDesktop.stop(),
// which removes the pinned blocks, CSS, markers, observers and the timer.
((opts) => {
const VERSION = 1;
const POS = opts.position === 'top' ? 'top' : 'bottom';
const KEY = 'steamos-nested-desktop';
const HIDDEN = 'data-sfui-desktop-hidden';
const PIN = 'sfui-pinned-desktop';
const STYLE_ID = 'sfui-pinned-desktop-style';
const CSS = `
[${HIDDEN}] { display: none !important; }
.${PIN} { flex: 0 0 auto; }
.${PIN} > .${PIN}-sep { height: 1px; background: rgba(255, 255, 255, 0.1); }
.${PIN} > [role=button]::after { display: none !important; }`;
const prev = window.__sfuiPinnedDesktop;
if (prev?.version === VERSION && prev.position === POS) { prev.scan(); return 'unchanged'; }
prev?.stop?.();
window.__sfuiDesktopFooter?.stop?.(); // predecessor of this patch
if (!window.g_PopupManager) return 'g_PopupManager missing';
const docs = new Map(); // popup document -> MutationObserver
const fiberOf = (el) => { const k = Object.keys(el).find((k) => k.startsWith('__reactFiber$')); return k && el[k]; };
// The stock Desktop item: role=button element whose fiber ancestors include
// the list entry keyed by the program's exe path.
const isDesktop = (el) => {
for (let f = fiberOf(el), i = 0; f && i < 12; f = f.return, i++)
if (f.key != null) return f.key === KEY || String(f.key).endsWith('/' + KEY);
return false;
};
// The list container (host div of the section component, which has a `header` prop).
const listOf = (el) => {
for (let f = fiberOf(el), i = 0; f && i < 60; f = f.return, i++) {
if (f.memoizedProps && 'header' in f.memoizedProps && typeof f.type === 'function') {
let c = f.child;
while (c && typeof c.type !== 'string') c = c.child;
return c?.stateNode ?? null;
}
}
return null;
};
// Child of the list container that holds the (scrolling) items.
const scrollerOf = (orig, list) => {
let el = orig;
while (el && el.parentElement !== list) el = el.parentElement;
return el;
};
const buildPin = (d, orig) => {
const w = d.defaultView;
const pin = d.createElement('div');
pin.className = PIN;
const panel = orig.parentElement; // scroll panel: copy its side padding
const cs = w.getComputedStyle(panel);
pin.style.paddingLeft = cs.paddingLeft;
pin.style.paddingRight = cs.paddingRight;
if (POS === 'top') pin.style.paddingTop = cs.paddingTop;
else pin.style.paddingBottom = cs.paddingBottom;
const margin = cs.getPropertyValue('--field-negative-horizontal-margin');
if (margin) pin.style.setProperty('--field-negative-horizontal-margin', margin);
const sep = d.createElement('div');
sep.className = `${PIN}-sep`;
const item = orig.cloneNode(true);
item.removeAttribute(HIDDEN);
item.classList.remove('gpfocus', 'gpfocuswithin');
item.addEventListener('click', (e) => {
// Not to React's root listener (the clone has no fiber); the stock item
// gets its own click instead.
e.stopPropagation();
e.preventDefault();
if (orig.isConnected) orig.click();
});
if (POS === 'top') pin.append(item, sep); else pin.append(sep, item);
pin.__sfuiOrig = orig;
pin.__sfuiLabel = orig.textContent;
return pin;
};
const update = (d) => {
const pins = [...d.getElementsByClassName(PIN)];
const hidden = d.querySelector(`[${HIDDEN}]`);
let orig = hidden?.isConnected && isDesktop(hidden) ? hidden : null;
if (!orig) {
for (const el of d.querySelectorAll('[role=button]'))
if (!el.closest('.' + PIN) && isDesktop(el)) { orig = el; break; }
}
for (const el of d.querySelectorAll(`[${HIDDEN}]`)) if (el !== orig) el.removeAttribute(HIDDEN);
const list = orig && listOf(orig);
const scroller = list && scrollerOf(orig, list);
const placed = (p) => POS === 'top' ? p.nextElementSibling === scroller : list.lastElementChild === p;
const ok = (p) => p.__sfuiOrig === orig && p.parentElement === list && placed(p) &&
p.__sfuiLabel === orig.textContent;
let keep = null;
for (const p of pins) if (!scroller || keep || !ok(p)) p.remove(); else keep = p;
if (!scroller) return;
if (!orig.hasAttribute(HIDDEN)) orig.setAttribute(HIDDEN, '');
if (!keep) {
const pin = buildPin(d, orig);
if (POS === 'top') list.insertBefore(pin, scroller); else list.appendChild(pin);
}
};
const attach = (d) => {
if (docs.has(d) || !d?.body) return;
if (!d.getElementById(STYLE_ID)) {
const s = d.createElement('style');
s.id = STYLE_ID; s.textContent = CSS;
(d.head ?? d.documentElement).appendChild(s);
}
const obs = new MutationObserver(() => { try { update(d); } catch (e) { console.error('sfui pinned-desktop:', e); } });
obs.observe(d.body, { childList: true, subtree: true, characterData: true });
docs.set(d, obs);
update(d);
};
const scan = () => {
for (const [d, obs] of docs) if (!d.defaultView || d.defaultView.closed) { obs.disconnect(); docs.delete(d); }
for (const p of g_PopupManager.GetPopups()) {
if (!/barpopup/.test(p.m_strName ?? '')) continue;
try { attach(p.window?.document); } catch (e) { console.error('sfui pinned-desktop:', e); }
}
};
const timer = setInterval(scan, 1000);
const stop = () => {
clearInterval(timer);
for (const [d, obs] of docs) {
obs.disconnect();
try {
for (const p of [...d.getElementsByClassName(PIN)]) p.remove();
for (const el of d.querySelectorAll(`[${HIDDEN}]`)) el.removeAttribute(HIDDEN);
d.getElementById(STYLE_ID)?.remove();
} catch {}
}
docs.clear();
if (window.__sfuiPinnedDesktop === state) delete window.__sfuiPinnedDesktop;
};
const state = { version: VERSION, position: POS, scan, stop, docs };
window.__sfuiPinnedDesktop = state;
scan();
return `patched (${POS})`;
})
@@ -0,0 +1,8 @@
// Reverts pinned-desktop/patch.js (pinned blocks, CSS, markers, observers,
// timer). Safe when not patched.
(() => {
const st = window.__sfuiPinnedDesktop;
if (!st) return 'not patched';
st.stop();
return 'unpatched';
})()
+143
View File
@@ -0,0 +1,143 @@
# Runtime patches of Steam's (and SteamVR's) web UIs through their local CEF
# DevTools ports: Steam's client UI on 127.0.0.1:8080 (SteamOS starts Steam
# with -cef-enable-debugging), SteamVR's vrwebhelper on 127.0.0.1:8087 when
# its debugger is enabled. Steam's files are untouched: the
# `steam-ui-patches` service (injector.mjs, Node) evaluates each registered
# patch in its target pages, re-injects it when a page is reloaded or
# recreated (and every 15 s), and evaluates its unpatch expression when the
# service stops, so the UI is back to stock without a Steam restart.
#
# Other modules (and your own config) register patches in
# `steamFrame.uiPatches.patches`; the service exists only while that list is
# non-empty. It is restarted on every switch (changed patches are re-injected,
# removed ones reverted by the old instance) and stopped once the list is
# empty. The keyboard patch (steam-keyboard-patch.nix) has its own helper.
{ config, pkgs, lib, ... }:
let
cfg = config.steamFrame.uiPatches;
inherit (lib) mkOption types;
patchType = types.submodule {
options = {
name = mkOption {
type = types.strMatching "[A-Za-z0-9_.-]+";
example = "launcher-menu-order";
description = "Unique name, used in the service's log.";
};
endpoint = mkOption {
type = types.str;
default = "http://127.0.0.1:8080";
example = "http://127.0.0.1:8087";
description = ''
DevTools base URL of the CEF instance (its /json/list is polled).
Steam's client UI is on port 8080, SteamVR's vrwebhelper on 8087.
'';
};
target = {
title = mkOption {
type = types.nullOr types.str;
default = null;
example = "SharedJSContext";
description = "Exact page title of the targets to patch.";
};
titleRegex = mkOption {
type = types.nullOr types.str;
default = null;
description = "JavaScript regex the page title must match.";
};
urlRegex = mkOption {
type = types.nullOr types.str;
default = null;
description = "JavaScript regex the page URL must match.";
};
};
patch = mkOption {
type = types.path;
description = ''
JavaScript file evaluated in every matching page (DevTools
Runtime.evaluate, awaited). Must be idempotent: it is re-evaluated
after page reloads and every 15 s. Its result value is logged when it
changes; return e.g. "patched", and "unchanged" (never logged) when
already applied.
'';
};
unpatch = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
JavaScript file evaluated in every patched page when the service
stops (switch, list emptied, logout), reverting the patch. Must be
safe when the page isn't patched.
'';
};
};
};
entry = p: {
inherit (p) name endpoint patch unpatch;
target = lib.filterAttrs (_: v: v != null) p.target;
};
configFile = pkgs.writeText "steam-ui-patches.json" (builtins.toJSON {
pollMs = 5000;
reinjectMs = 15000;
debounceMs = 3000;
unpatchTimeoutMs = 2000;
patches = map entry cfg.patches;
});
in {
imports = [ ./session.nix ];
options.steamFrame.uiPatches.patches = mkOption {
type = types.listOf patchType;
default = [ ];
example = lib.literalExpression ''
[ {
name = "my-patch";
target.title = "SharedJSContext";
patch = ./my-patch/patch.js;
unpatch = ./my-patch/unpatch.js;
} ]
'';
description = ''
Runtime patches of Steam's web UIs, kept injected by the
`steam-ui-patches` user service over the local CEF DevTools ports and
reverted when it stops. Every target (page) of the endpoint matching all
given target criteria is patched.
'';
};
config = lib.mkMerge [
(lib.mkIf (cfg.patches != [ ]) {
assertions = [ {
assertion = lib.allUnique (map (p: p.name) cfg.patches);
message = "steamFrame.uiPatches.patches: names must be unique.";
} ];
systemd.user.services.steam-ui-patches = {
Unit.Description = "Runtime patches of Steam's UI (CEF DevTools)";
Service = {
ExecStart = lib.escapeShellArgs [
"${pkgs.nodejs}/bin/node"
"${./steam-ui-patches/injector.mjs}"
"${configFile}"
];
Restart = "always";
RestartSec = 5;
TimeoutStopSec = 5; # the injector unpatches Steam's UI on SIGTERM
};
Install.WantedBy = [ "default.target" ];
};
# Restart on every switch so changed patches are re-injected (each
# replaces its older version) and removed ones are reverted by the old
# instance.
steamFrame.userServices.restart = [ "steam-ui-patches.service" ];
})
# No patches: stop a still-running injector, which reverts its patches, so
# Steam's UI is stock right away.
(lib.mkIf (cfg.patches == [ ]) {
steamFrame.userServices.stop = [ "steam-ui-patches.service" ];
})
];
}
+132
View File
@@ -0,0 +1,132 @@
// injector.mjs: keeps runtime patches injected into Steam's UI (CEF) through
// Chrome DevTools, and reverts them when stopped.
// usage: node injector.mjs <config.json>
//
// config.json (generated by steam-ui-patches.nix):
// { "pollMs": 5000, // target discovery / reconnect interval
// "reinjectMs": 15000, // periodic re-injection
// "debounceMs": 3000, // delay after a burst of new JS contexts
// "unpatchTimeoutMs": 2000,
// "patches": [ { "name": "launcher-menu-order",
// "endpoint": "http://127.0.0.1:8080",
// "target": { "title": "SharedJSContext" }, // or titleRegex / urlRegex
// "patch": "/nix/store/...-patch.js",
// "unpatch": "/nix/store/...-unpatch.js" } ] } // unpatch optional
//
// Every target (page of an endpoint's /json/list) matched by at least one
// patch gets one DevTools session, which evaluates all of its patches: right
// away, after new execution contexts (debounced) and every reinjectMs.
// Patches must be idempotent expressions; their (awaited) result value is
// logged when it changes, e.g. "patched" once, then "unchanged" silently.
// On SIGTERM/SIGINT every live session evaluates its patches' unpatch
// expressions (if any) (each with a timeout), then the process exits.
import { readFileSync } from 'node:fs';
const cfg = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const POLL = cfg.pollMs ?? 5000;
const REINJECT = cfg.reinjectMs ?? 15000;
const DEBOUNCE = cfg.debounceMs ?? 3000;
const UNPATCH_TIMEOUT = cfg.unpatchTimeoutMs ?? 2000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const patches = cfg.patches.map((p) => {
const t = p.target ?? {};
const title = t.title, titleRe = t.titleRegex && new RegExp(t.titleRegex), urlRe = t.urlRegex && new RegExp(t.urlRegex);
return {
name: p.name,
endpoint: (p.endpoint ?? 'http://127.0.0.1:8080').replace(/\/+$/, ''),
matches: (x) => (title === undefined || x.title === title) &&
(!titleRe || titleRe.test(x.title)) && (!urlRe || urlRe.test(x.url)),
patch: readFileSync(p.patch, 'utf8'),
unpatch: p.unpatch ? readFileSync(p.unpatch, 'utf8') : null,
};
});
const sessions = new Map(); // "<endpoint> <target id>" -> session
let stopping = false;
// Result value of a Runtime.evaluate reply, or the exception text.
const value = (r) => r.error?.message ?? r.result?.exceptionDetails?.exception?.description ??
r.result?.exceptionDetails?.text ?? r.result?.result?.value ?? r.result?.result?.type ?? 'no reply';
function open(endpoint, target, list) {
const key = `${endpoint} ${target.id}`;
const tag = `[${target.title}]`;
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0, soon = null, timer = null, connected = false;
const pending = new Map();
const last = new Map(); // patch name -> last logged result
const call = (method, params = {}) => new Promise((res) => {
if (ws.readyState !== WebSocket.OPEN) return res({ error: { message: 'closed' } });
const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params }));
});
const evaluate = (expression) => call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
const inject = async () => {
for (const p of list) {
const v = String(value(await evaluate(p.patch)));
if (last.get(p.name) !== v && v !== 'unchanged') console.log(`${tag} ${p.name}: ${v}`);
last.set(p.name, v);
}
};
const injectSoon = () => { clearTimeout(soon); soon = setTimeout(() => inject().catch(() => {}), DEBOUNCE); };
const unpatch = () => Promise.all(list.filter((p) => p.unpatch).map(async (p) => {
const r = await Promise.race([evaluate(p.unpatch), sleep(UNPATCH_TIMEOUT).then(() => ({ error: { message: 'timeout' } }))]);
console.log(`${tag} ${p.name} unpatch: ${value(r)}`);
}));
const session = { unpatch };
sessions.set(key, session);
ws.onopen = async () => {
connected = true;
console.log(`${tag} connected (${endpoint}): ${list.map((p) => p.name).join(', ')}`);
await call('Runtime.enable');
await inject().catch((e) => console.error(`${tag} inject:`, e.message));
timer = setInterval(() => inject().catch(() => {}), REINJECT);
};
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
else if (m.method === 'Runtime.executionContextCreated' && !stopping) injectSoon();
};
ws.onerror = () => {}; // followed by onclose
ws.onclose = () => {
if (sessions.get(key) === session) sessions.delete(key);
clearTimeout(soon); clearInterval(timer);
for (const r of pending.values()) r({ error: { message: 'closed' } });
pending.clear();
if (connected) console.log(`${tag} disconnected`);
};
}
// Discovery: connect to every matching target that has no session yet.
// Sessions of vanished targets end on their own (socket closes).
const down = new Set();
async function poll(endpoint, list) {
let targets;
try {
targets = await (await fetch(`${endpoint}/json/list`, { signal: AbortSignal.timeout(3000) })).json();
if (down.delete(endpoint)) console.log(`${endpoint} up`);
} catch (e) {
if (!down.has(endpoint)) { down.add(endpoint); console.error(`${endpoint} unreachable: ${e.message}`); }
return;
}
for (const t of targets) {
if (stopping || !t.webSocketDebuggerUrl || sessions.has(`${endpoint} ${t.id}`)) continue;
const matching = list.filter((p) => p.matches(t));
if (matching.length) open(endpoint, t, matching);
}
}
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, async () => {
if (stopping) return;
stopping = true;
await Promise.all([...sessions.values()].map((s) => s.unpatch()));
setTimeout(() => process.exit(0), 200);
});
const endpoints = [...new Set(patches.map((p) => p.endpoint))];
console.log(`patches: ${patches.map((p) => `${p.name} @ ${p.endpoint}`).join(', ')}`);
while (!stopping) {
await Promise.all(endpoints.map((ep) => poll(ep, patches.filter((p) => p.endpoint === ep))));
await sleep(POLL);
}
+2
View File
@@ -17,6 +17,8 @@
# steamFrame = {
# keyboardLayout = "de"; # XKB layout for the Steam session
# steamKeyboardPatch.enable = true; # Esc/Ctrl/Alt/arrows on the VR keyboard
# # VR "+" menu: sorted by name, Desktop pinned below the list:
# launcherMenu = { sort = true; pinDesktop = "bottom"; };
# firefox.enable = true; # launcher for the Firefox Flatpak
# # Hidden from the "+" menu (with Steam Developer Mode on):
# hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];