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

+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);
}