mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 04:04:16 +02:00
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:
1 parent
f7eadb9ac1
commit
24d6504f30
10 files changed
+628
-2
No files matched your search
@@ -3,7 +3,8 @@
|
|||||||
[Home Manager](https://github.com/nix-community/home-manager) modules for the
|
[Home Manager](https://github.com/nix-community/home-manager) modules for the
|
||||||
Valve Steam Frame: SteamOS on `aarch64-linux`, standalone home-manager on a
|
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
|
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
|
declaratively, so every change can be reverted by activating an older
|
||||||
home-manager generation.
|
home-manager generation.
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ the two files below).
|
|||||||
steamFrame = {
|
steamFrame = {
|
||||||
keyboardLayout = "de";
|
keyboardLayout = "de";
|
||||||
steamKeyboardPatch.enable = true;
|
steamKeyboardPatch.enable = true;
|
||||||
|
launcherMenu = { sort = true; pinDesktop = "bottom"; };
|
||||||
firefox.enable = true;
|
firefox.enable = true;
|
||||||
# Only relevant with Steam Developer Mode on (see hidden-apps below).
|
# Only relevant with Steam Developer Mode on (see hidden-apps below).
|
||||||
hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
|
hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
|
||||||
@@ -149,7 +151,7 @@ home-manager switch --flake .#steamos
|
|||||||
```
|
```
|
||||||
|
|
||||||
Individual modules are available as
|
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.
|
`default` imports all of them.
|
||||||
|
|
||||||
**Steam Developer Mode** (a Steam setting, not managed here) makes the "+"
|
**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.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.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.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.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.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. |
|
| `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.
|
**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`)
|
### Hidden apps (`hiddenApps`)
|
||||||
|
|
||||||
**Problem:** with Steam Developer Mode on, the "+" menu lists every desktop
|
**Problem:** with Steam Developer Mode on, the "+" menu lists every desktop
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
keyboard-layout = ./modules/keyboard-layout.nix;
|
keyboard-layout = ./modules/keyboard-layout.nix;
|
||||||
steam-keyboard-patch = ./modules/steam-keyboard-patch.nix;
|
steam-keyboard-patch = ./modules/steam-keyboard-patch.nix;
|
||||||
hidden-apps = ./modules/hidden-apps.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.
|
# 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
|
# The key lets the module system deduplicate it when it is imported both
|
||||||
# directly and through `default`.
|
# directly and through `default`.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
})()
|
||||||
@@ -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';
|
||||||
|
})()
|
||||||
@@ -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" ];
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
# steamFrame = {
|
# steamFrame = {
|
||||||
# keyboardLayout = "de"; # XKB layout for the Steam session
|
# keyboardLayout = "de"; # XKB layout for the Steam session
|
||||||
# steamKeyboardPatch.enable = true; # Esc/Ctrl/Alt/arrows on the VR keyboard
|
# 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
|
# firefox.enable = true; # launcher for the Firefox Flatpak
|
||||||
# # Hidden from the "+" menu (with Steam Developer Mode on):
|
# # Hidden from the "+" menu (with Steam Developer Mode on):
|
||||||
# hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
|
# hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
|
||||||
|
|||||||
Reference in new issue
Block a user