From 24d6504f3044bcabe822010783ee588a76e5980d Mon Sep 17 00:00:00 2001 From: Pierre Kisters <1524059+lhns@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:36:12 +0200 Subject: [PATCH] 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. --- README.md | 86 ++++++++- flake.nix | 2 + modules/launcher-menu.nix | 58 +++++++ modules/launcher-menu/order/patch.js | 27 +++ modules/launcher-menu/order/unpatch.js | 9 + modules/launcher-menu/pinned-desktop/patch.js | 163 ++++++++++++++++++ .../launcher-menu/pinned-desktop/unpatch.js | 8 + modules/steam-ui-patches.nix | 143 +++++++++++++++ modules/steam-ui-patches/injector.mjs | 132 ++++++++++++++ template/home.nix | 2 + 10 files changed, 628 insertions(+), 2 deletions(-) create mode 100644 modules/launcher-menu.nix create mode 100644 modules/launcher-menu/order/patch.js create mode 100644 modules/launcher-menu/order/unpatch.js create mode 100644 modules/launcher-menu/pinned-desktop/patch.js create mode 100644 modules/launcher-menu/pinned-desktop/unpatch.js create mode 100644 modules/steam-ui-patches.nix create mode 100644 modules/steam-ui-patches/injector.mjs diff --git a/README.md b/README.md index 5fedce2..aa5dd30 100644 --- a/README.md +++ b/README.md @@ -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/` → `/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 diff --git a/flake.nix b/flake.nix index bc01a03..60f198a 100644 --- a/flake.nix +++ b/flake.nix @@ -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`. diff --git a/modules/launcher-menu.nix b/modules/launcher-menu.nix new file mode 100644 index 0000000..cf85c46 --- /dev/null +++ b/modules/launcher-menu.nix @@ -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; + }; +} diff --git a/modules/launcher-menu/order/patch.js b/modules/launcher-menu/order/patch.js new file mode 100644 index 0000000..37677c9 --- /dev/null +++ b/modules/launcher-menu/order/patch.js @@ -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'; +})() diff --git a/modules/launcher-menu/order/unpatch.js b/modules/launcher-menu/order/unpatch.js new file mode 100644 index 0000000..a03d934 --- /dev/null +++ b/modules/launcher-menu/order/unpatch.js @@ -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'; +})() diff --git a/modules/launcher-menu/pinned-desktop/patch.js b/modules/launcher-menu/pinned-desktop/patch.js new file mode 100644 index 0000000..f2afcb1 --- /dev/null +++ b/modules/launcher-menu/pinned-desktop/patch.js @@ -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: ()({ 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})`; +}) diff --git a/modules/launcher-menu/pinned-desktop/unpatch.js b/modules/launcher-menu/pinned-desktop/unpatch.js new file mode 100644 index 0000000..951e408 --- /dev/null +++ b/modules/launcher-menu/pinned-desktop/unpatch.js @@ -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'; +})() diff --git a/modules/steam-ui-patches.nix b/modules/steam-ui-patches.nix new file mode 100644 index 0000000..cfc85b4 --- /dev/null +++ b/modules/steam-ui-patches.nix @@ -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" ]; + }) + ]; +} diff --git a/modules/steam-ui-patches/injector.mjs b/modules/steam-ui-patches/injector.mjs new file mode 100644 index 0000000..492e01f --- /dev/null +++ b/modules/steam-ui-patches/injector.mjs @@ -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 (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(); // " " -> 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); +} diff --git a/template/home.nix b/template/home.nix index 5aaa281..04db924 100644 --- a/template/home.nix +++ b/template/home.nix @@ -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" ];