Patch headers and comments: concise

Document the patch calling convention once in lib/default.nix; patch
headers say what, target, hook, state and contracts. Comments only, no
code changes.
This commit is contained in:
Pierre Kisters committed 2026-09-27 23:57:50 +02:00
1 parent 4bb0fdd863
commit 4c843c3ce4
22 files changed
+346 -553

No files matched your search

+19 -38
View File
@@ -1,51 +1,32 @@
// dashboard-windows: resize and grab-distance limits of SteamVR dashboard
// windows (Steam, app windows, overlays, the theater screen, the dashboard
// itself). Target: SteamVR's dashboard page (vrwebhelper, DevTools
// 127.0.0.1:8087, title "systemui").
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts, hooks), with find the
// finder library (lib/finders.js), sigs this patch's module signatures
// (lib/signatures.json, "dashboard-windows"), opts the options from
// dashboard-windows.nix (null = stock) and hooks the shared method hooks
// (lib/hooks.js), e.g. opts
// windows (Steam, app windows, overlays, theater screen, the dashboard).
// Target: SteamVR dashboard (vrwebhelper, DevTools 127.0.0.1:8087, title
// "systemui"). mkPatch patch (see lib/default.nix); opts (null = stock):
// { maxScale: 4.0,
// distance: { world: {min: null, max: 10}, theater: {...}, dashboard: {...} } }
//
// How it works: systemui builds a scene graph from its DOM and sends it to
// vrcompositor through its mailbox WebSocket,
// systemui sends its scene graph to vrcompositor via
// mailbox.SendMessage("vrcompositor_systemlayer", {type: "update_scene_graph", scene_graph})
// and vrcompositor enforces the limits it finds there:
// - "frame-resize-scale-min"/"-max" on every window frame (stock 0.25 / 2,
// relative to the window's default size; the theater screen's default is
// 2.8x larger): range of the resize handle;
// - "min-distance"/"max-distance" (meters) on grab nodes: how close / far a
// grabbed window can be pulled in / pushed back (thumbstick / scroll):
// and vrcompositor enforces the limits in it:
// - "frame-resize-scale-min"/"-max" on window frames (stock 0.25 / 2, relative
// to the default size; the theater's default is 2.8x larger);
// - "min-distance"/"max-distance" (m) on grab nodes (pull in / push back):
// world "grab-scale" 0.25 / 5 windows placed in the world
// theater "grab-transform" 1 / 6 the theater screen
// dashboard "grab-transform" 0.3 / 4 the dashboard itself
// (the keyboard's grab-transform, 0.2 / 1, is left alone).
// These are constants/literals in systemui's bundle (not patchable at the
// source), so this patch hooks the mailbox class's SendMessage (on its
// prototype, which existing instances use; through lib/hooks.js, which
// window-curvature shares) and rewrites them in outgoing scene graphs (a
// fresh object per update, so editing it in place is safe).
// Grab nodes are identified by node type plus their exact stock values: after
// a SteamVR update that changes them, the distance rewrite is a no-op.
// Then systemui is asked for one scene-graph resend (its own debounced
// scheduler: rebuilds the unchanged graph, no visible UI change), so new
// limits apply immediately.
// They are literals in systemui's bundle, so SendMessage is hooked on the
// mailbox prototype (lib/hooks.js, shared with window-curvature) and outgoing
// scene graphs (fresh objects per update) are edited in place. Grab nodes are
// matched by type plus exact stock values, so if SteamVR changes them the
// distance rewrite becomes a no-op. A scene-graph resend (systemui's debounced
// scheduler) applies new limits at once.
//
// State: window.__sfuiDashboardWindows (mailbox prototype, resend function,
// options id, per-kind rewrite counters in .hits). The hook is registered
// as NAME; unpatch.js removes it and resends the stock graph. (VERSION 2
// wrapped SendMessage itself and read the rewrite from the state's
// `rewrite`, which is never set any more, so a leftover wrapper is inert; the
// hooks library unwinds one still on top.)
// The mailbox class and the scheduler are found by signature, not by
// webpack module id or minified export name; if one doesn't match, the patch
// returns an error and changes nothing. Idempotent: same VERSION and options
// -> "unchanged"; otherwise the rewrite is replaced in place.
// State: window.__sfuiDashboardWindows (proto, resend, options id, counters in
// .hits); hook name NAME. (VERSION 2 wrapped SendMessage itself and read
// state.rewrite, no longer set, so a leftover wrapper is inert; hooks.js
// unwinds one on top.) Signature mismatch -> error, nothing changed. Same
// VERSION and options -> "unchanged", else the rewrite is replaced in place.
((find, sigs, opts, hooks) => {
const NAME = 'dashboard-windows';
const VERSION = 3;
+3 -5
View File
@@ -1,8 +1,6 @@
// Reverts dashboard-windows/patch.js: removes its SendMessage hook (the
// shared wrapper goes too once no other patch uses it) and resends the scene
// graph, so vrcompositor gets the stock limits again right away. Also
// restores the mailbox SendMessage if VERSION 2's own wrapper is on top.
// Safe when not patched.
// Reverts dashboard-windows/patch.js: removes its SendMessage hook (and a
// VERSION 2 own wrapper if on top) and resends the scene graph so the stock
// limits apply at once. Safe when not patched.
(() => {
const NAME = 'dashboard-windows';
const st = window.__sfuiDashboardWindows;
+47 -69
View File
@@ -1,81 +1,59 @@
// frame-controls: move the control icons under SteamVR dashboard windows
// between the window's bottom bar and its More Options (three-dot) menu.
// A long press on a bar icon or a menu row (opts.longPressMs; a ring around
// the icon shows the progress from half that time, at most after 1 s) opens a
// small popup with a "Show in bar" checkbox; toggling it moves that control for all
// windows. Short presses stay stock. The three-dot button itself can't be
// moved (stock shows it while its menu has entries). opts.floatInTheater
// gives theater windows the "Float" control back.
// frame-controls: move SteamVR dashboard window controls between the bottom
// bar and the More Options (three-dot) menu. A long press (opts.longPressMs;
// progress ring from half that time, at most after 1 s) on a bar icon or menu
// row opens a "Show in bar" popup; toggling it moves that control in all
// windows. Short presses stay stock; the three-dot button can't be moved.
// opts.floatInTheater gives theater windows the "Float" control back.
//
// Target: SteamVR's dashboard page (vrwebhelper, DevTools 127.0.0.1:8087,
// title "systemui"). This file is a function expression, called by the file
// lib/default.nix (mkPatch) generates: (<this file>)(find, sigs, opts), with
// find the finder library (lib/finders.js), sigs this patch's module
// signatures (lib/signatures.json, "frame-controls") and opts the options
// from frame-controls.nix.
// Target: SteamVR dashboard (vrwebhelper, DevTools 127.0.0.1:8087, title
// "systemui"). mkPatch patch (see lib/default.nix); opts: { inBar, inMenu,
// longPressMs, floatInTheater }.
//
// Stock frame controls (systemui): each window (Frame) renders its controls
// as <FrameControlsItem params={type, action_id}> (type 2 = action button,
// 1 = spacer); the items register while rendering and the frame's
// <FrameControls> hands the lists to Frame.prototype.SetControlsItems(bottom,
// tabHover, additional) (a MobX action). Items inside onlyVisibleIn=
// "additional-options" (curvature, dock to a controller) go to `additional`.
// systemui draws the controls of every window itself (also the Steam window):
// panel vsg-node#legacy-frame-controls-<frameID> from `bottom`, and, while
// the menu is open, #legacy-frame-controls-additional-options-<frameID> from
// `additional`. Buttons are div.ButtonControl whose React `control` prop is
// the item; stock actions run on click (primary button only).
// Stock: each Frame renders <FrameControlsItem params={type, action_id}>
// (2 = action, 1 = spacer); <FrameControls> passes the lists to the MobX
// action Frame.prototype.SetControlsItems(bottom, tabHover, additional)
// (onlyVisibleIn="additional-options" items go to `additional`). systemui
// draws them as panels vsg-node#legacy-frame-controls-<frameID> (bar) and
// #legacy-frame-controls-additional-options-<frameID> (open menu); buttons are
// div.ButtonControl with the item as React prop `control`.
//
// Placement: SetControlsItems is wrapped; the stock lists are remembered per
// frame and a re-partitioned copy is stored (stock order kept; a control
// moved into the bar joins the group left of the three-dot button, which
// keeps a group of its own; adjacent spacer runs are merged so gaps don't
// widen). Without a placement differing from stock the stock arrays pass
// through unchanged. A change re-applies the remembered lists of every frame
// (no React re-render needed). The tab-hover list is untouched.
// Placement is global per control type: a control's key is its action icon,
// "icon:<enum>" (the same in every window and language; action ids are per
// frame instance and app window keys change with every app start). Effective
// placement: the popup's choice, else opts.inBar / opts.inMenu, else stock; a
// popup choice is dropped when the option for that control changes. Kept in
// window.__sfuiFrameControlsState (survives patch restarts and upgrades;
// unpatch keeps it) and mirrored to the page's localStorage, so it also
// survives SteamVR restarts.
// Placement: SetControlsItems is wrapped; stock lists are remembered per frame
// and a re-partitioned copy is stored (stock order kept; a control moved to the
// bar joins the group left of the three-dot button; adjacent spacer runs are
// merged). A change re-applies all remembered lists (no re-render needed).
// Keyed per control type by action icon, "icon:<enum>" (action ids are per
// frame, app window keys per app start). Effective: popup choice, else
// opts.inBar/inMenu, else stock; a popup choice is dropped when that control's
// option changes. State in window.__sfuiFrameControlsState (kept across
// re-patch and unpatch) and localStorage (survives SteamVR restarts).
//
// Long press: SteamVR delivers only primary-button laser input to the
// dashboard (no right-click; the thumbstick click arrives as nothing), so a
// long press is the trigger. Nothing is stopped or restyled while holding;
// the timer keeps running when the laser moves or leaves the control and is
// cancelled by an early release (or a curvature drag, below). When it completes, the popup opens and
// the one click that follows the release on that control is swallowed, so
// its stock action doesn't run.
// window-curvature (its controls own press-and-drag) contract: its elements
// carry the class sfui-curv-ctl; a drag there dispatches a bubbling
// Long press: the dashboard only gets primary-button laser input (no right
// click; thumbstick click arrives as nothing). Holding changes nothing; the
// timer survives the laser leaving the control, an early release cancels it.
// On completion the popup opens and the next click on that control is
// swallowed so its stock action doesn't run.
// Contract with window-curvature (whose controls own press-and-drag): its
// elements carry class sfui-curv-ctl; a drag there dispatches a bubbling
// CustomEvent 'sfui-curv-dragstart' (cancels the long press before the ring
// shows); once the ring shows, the long press takes the press over with
// window.__sfuiWindowCurvature.cancelPress(), so drifting doesn't start a
// drag. Neither side reads the other's thresholds.
// shows); once the ring shows, the long press takes over the press via
// window.__sfuiWindowCurvature.cancelPress(). Neither reads the other's
// thresholds.
//
// Popup: in a scene-graph panel of its own, attached like the stock menu, so
// neither the bar nor the menu panel changes. The dashboard builds its scene
// graph by walking the DOM (vsg-transform elements from their attributes,
// other nodes through an element's buildNode()); panel textures are regions
// of the page, published per panel through SGApp's embedded-UV table. So:
// Popup: its own scene-graph panel so the bar/menu panels don't change. The
// dashboard builds its scene graph from the DOM (vsg-transform attributes,
// other nodes via element.buildNode()); panel textures are page regions
// published through SGApp's embedded-UV table. So, after the bar's transform:
// <vsg-transform parent-id=<anchor> translation=...>
// <vsg-node> buildNode: a panel with the bar/menu panel's own properties
// <vsg-node> buildNode: panel copying the bar/menu panel's properties
// (key, meters-per-pixel, curvature origin, laser visibility),
// origin bottom centre, own UVs and embedded-UV slot
// popup
// inserted after the bar's transform. Bar controls: the pressed button's
// anchor (the tooltips' anchor), 0.15 up (the tooltips' offset): above the
// icon. Menu rows: the three-dot button's popout anchor (from which the stock
// menu is placed), centred on the menu and GAP_PX above it. The stock menu
// closes when the compositor's input focus leaves the bar and menu panels,
// which pointing at the popup does, so while a menu popup is open a close of
// that menu is deferred (Frame.prototype.SetControlAdditionalOptionsOpen
// wrapped) and settled when the popup closes. The popup closes on a press
// outside it, Escape, 1 s after the laser left it and its bar/menu, and after
// toggling. Without SGApp/anchors the popup goes into the bar/menu panel.
// Anchor: bar -> the button's tooltip anchor, 0.15 up (tooltip offset); menu
// -> the three-dot popout anchor, centred on the menu, GAP_PX above it. The
// stock menu closes when compositor focus leaves bar and menu, which pointing
// at the popup does, so while a menu popup is open that close is deferred
// (SetControlAdditionalOptionsOpen wrapped) and settled on popup close. Closes
// on a press outside, Escape, 1 s after the laser left popup and bar/menu, and
// after toggling. Without SGApp/anchors the popup goes into the bar/menu panel.
//
// Debugging: window.__sfuiFrameControls: log, dump(), placement(),
// setPlacement(name or "icon:N", 'bar' | 'menu' | null), reset().
+5 -8
View File
@@ -1,11 +1,8 @@
// Reverts frame-controls/patch.js: ends a running long press, closes the
// popup (and removes its scene-graph panel), gives every window its stock
// control lists back and removes its wrappers (Frame.prototype
// SetControlsItems / SetControlAdditionalOptionsOpen; a wrapper something
// else wrapped since stays in the chain but is inert), listeners, style and
// the Float actions it added to theater windows.
// window.__sfuiFrameControlsState (placements, log) and its localStorage
// mirror are kept for a re-injection. Safe when not patched.
// Reverts frame-controls/patch.js: ends a long press, closes the popup, restores
// stock control lists, removes wrappers (a wrapper wrapped by something else
// since stays inert in the chain), listeners, style and the theater Float
// actions. Keeps window.__sfuiFrameControlsState and its localStorage mirror.
// Safe when not patched.
(() => {
const s = window.__sfuiFrameControls;
if (!s) return 'not patched';
+26 -60
View File
@@ -1,57 +1,26 @@
// grid: shows the programs of the VR dashboard's "+" menu (section
// #VRDashboard_LaunchNonSteamApp) as a grid of tiles: large icon, name
// centred below (up to 2 lines).
// grid: shows the programs section of the VR dashboard's "+" menu
// (#VRDashboard_LaunchNonSteamApp) as a grid of tiles (icon, name below).
// mkPatch patch (see lib/default.nix); opts: { columns, maxRows } (maxRows
// null: the stock 600 px max height).
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and opts { columns: 4, maxRows: 4 } from
// launcher-menu.nix (maxRows null: the list fills up to the menu's stock
// 600 px max height).
//
// Pure restyling, evaluated in SharedJSContext (the bar popups share its
// realm): Steam's own items (with their onActivate, focus handling and
// sounds) stay where they are, so launching and the other launcher-menu
// patches keep working unchanged. A timer attaches a MutationObserver to
// every dashboard bar popup document (g_PopupManager); whenever the menu
// renders, the programs section is found through React fibers (the section
// component, which has a `header` prop, keyed "programs"; the "windows"
// section, "add desktop window", stays a list) and its elements get
// data-sfui-grid="<role>" markers, which a stylesheet in that document turns
// into:
// - list: the section (flex column: heading + scroll region) is as wide
// as the popup window (100vw, a fixed 300 px);
// - panel: the item container becomes a CSS grid of N columns; it still
// scrolls inside the stock scroll region, so the heading stays;
// - tile internals (wrap, label, iconbox, icon, labelbox, marquee, text)
// are laid out as a column;
// - scroller: the stock scroll region. With maxRows, its max-height (CSS
// variable) is set to the bottom of row maxRows, measured at
// layout time (labels have 1 or 2 lines), so the menu shrinks to
// that many rows and the rest scrolls;
// - fade: the element with Steam's ScrollFade class. Steam computes its
// ScrolledToTop/ScrolledToBottom state only on React renders and
// scroll events, so it is stale once the grid changes the layout
// (e.g. a bottom fade although nothing overflows). The state is
// recomputed (same thresholds) on scroll, resize and mutation and
// put in data-sfui-fade (none/top/bottom/both), which selects the
// same gradients as Steam's stylesheet (var(--scroll-fade-size)).
// - shadow: the other stock indicator: the scroller's ::before/::after
// shadows (box-shadow in the background colour at the top and
// bottom edge), shown by Steam's can-scroll-up/-down classes, which
// go stale the same way. The same state goes in data-sfui-shadow
// on the scroller and sets the pseudo-elements' opacity. Once
// maxRows caps the scroller, the inner panel is what scrolls (as in
// Steam's own design; the outer element keeps the shadows fixed),
// so the state is read from whichever of the two overflows.
// Steam's gamepad navigation derives a panel's layout from its computed
// style (display: grid), so up/down/left/right move between tiles.
// With the pinned-desktop patch, its pinned row (.sfui-pinned-desktop,
// outside the grid) is made slim and its content centred.
// Anchors (section keys, `header` prop, ScrollFade classes and gradients)
// are checked by scripts/check-signatures.mjs ("launcher-menu-grid" in
// lib/signatures.json).
// unpatch.js (or a new VERSION or options) calls __sfuiLauncherGrid.stop(),
// which removes markers, stylesheets, observers and the timer.
// Pure restyling in SharedJSContext (the bar popups share its realm): Steam's
// items and handlers stay, so the other launcher-menu patches keep working. A
// timer attaches a MutationObserver to every bar popup document
// (g_PopupManager); the section is found via React fibers (component with a
// `header` prop, key "programs"; "windows" stays a list) and its elements get
// data-sfui-grid="<role>" markers that a stylesheet turns into:
// - list/panel: full-width section; the item container is an N-column grid
// still scrolling inside the stock scroll region (heading stays);
// - scroller: with maxRows, max-height = bottom of row maxRows, measured at
// layout time (labels have 1 or 2 lines);
// - fade/shadow: Steam computes ScrollFade and can-scroll-up/-down only on
// renders and scroll events, so they go stale after the grid relayout. The
// state is recomputed (Steam's thresholds) into data-sfui-fade/-shadow; once
// maxRows caps the scroller, the inner panel is what scrolls.
// Gamepad navigation follows the computed display: grid. The pinned-desktop
// row (.sfui-pinned-desktop) is made slim and centred.
// Anchors: "launcher-menu-grid" in lib/signatures.json.
// unpatch.js (or a new VERSION/options) calls __sfuiLauncherGrid.stop().
((find, sigs, opts) => {
const NAME = 'launcher-menu-grid';
const VERSION = 6;
@@ -143,9 +112,8 @@ ${PINNED} > [role=button] * { flex-grow: 0 !important; text-align: center !impor
const regionsOf = (d) => [...regions].filter(([, r]) => r.d === d).map(([sc]) => sc);
const px = (x) => parseFloat(x) || 0;
// Section of an item: the nearest fiber ancestor that is a function
// component with a `header` prop; its key names the section, its first
// host descendant is the list container.
// Section of an item: nearest function-component fiber with a `header`
// prop; its key names the section, its first host node is the list.
const sectionOf = (item) => {
const f = find.findFiberUp(item, (x) => typeof x.type === 'function' &&
x.memoizedProps && typeof x.memoizedProps === 'object' && 'header' in x.memoizedProps, 60);
@@ -192,10 +160,8 @@ ${PINNED} > [role=button] * { flex-grow: 0 !important; text-align: center !impor
for (let w = label.parentElement; w && w !== item; w = w.parentElement) mark(w, 'wrap');
};
// Scroll indicator state from the actual scroll position (Steam's
// thresholds), for both the mask fade and the edge shadows.
// The element that actually scrolls: the inner panel once maxRows caps
// the scroller, otherwise the scroller.
// Scroll state (Steam's thresholds) of the element that actually scrolls:
// the inner panel once maxRows caps the scroller, else the scroller.
const scrolling = (sc, panel) =>
panel && (panel.scrollHeight > panel.clientHeight + 1 || panel.scrollTop > 0) ? panel : sc;
const fade = (sc) => {
+6 -9
View File
@@ -1,15 +1,12 @@
# shellcheck shell=bash
# Hicolor fallbacks for desktop entry icons that only Breeze has.
#
# Usage: steam-frame-icon-fallbacks [EXTRA_NAME...]
# BREEZE_APPS=<breeze-icons>/share/icons/breeze/apps: link the Icon= names
# of the desktop entries Steam sees (plus EXTRA_NAMEs) that no hicolor
# theme dir has but Breeze does, as
# $XDG_DATA_HOME/icons/hicolor/scalable/apps/<name>.svg -> Breeze's SVG.
# BREEZE_APPS unset: remove all links made before (disabled).
# Links made are listed in a manifest; only those, and only while they still
# point into a breeze-icons store path, are ever removed. Nothing else in the
# icon dir is touched.
# BREEZE_APPS=<breeze-icons>/share/icons/breeze/apps: link each Icon= name
# (of the entries Steam sees, plus EXTRA_NAMEs) that no hicolor dir has
# but Breeze does as $XDG_DATA_HOME/icons/hicolor/scalable/apps/<name>.svg.
# BREEZE_APPS unset: remove the links made before.
# Only links listed in the manifest and still pointing into breeze-icons are
# ever removed.
data_home=${XDG_DATA_HOME:-$HOME/.local/share}
dest=$data_home/icons/hicolor/scalable/apps
+15 -27
View File
@@ -1,32 +1,20 @@
// launch: what happens when a program of the VR dashboard's "+" menu
// (#VRDashboard_LaunchNonSteamApp) is activated.
// launch: what activating a program in the VR dashboard's "+" menu does.
// mkPatch patch (see lib/default.nix); opts: { closeOnLaunch, debounceSeconds }.
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and opts the options from launcher-menu.nix, e.g.
// { closeOnLaunch: true, debounceSeconds: 10 }.
//
// Stock, an item's onActivate only calls
// SteamClient.Apps.LaunchNonSteamApp(strCmdline) (plus the nav sound); the
// "+" popup stays open until the new window takes over, so users click again
// and start the program twice. That call is the only use of
// LaunchNonSteamApp in Steam's UI and is looked up at call time, so this
// patch wraps it in SharedJSContext (the dashboard bar's realm):
// - debounceSeconds > 0: a launch of the same command line within that many
// seconds of the last accepted one is ignored (console.info, and reported
// in the steam-ui-patches journal on the next re-injection);
// - closeOnLaunch: after a launch (ignored or not), the "+" popup is closed
// through the bar button's own popup handle (closePopup(), what stock does
// after adding a desktop window). The handle is found through React props,
// not minified names: in any popup document (the dashboard bar's), a
// .VRDashboardBarSmallButton element whose fiber ancestors include the bar
// button (prop refBarPopopHandle) and, above it, the "+" component (prop
// allowLaunchProgram). scripts/check-signatures.mjs checks these names
// Stock, an item only calls SteamClient.Apps.LaunchNonSteamApp(cmdline) and
// the popup stays open until the new window appears, so users click twice.
// That call is its only UI use and is looked up at call time, so it is wrapped
// here in SharedJSContext:
// - debounceSeconds > 0: the same command line within that many seconds of
// the last accepted launch is ignored (console.info; reported in the
// steam-ui-patches journal on the next re-injection);
// - closeOnLaunch: afterwards the "+" popup is closed via the bar button's
// popup handle (closePopup(), as stock does after adding a desktop window),
// found through React props: a .VRDashboardBarSmallButton whose fiber
// ancestors have refBarPopopHandle and, above, allowLaunchProgram
// ("launcher-menu-launch" in lib/signatures.json).
// Works for every activation path (pointer, controller, the pinned Desktop
// copy, which clicks the stock item). The original is kept as __sfuiOrig
// (unpatch.js restores it). Idempotent; bump VERSION when changing the
// wrapper.
// Covers every activation path (pointer, controller, pinned Desktop copy).
// Original kept as __sfuiOrig for unpatch.js; bump VERSION on changes.
((find, sigs, opts) => {
const VERSION = 2;
const NAME = 'launcher-menu-launch';
+5 -7
View File
@@ -1,10 +1,8 @@
// 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.
// order: sorts the VR dashboard's "+" menu alphabetically (case-insensitive),
// Desktop included. Steam shows ScanForInstalledNonSteamApps() in GLib
// hash-table order and looks the function up at call time, so wrapping it in
// SharedJSContext suffices. Original kept as __sfuiOrig for unpatch.js; bump
// VERSION on changes.
(() => {
const VERSION = 3;
const NAME = 'launcher-menu-order';
+12 -27
View File
@@ -1,31 +1,16 @@
// 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.
// pinned-desktop: pins "Desktop" (the nested Plasma session) above or below
// the scrolling list of the VR dashboard's "+" menu.
// mkPatch patch (see lib/default.nix); opts: { position: "top" | "bottom" }.
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and opts { position: "top" } or
// { position: "bottom" } from launcher-menu.nix.
//
// 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.
// Anchors: React props and keys (the list key, the section's `header` prop)
// and the bar popups' window names ("valve.steam.gamepadui.barpopup...");
// scripts/check-signatures.mjs checks that Steam's UI still has them
// ("launcher-menu-pinned-desktop" in lib/signatures.json).
// unpatch.js (or a new VERSION/position) calls __sfuiPinnedDesktop.stop(),
// which removes the pinned blocks, CSS, markers, observers and the timer.
// DOM patch in SharedJSContext: a timer attaches a MutationObserver to every
// bar popup document (g_PopupManager). The stock Desktop item (fiber list key
// == strExePath "steamos-nested-desktop") is hidden by CSS and a clone plus a
// 1 px separator is inserted before ("top") or after ("bottom") the scroll
// region. Clicking the clone clicks the hidden item, so Steam's own handler
// runs. The list container is a flex column with a max-height, so the scroll
// region shrinks and the menu keeps its size.
// Anchors: "launcher-menu-pinned-desktop" in lib/signatures.json.
// unpatch.js (or a new VERSION/position) calls __sfuiPinnedDesktop.stop().
((find, sigs, opts) => {
const NAME = 'launcher-menu-pinned-desktop';
const VERSION = 3;
+11 -21
View File
@@ -1,25 +1,15 @@
// launcher-menu show-all: the VR dashboard's "+" menu lists all programs
// without Steam's Developer Mode.
// Steam filters the programs of ScanForInstalledNonSteamApps() by the base
// name of their executable: some are always hidden (steam, vrurlhandler),
// and without Developer Mode (client setting developer_mode_enabled) also a
// second list: firewall-config, vlc, dolphin, cmake-gui, plasma-discover,
// konsole, systemsettings, qrenderdoc, sh, lxterminal. The menu's filter
// spreads that list into its block list only when Developer Mode is off
// (`bDevMode || block.push(...devModeOnly)`), and nothing else uses it.
// show-all: the VR dashboard's "+" menu lists all programs without Steam's
// Developer Mode.
// mkPatch patch (see lib/default.nix); no opts; sigs: "launcher-menu-show-all"
// (the module exporting the list, plus a check-only anchor for the filter).
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs), with find the finder
// library (lib/finders.js) and sigs this patch's module signatures
// (lib/signatures.json, "launcher-menu-show-all": the module exporting the
// list, plus a check-only anchor for the filter). No options.
//
// The patch gives that one array an own, empty Symbol.iterator, so spreading
// it adds nothing; its contents stay as they are (the signature keeps
// matching on re-injection) and the Developer Mode setting itself, used
// elsewhere (settings pages, controller pairing, ...), is not touched. The
// menu re-filters whenever it rescans, i.e. on every open. The array is
// remembered as window.__sfuiShowAllApps for unpatch.js. Idempotent.
// Steam hides some executables always (steam, vrurlhandler) and, without
// Developer Mode, a second list (firewall-config, vlc, dolphin, cmake-gui,
// plasma-discover, konsole, systemsettings, qrenderdoc, sh, lxterminal) via
// `bDevMode || block.push(...devModeOnly)`; nothing else uses that array.
// The patch gives it an own, empty Symbol.iterator, so spreading adds
// nothing while its contents (and the signature) stay intact and the setting
// itself is untouched. Remembered as window.__sfuiShowAllApps for unpatch.js.
((find, sigs) => {
const VERSION = 1;
let mods;
+2 -4
View File
@@ -1,7 +1,5 @@
// Reverts show-all/patch.js: removes the empty iterator from Steam's
// Developer Mode app list (remembered as window.__sfuiShowAllApps), so the
// "+" menu hides those programs again while Developer Mode is off. Safe when
// not patched.
// Reverts show-all/patch.js (removes the empty iterator). Safe when not
// patched.
(() => {
const s = window.__sfuiShowAllApps;
if (!s) return 'not patched';
+21 -11
View File
@@ -1,13 +1,23 @@
# Shared helpers of the runtime Steam UI patches.
# - finders.js: signature-based lookup of webpack modules/exports and React
# fibers (no module ids or minified names);
# - signatures.json: the signatures the patches use, per patch, also read by
# Shared helpers of the runtime Steam UI patches:
# - finders.js: signature-based lookup of webpack modules/exports and fibers;
# - signatures.json: per-patch signatures, also read by
# scripts/check-signatures.mjs (run it after a Steam update);
# - hooks.js: shared method hooks (one wrapper per method for all patches,
# e.g. the SteamVR dashboard mailbox's SendMessage);
# - mkPatch: turns a patch written as a function expression
# `(find, sigs, opts, hooks) => …` into the single expression the injectors
# evaluate: (<patch>)(<finders.js>, <signatures>, <opts>, <hooks.js>).
# - hooks.js: one shared wrapper per hooked method (e.g. SendMessage);
# - mkPatch: builds the expression the injector evaluates.
#
# Patch convention: a patch file is a function expression
# (find, sigs, opts, hooks) => <status string or Promise of one>
# (trailing parameters may be omitted), called as
# (<patch>)(<finders.js>, <signatures>, <opts>, <hooks.js>)
# with find = lib/finders.js, sigs = its module signatures, opts = the JSON
# options from its module, hooks = lib/hooks.js. The injector re-evaluates it
# on every new JS context and periodically, so it must be idempotent: keep
# state on a window.__sfui* global (or on the wrapped function), return
# "unchanged" when already applied (not logged) and tear down/re-apply when
# its VERSION or options differ. Bump VERSION whenever the patch code changes.
# A matching unpatch.js (plain expression) reverts it when the service stops.
# Patches needing none of the arguments may be plain expressions without
# mkPatch (e.g. launcher-menu/order).
{ pkgs }:
let
sigFile = builtins.fromJSON (builtins.readFile ./signatures.json);
@@ -17,8 +27,8 @@ in {
hooks = ./hooks.js;
# name: file name (and default signatures entry); src: the patch file;
# signatures: module signatures passed as `sigs` (default: the entry `name`
# of signatures.json, else none); opts: JSON-serialisable options.
# signatures: `sigs` (default: signatures.json entry `name`, else none);
# opts: JSON-serialisable options.
mkPatch = { name, src, signatures ? sigFile.patches.${name}.modules or { }, opts ? { } }:
pkgs.writeText "${name}.js" ''
(${builtins.readFile src}
+10 -14
View File
@@ -1,15 +1,12 @@
// finders.js: signature-based lookup of webpack modules, their exports and
// React fibers in Steam's (and SteamVR's) web UIs, so runtime patches don't
// depend on webpack module ids or minified export names, which change with
// every Steam update. In the spirit of Decky Loader's @decky/ui
// (findModule, findModuleChild, findInReactTree) and Vencord's `find`.
// finders.js: signature-based lookup of webpack modules, exports and React
// fibers in Steam's and SteamVR's web UIs, so patches don't depend on module
// ids or minified names (which change with every Steam update). Like Decky
// Loader's @decky/ui finders and Vencord's `find`.
//
// This file is a single expression. Evaluated in a page it installs the
// library as window.__sfuiFind (unless an equal or newer VERSION is already
// there) and evaluates to it; lib/default.nix passes it to the patches that
// need it, together with their signatures from signatures.json. The same
// file is evaluated by scripts/check-signatures.mjs in Node, so patches and
// the offline check match signatures with the same code.
// A single expression: installs window.__sfuiFind (unless an equal or newer
// VERSION is there) and evaluates to it; mkPatch passes it as `find`.
// scripts/check-signatures.mjs evaluates the same file in Node, so the
// offline check matches exactly like the patches.
//
// Signatures (plain JSON, see signatures.json):
// text signature, matched against a source text (a module factory's or a
@@ -188,9 +185,8 @@
}
// Module signature -> { id, module (exports object), exports: { name: value },
// keys: { name: export key } }. Cached per page (this library lives on the
// page's window); a cached result is reused while the module factory is
// still registered and every export still has the same value.
// keys: { name: export key } }. Cached per page while the factory is still
// registered and every export keeps its value.
const cache = new Map();
function resolve(req, sig, name = 'module') {
const ck = name + '\n' + JSON.stringify(sig);
+19 -29
View File
@@ -1,33 +1,23 @@
// hooks.js: shared method hooks for runtime patches. Several patches can
// intercept the same method (e.g. SendMessage of the SteamVR dashboard's
// mailbox, which dashboard-windows and window-curvature both use to rewrite
// outgoing scene graphs) through ONE wrapper per method, instead of a chain
// of per-patch wrappers: a wrapper can't be taken out of the middle of a
// chain, so patches restarted in varying order would pile up inert wrappers
// (or run a rewrite twice).
// hooks.js: shared method hooks. Patches that intercept the same method (e.g.
// the SteamVR dashboard mailbox's SendMessage, used by dashboard-windows and
// window-curvature) share ONE wrapper per method: a wrapper can't be removed
// from the middle of a chain, so per-patch wrappers restarted in varying
// order would pile up (or run a rewrite twice).
//
// This file is a single expression. Evaluated in a page it installs the
// library as window.__sfuiHooks (unless an equal or newer VERSION is already
// there; a newer one takes over the registry) and evaluates to it;
// lib/default.nix (mkPatch) passes it to every patch as its 4th argument.
//
// before(obj, method, name, fn) registers (or replaces) hook `name` on
// obj[method] (e.g. a class prototype): fn.call(this, args) runs before
// the original with the call's arguments array (which it may modify in
// place). Installs the wrapper if needed. Idempotent. A hook that
// throws is skipped (count and last message in errors[name]).
// remove(obj, method, name) unregisters it; once no hook is left the
// wrapper is removed too (if nothing wrapped the method since).
// has(obj, method, name) registered and the wrapper installed.
//
// The wrapper (marked fn.__sfuiHooks = method, original in fn.__sfuiOrig)
// looks the hooks up per call, so re-registering, removing and upgrading the
// library take effect in place. Hooks run once per call, even if a wrapper
// ended up in the method's chain twice (then also not for a call the method
// makes to itself). Hooks are called in registration order.
// Migration: per-patch wrappers from before this library (fn.__sfuiPatch ===
// name, original in fn.__sfuiOrig) on top of obj[method] are unwound when
// `name` registers.
// A single expression: installs window.__sfuiHooks (unless an equal or newer
// VERSION is there; a newer one takes over the registry) and evaluates to it;
// mkPatch passes it to every patch as `hooks`.
// before(obj, method, name, fn) register/replace hook `name`:
// fn.call(this, args) runs before the original and may modify the args
// array in place. Idempotent. Throwing hooks are skipped and counted in
// errors[name].
// remove(obj, method, name) unregister; the wrapper goes with the last
// hook (if nothing wrapped the method since).
// has(obj, method, name) registered and wrapper installed.
// The wrapper (fn.__sfuiHooks = method, original in fn.__sfuiOrig) looks hooks
// up per call, in registration order, and runs them once per call even if it
// is in the chain twice. Legacy per-patch wrappers (fn.__sfuiPatch === name)
// on top of obj[method] are unwound when `name` registers.
(() => {
const VERSION = 1;
const G = globalThis;
+32 -53
View File
@@ -1,65 +1,44 @@
// steam-close-button: a Close (X) button on the SteamVR dashboard's main
// "Steam" window (Steam's library, overlay valve.steam.gamepadui.main).
// Target: SteamVR's dashboard page (vrwebhelper, DevTools 127.0.0.1:8087,
// title "systemui").
// "Steam" window (overlay valve.steam.gamepadui.main).
// Target: SteamVR dashboard (vrwebhelper, DevTools 127.0.0.1:8087, title
// "systemui"). mkPatch patch (see lib/default.nix); no options.
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and sigs this patch's module signatures
// (lib/signatures.json, "steam-close-button": MobX, the dock-location enum,
// the frame's `closing` component class, plus check-only anchors for the
// dashboard internals used below). No options.
//
// The button: every dashboard frame has a `closing` component; its X renders
// when closing.showCloseButton, i.e. when one of closeMethodPriority's methods
// is possible. Method 0 is "componentProps.onCloseRequested exists", and
// The button: a frame's `closing` component shows X when a close method is
// possible; method 0 is "componentProps.onCloseRequested exists", with
// componentProps = {...defaultComponentProps, ...frame.props.componentProps.closing}.
// The main frame passes no closing props, so this patch replaces that
// instance's plain `defaultComponentProps` field with a copy that adds
// onCloseRequested (original kept as the non-enumerable __sfuiOrig), then
// re-assigns frame.props (a shallow copy, in a MobX action) so the computed
// componentProps re-evaluates and the button appears. React re-assigning
// frame.props later keeps it (the defaults are merged every time).
// The main frame passes no closing props, so this instance's
// defaultComponentProps is replaced by a copy adding onCloseRequested
// (original as non-enumerable __sfuiOrig) and frame.props is re-assigned (in a
// MobX action) so the computed re-evaluates. Later React props updates keep it.
//
// Clicking X (RequestClose -> onCloseRequested); the frame can't be
// destroyed, so it is put out of view:
// Clicking X: the frame can't be destroyed, so it is put out of view:
// * undocked (world/theater/hand) -> docked back into the dashboard;
// * if it was the active dashboard frame -> the dashboard switches to the
// most recently active other frame that is alive, docked in the dashboard
// and has a dashboard-bar tab (the call a tab click makes);
// * with none -> "bar only": the dashboard stays open with no active frame,
// just the dashboard bar (a docked frame renders only while active).
// Stock SteamVR has that state (e.g. when a VRLink remote frame vanishes)
// but never keeps it, so while bar-only is on and no frame is active:
// * if it was active -> switch to the most recently active other frame that
// is alive, docked and has a dashboard-bar tab (as a tab click does);
// * with none -> "bar only": dashboard open with no active frame.
// Stock SteamVR has that state but never keeps it, so while bar-only is on
// and no frame is active:
// - an instance override of Dashboard.autoSwitchOverlayIfNeeded returns
// early (its onDashboardTabsUpdated autorun, and reopening the dashboard
// with the Steam button, would otherwise switch right back to Steam);
// early (its autorun, and reopening the dashboard, would switch back);
// - an instance override of Dashboard.onShowOverlayRequestFromSteam drops
// the FIRST ShowOverlay("valve.steam.gamepadui.main") from Steam after each
// dashboard open, within STEAM_SHOW_CAP_MS (Steam's main VR window sends
// it by itself 50 ms .. 1.7 s after the open). Trade-off: should Steam not
// send it, the user's first explicit Steam-menu pick (e.g. Library) in
// that session is swallowed once; a second one works.
// A Steam tab click (DashboardTabClicked -> switchToFrameInternal) and
// SteamVR's own CVRSteamPrivate::SwitchToDashboardOverlay path aren't
// filtered. Bar-only ends as soon as any frame becomes active.
// the FIRST ShowOverlay(main) from Steam after each dashboard open, within
// STEAM_SHOW_CAP_MS (Steam sends it by itself 50 ms .. 1.7 s after open).
// Trade-off: if Steam doesn't send it, the user's first explicit Steam-menu
// pick in that session is swallowed once.
// Steam tab clicks and SteamVR's SwitchToDashboardOverlay aren't filtered.
// Bar-only ends as soon as any frame becomes active.
//
// State that must outlive this instance (bar-only flag, last open time,
// pending Steam request, frame history, suppression log) is kept in
// window.__sfuiSteamCloseState (schema 1), which upgrades take over.
// Teardown (unpatch.js, a newer VERSION) only removes overrides, reactions
// and markers: it never switches frames and leaves the state, so a
// re-injection (service restart, switch) continues bar-only. It is in-page
// only: a dashboard (vrwebhelper) reload starts fresh (see loadState).
// State (bar-only flag, open time, pending request, history, log) lives in
// window.__sfuiSteamCloseState (schema 1), taken over by upgrades. Teardown
// never switches frames and keeps the state, so a re-injection continues
// bar-only; a dashboard reload starts fresh.
//
// Without the Dashboard component, X hides the dashboard. RequestClose shows
// a spinner after onCloseRequested (it expects the frame to go away); it is
// reset right after. A MobX reaction keeps the history of active frames and
// re-applies when the main frame changes (the 15 s re-injection covers the
// rest). window.__sfuiSteamClose: plan() (what a click would do now, no side
// effects), wouldIgnoreSteamShow(), barOnly, state, teardown().
// Results: "patched" / "unchanged", with " (bar-only, N ignored Steam
// requests)" while there is something to report, so the injector logs it.
// Without the Dashboard component, X hides the dashboard. RequestClose's
// spinner (it expects the frame to go away) is reset right after. A MobX
// reaction tracks active-frame history and re-applies when the main frame
// changes. window.__sfuiSteamClose: plan() (what a click would do),
// wouldIgnoreSteamShow(), barOnly, state, teardown(). Result: "patched" /
// "unchanged", plus " (bar-only, N ignored Steam requests)" when relevant.
((find, sigs, opts) => {
const NAME = 'steam-close-button';
const VERSION = 5;
+4 -7
View File
@@ -1,10 +1,7 @@
// Reverts steam-close-button/patch.js: removes its overrides, reactions and
// markers (the X disappears). Teardown only: it never switches frames and
// leaves window.__sfuiSteamCloseState (bar-only flag etc.) in place, so a
// re-injection (service restart, switch) continues where it left off; if the
// patch is gone for good while bar-only, the dashboard just stays without an
// active frame until the next tab click or dashboard open. Safe when not
// patched.
// Reverts steam-close-button/patch.js (the X disappears). Never switches
// frames and keeps window.__sfuiSteamCloseState, so a re-injection continues;
// if removed while bar-only, the dashboard stays without an active frame
// until the next tab click or open. Safe when not patched.
(() => {
const s = window.__sfuiSteamClose;
if (!s) return 'not patched';
+21 -33
View File
@@ -12,18 +12,12 @@
// keymap -- are typed by the helper instead
// - Enter types Return for app windows, even if a Steam search box had focus
// before (Steam then labels it "Search" and closes the keyboard instead)
// Everything goes through the CDP binding window.__vrkbdKey("<op>:<arg>"),
// executed by helper.mjs with xdotool on :0. Every replaced function keeps its
// original as __vrkbdOrig (unpatch.js restores them, through the references
// this patch remembers in window.__vrkbdRefs).
// Key output goes through the CDP binding window.__vrkbdKey("<op>:<arg>"),
// run by helper.mjs with xdotool on :0. Replaced functions keep their
// original as __vrkbdOrig; window.__vrkbdRefs etc. are for unpatch.js.
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and sigs this patch's module signatures
// (lib/signatures.json, "steam-keyboard-patch"). Steam's webpack modules are
// located by those signatures, not by module id or minified export name; if
// one doesn't match, the patch returns an error and changes nothing.
// Idempotent: safe to evaluate repeatedly.
// mkPatch patch (see lib/default.nix); no options. On a signature mismatch
// it returns an error and changes nothing. Idempotent.
((find, sigs) => {
const VERSION = 10;
const send = (msg) => window.__vrkbdKey && window.__vrkbdKey(msg);
@@ -51,13 +45,11 @@
};
// ---- bottom row ------------------------------------------------------------
// The keyboard window has a fixed height, so no extra row: Esc/Ctrl/Alt go
// left of the space bar, and Steam's arrow keys become four separate keys
// (stock layouts pair them as [Left, Up-when-shifted], [Right, Down-when-shifted]).
// Every entry is a [normal, shifted, altgr] triple: Steam's stock bottom row
// lacks explicit variants, so with Shift the close icon jumps and with AltGr
// keys turn into empty full-width panels. null as the shifted variant means
// "same key, no shift label".
// Fixed window height, so no extra row: Esc/Ctrl/Alt go left of the space
// bar and the stock paired arrows ([Left, Up-shifted], [Right, Down-shifted])
// become four keys. Entries are explicit [normal, shifted, altgr] triples:
// without them Shift moves the close icon and AltGr yields empty
// full-width keys. null shifted = same key, no shift label.
const HALF = 2; // key type "Half"; widths set by the stylesheet
const k = (key, label) => ({ key, label, type: HALF });
const same = (x) => [x, null, x];
@@ -89,11 +81,10 @@
}
// ---- text: characters Steam's key emulation can't produce --------------------
// All keyboard text for gamescope windows ends up in
// SteamClient.Input.ControllerKeyboardSendText (via several paths, incl. the
// VR text override), which only maps plain ASCII on the base/shift levels;
// everything else comes out as "1". Hand those characters to the helper, in
// order; the rest passes through unchanged. (helper.mjs checks the same set.)
// All text for gamescope windows ends in ControllerKeyboardSendText, which
// only maps plain ASCII on the base/shift levels (else "1"). Those
// characters go to the helper, in order; the rest passes through.
// (helper.mjs checks the same set.)
const viaHelper = (c) => c.codePointAt(0) > 127 || '|@{[]}\\~^`'.includes(c);
wrap(SteamClient.Input, 'ControllerKeyboardSendText', (orig) => function (text, ...rest) {
if (typeof text !== 'string') return orig.call(this, text, ...rest);
@@ -108,16 +99,13 @@
});
// ---- Enter for app windows ------------------------------------------------------
// The manager keeps the props of the last focused Steam text field
// (m_ActiveElementProps, set on DOM focus) until that field gets a gamepad
// blur, which doesn't happen when you leave the dashboard. Search boxes
// carry strEnterKeyLabel "#SearchEnterKeyLabel" ("Suchen") and an
// onEnterKeyPress returning "VKClose", so when the keyboard is then opened
// for an app window, Enter shows "Suchen", runs the Steam search and hides
// the keyboard instead of typing Return. While the keyboard serves
// something other than this Steam UI (the same test Steam uses for its
// text dispatch, in the VirtualKeyboardManager's module), ignore those
// props and the dismiss-on-Enter flag.
// The manager keeps the last focused Steam text field's props
// (m_ActiveElementProps) until a gamepad blur, which leaving the dashboard
// doesn't send. A search box's props (Enter label "Search", onEnterKeyPress
// -> "VKClose") then make Enter search and hide the keyboard in app windows.
// While the keyboard serves something other than this Steam UI (Steam's own
// test, VirtualKeyboardManager module), ignore those props and the
// dismiss-on-Enter flag.
const forOther = (m) => {
const s = Status.VRKeyboardStatus, ui = m.m_Instance;
return !!s?.bIsOpen && !(s.sOverlayKey && s.sOverlayKey === ui?.GetVROverlayKey?.()) &&
+4 -5
View File
@@ -1,8 +1,7 @@
// Reverts patch.js in Steam's SharedJSContext; evaluated by helper.mjs
// when it stops (service stopped, module disabled). Every patched function
// keeps its original as __vrkbdOrig; the patched objects Steam doesn't expose
// globally were remembered by patch.js (window.__vrkbdRefs, __vrkbdLayouts,
// __vrkbdProto, __vrkbdInst). Safe to run when nothing is patched.
// Reverts patch.js in Steam's SharedJSContext (run by helper.mjs on stop).
// Patched functions keep their original as __vrkbdOrig; objects Steam doesn't
// expose are in window.__vrkbdRefs/__vrkbdLayouts/__vrkbdProto/__vrkbdInst.
// Safe when not patched.
(() => {
const refs = window.__vrkbdRefs;
if (!refs && !window.__vrkbdProto && !window.__vrkbdLayouts) return 'not patched';
+66 -97
View File
@@ -1,83 +1,62 @@
// window-curvature: adjustable curvature per SteamVR dashboard window. The
// stock "Toggle Curvature" control becomes a wheel wherever it is shown: the
// row of a window's More Options (three-dot) menu (with the value on the
// right of the row) and, when the control sits in the window's bottom bar
// (e.g. moved there by frame-controls), that bar button (no value shown:
// the steps and snap points are felt as controller haptics). Click toggles
// (curved -> flat, flat -> stock curve), dragging up/down with the laser sets
// the curvature live.
// stock "Toggle Curvature" control becomes a wheel: in the More Options menu
// row (value shown on the right) and, if the control is in the bottom bar
// (e.g. via frame-controls), on that bar button (no value; steps and snap
// points are felt as haptics). Click toggles (curved -> flat, flat -> stock
// curve); dragging up/down with the laser sets the curvature live.
//
// Target: SteamVR's dashboard page (vrwebhelper, DevTools 127.0.0.1:8087,
// title "systemui"). This file is a function expression, called by the file
// lib/default.nix (mkPatch) generates: (<this file>)(find, sigs, opts, hooks),
// with find the finder library (lib/finders.js), sigs this patch's module
// signatures (lib/signatures.json, "window-curvature"), opts the options from
// window-curvature.nix and hooks the shared method hooks (lib/hooks.js).
// Target: SteamVR dashboard (vrwebhelper, DevTools 127.0.0.1:8087, title
// "systemui"). mkPatch patch (see lib/default.nix); opts: { default, max,
// step, snap, snapPoints, dragThreshold, dragPixelsPerUnit,
// barDragPixelsPerUnit, barDragRoom, haptics }.
//
// How stock curvature works (systemui's `curvature` frame component, found
// by signature; frame.curvature):
// shouldCurve on/off: m_bCurveOverride (set by ToggleCurvature(), cleared
// when the dock location changes), else the stock default:
// curved when docked in the dashboard, theater per setting,
// flat elsewhere (world, hands).
// curvatureOriginDistance = shouldCurve ? DashboardStore.curvatureDistance
// (dashboard distance + 1.8 m, e.g. 2.95) : 1000.
// The frame renders a transform node with id curvatureTransformOriginID
// ("frame:<id>:curvature-origin") at translation z = that distance, and
// every panel of the frame references it as "curvature-origin-id";
// vrcompositor bends the panels onto a cylinder around that point. So
// curvature = 1 / radius, and "flat" is just a very distant origin (1000).
// Those computed properties can't be overridden (non-configurable MobX
// instance properties), so this patch hooks the mailbox's SendMessage
// (lib/hooks.js, shared with dashboard-windows) and rewrites the origin's
// translation in outgoing scene graphs: distance = stock distance / value,
// value 1 = SteamVR's stock curve, 2 = twice as curved (half the radius),
// 0 = flat (the stock toggle off). On/off stays the stock state
// (ToggleCurvature), so the stock toggle and this control always agree.
// Stock curvature (frame.curvature, systemui's `curvature` component):
// shouldCurve = m_bCurveOverride (set by ToggleCurvature(), cleared on dock
// change) else curved when docked in the dashboard, theater per setting, flat
// elsewhere. The frame renders transform "frame:<id>:curvature-origin" at
// z = shouldCurve ? DashboardStore.curvatureDistance (dashboard distance +
// 1.8 m) : 1000; its panels reference it as "curvature-origin-id" and
// vrcompositor bends them onto a cylinder around it (curvature = 1/radius).
// Those MobX properties are non-configurable, so this patch hooks the mailbox
// SendMessage (lib/hooks.js, shared with dashboard-windows) and rewrites the
// origin's z in outgoing scene graphs to stock / value (1 = stock, 2 = half
// the radius, 0 = flat = stock toggle off). On/off stays the stock state, so
// stock toggle and wheel always agree.
//
// Values are kept per window (key: the frame's first overlay key) in
// window.__sfuiWindowCurvatureState (schema 1: { values, log }), which
// unpatch and upgrades leave in place (not persisted across a dashboard
// reload / SteamVR restart). A window without its own value uses
// opts.default when placed in the world or on a hand, and 1 (stock) when
// docked in the dashboard or the theater, so the docked Steam window stays
// concentric with the dashboard bar.
// Values per window (key: first overlay key) in
// window.__sfuiWindowCurvatureState (schema 1: { values, log }); kept across
// re-patch and unpatch, not across a dashboard reload. Without a stored value:
// opts.default in the world or on a hand, 1 when docked in the dashboard or
// theater (so the Steam window stays concentric with the dashboard bar).
//
// Bar button: the bar panel (vsg-node#legacy-frame-controls-<frameID>,
// origin TopCenter) is one button high, and the laser's position stops at
// the edge of the pressed panel. While a drag on the bar button runs, the
// panel gets opts.barDragRoom px of transparent padding above and below
// (stock window.forceLayoutUpdate() re-measures it), and the panel's origin
// is moved by the same amount in outgoing scene graphs, so the bar stays
// where it is in VR. The padding moves the bar down in page coordinates;
// laser events the compositor still maps with the old layout are recognised
// (the laser moves continuously) and shifted. opts.barDragPixelsPerUnit
// sets the drag speed there.
// Bar button: the bar panel (#legacy-frame-controls-<frameID>, origin
// TopCenter) is one button high and the laser stops at the pressed panel's
// edge. During a bar drag the panel gets opts.barDragRoom px of transparent
// padding above and below (window.forceLayoutUpdate() re-measures) and its
// origin is compensated in outgoing scene graphs so the bar stays put in VR.
// Laser events still mapped with the old layout are detected and shifted.
//
// Haptics (opts.haptics): VRHTML.VROverlay.TriggerOverlayHapticEffect with
// the stock EOverlayHapticEffect values (sigs.hapticEffects): SlidingEdge at
// 0 and max, Snap at a snap point, Sliding for other steps (at most every
// 30 ms).
// Haptics (opts.haptics): VROverlay.TriggerOverlayHapticEffect with stock
// EOverlayHapticEffect values: SlidingEdge at 0/max, Snap at snap points,
// Sliding otherwise (at most every 30 ms).
//
// Contract with other patches (e.g. a long press on frame controls): this
// patch owns press-and-drag on its controls.
// Contract with other patches (e.g. frame-controls' long press); this patch
// owns press-and-drag on its controls:
// - Every element it drives has the class `sfui-curv-ctl`.
// - When a press on one becomes a drag (opts.dragThreshold px vertical),
// it dispatches a bubbling CustomEvent `sfui-curv-dragstart` on the
// element (detail { frameID, where: 'menu' | 'bar' }); when that drag
// ends (release or cancelPress), `sfui-curv-dragend`.
// - window.__sfuiWindowCurvature.cancelPress() ends the current press on
// any of its controls without its click (a drag stays at its value);
// returns whether a press was active.
// A patch with its own gesture on these elements lets mousemove through
// while that gesture is undecided, drops it on `sfui-curv-dragstart`, and
// calls cancelPress() when it takes the press over. Neither side reads the
// other's thresholds or restores the other's state afterwards.
// - When a press becomes a drag (opts.dragThreshold px vertical) it
// dispatches a bubbling CustomEvent `sfui-curv-dragstart` on the element
// (detail { frameID, where: 'menu' | 'bar' }); when that drag ends
// (release or cancelPress), `sfui-curv-dragend`.
// - window.__sfuiWindowCurvature.cancelPress() ends the current press
// without its click (a drag keeps its value); returns whether a press
// was active.
// A patch with its own gesture on these elements lets mousemove through while
// undecided, drops its gesture on `sfui-curv-dragstart`, and calls
// cancelPress() when it takes the press over. Neither side reads the other's
// thresholds or restores the other's state.
//
// Debugging: window.__sfuiWindowCurvature: dump() (per frame: dock, on/off,
// stored/applied value, distances, controls), hits (scene-graph rewrite and
// haptic counters), log (last events, also in the state), setValue(frameID,
// v), controls(), cancelPress().
// Debugging: window.__sfuiWindowCurvature: dump(), hits (rewrite/haptic
// counters), log, setValue(frameID, v), controls(), cancelPress().
((find, sigs, opts, hooks) => {
const NAME = 'window-curvature';
const VERSION = 11;
@@ -230,11 +209,8 @@
resend();
};
// Sets the shown value of a frame: 0 = curvature off, > 0 = on with that
// strength. On/off goes through the stock ToggleCurvature (what the
// control's stock click invokes), so Steam's state and the icon match.
// Going to 0 forgets the window's value (turning it on again, e.g. by
// docking it in the dashboard, uses the default).
// 0 = off, > 0 = on with that strength. On/off via stock ToggleCurvature so
// Steam's state and icon match. 0 forgets the stored value.
const setValue = (f, v, why) => {
const c = curvatureOf(f);
if (!c) return;
@@ -283,10 +259,9 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; }
document.head.appendChild(s);
};
// The frame's Toggle Curvature action: a toggle (invocation 2) with the
// curvature icons. Its menu row is found by position among the menu's
// rows, checked against (else looked up by) the action's label; its bar
// button by the React `control` prop of the bar's buttons.
// Toggle Curvature action: invocation 2 with the curvature icons. Menu row:
// by position, verified (else found) by label; bar button: by React
// `control` prop.
const isCurvatureAction = (a) => {
const p = a?.partialParams;
return p?.invocation === 2 && p?.icon?.enum === ICON_OFF && p?.icon_active?.enum === ICON_ON;
@@ -315,11 +290,9 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; }
return null;
};
// Digits have no descenders, so their ink sits above the centre of the line
// box that translateY(-50%) centres. The ink box is measured once (canvas
// measureText: font metrics vs. actual digit bounds) and the number shifted
// by the difference, so it is optically centred between the arrows.
// (CSS text-box-trim would do this; Steam's Chromium lacks it.)
// Digits have no descenders, so translateY(-50%) centres them too high;
// shift by the measured ink offset (canvas measureText) to centre them
// optically between the arrows. (Steam's Chromium lacks text-box-trim.)
let ctx2d;
const inkShift = (el) => {
const cs = getComputedStyle(el);
@@ -356,15 +329,11 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; }
}
// ---- input -----------------------------------------------------------------------------
// Every press/click on a control is stopped at the element, so React
// (listening at the root) never runs the stock onClick; the click is
// re-implemented on mouseup when the press never became a drag. Drag:
// vertical and relative (the cylinder bends horizontally, so the pointer's
// y on the panel barely moves while the curvature changes). A press
// becomes a drag after dragThreshold px (then re-based, so there is no
// jump); the value follows at dragPixelsPerUnit (bar: barDragPixelsPerUnit)
// px per 1.0, see dragValue. One press at a time, window capture listeners
// while it lasts.
// Presses/clicks are stopped at the element so React (root listener) never
// runs the stock onClick; a press that never became a drag is a click on
// mouseup. Drag is vertical and relative (the cylinder bends horizontally,
// so y barely moves as curvature changes); after dragThreshold px it
// re-bases (no jump). One press at a time, window capture listeners.
const stop = (e) => e.stopPropagation();
const STOPPED = ['click', 'dblclick', 'mouseup', 'pointerdown', 'pointerup', 'contextmenu'];
let press = null; // { c, y0, v0, last, moved, shift, stale }
@@ -518,9 +487,9 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; }
return 'ok';
};
// Wanted controls: open menus, and bars that hold the curvature action;
// injected with retries until React has rendered them (bars only while
// the frame is visible: a hidden frame has no bar panel).
// Inject into open menus and bars holding the curvature action, retrying
// until React rendered them (bars only while visible: hidden frames have no
// bar panel).
let syncTimer = null, retries = 0;
const failed = new Map(); // key -> last reason logged
const sync = () => {
+4 -7
View File
@@ -1,10 +1,7 @@
// Reverts window-curvature/patch.js: ends a running press or drag, removes
// the control from open menus and bar buttons (class, listeners, value
// indicator, a bar's drag room), its style, reactions and SendMessage hook
// (the shared wrapper goes too once no other patch uses it) and resends the
// scene graph, so windows get the stock curve radius back. Curvature on/off
// is stock state and stays as it is. window.__sfuiWindowCurvatureState
// (per-window values) is kept for a re-injection. Safe when not patched.
// Reverts window-curvature/patch.js: ends a press, removes controls, style,
// reactions and the SendMessage hook, and resends the scene graph (stock
// radius back). Curvature on/off is stock state and stays.
// window.__sfuiWindowCurvatureState is kept. Safe when not patched.
(() => {
const s = window.__sfuiWindowCurvature;
if (!s) return 'not patched';
+9 -14
View File
@@ -1,12 +1,9 @@
#!/usr/bin/env node
// check-signatures.mjs: checks, without Steam running and without a browser,
// that every finder signature the runtime UI patches use (modules/lib/
// signatures.json) still matches the installed Steam / SteamVR web UI
// bundles: each module signature must match exactly one webpack module, each
// export signature exactly one export of it, and the strings a patch relies
// on ("expects") should still be there; a "stylesheet" signature (CSS a patch
// relies on, e.g. a variable) must match exactly one stylesheet of the
// bundle's "styles" directory. Run it after a Steam update:
// check-signatures.mjs: checks offline (no Steam, no browser) that every
// signature in modules/lib/signatures.json still matches the installed Steam /
// SteamVR web UI bundles: module and export signatures exactly once,
// "expects" strings still present (warnings), "stylesheet" signatures exactly
// one file of the bundle's "styles" directory. Run it after a Steam update:
//
// nix shell nixpkgs#nodejs -c node scripts/check-signatures.mjs
//
@@ -20,12 +17,10 @@
// Exit status: 0 all found, 1 something missing/ambiguous (or a warning with
// --strict), 2 usage/IO error.
//
// Modules are extracted by webpack-modules.mjs (factory sources exactly as
// Function.prototype.toString sees them in the page). To check export
// signatures, the matched module's factory is run in a throwaway VM context
// in which every import and unknown global is an inert stub, so top-level
// definitions (objects, functions, classes, singletons) exist and are
// matched with the same code the patches use (modules/lib/finders.js).
// Modules come from webpack-modules.mjs (factory sources as the page's
// Function.prototype.toString sees them). For export signatures the matched
// factory runs in a throwaway VM context where imports and unknown globals
// are inert stubs, and is matched with modules/lib/finders.js.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { homedir } from 'node:os';
+5 -8
View File
@@ -5,14 +5,11 @@
// const mods = loadBundles('/home/deck/.local/share/Steam/steamui');
// // Map "<id>" -> { id, file, factory, source }
//
// Module maps are object literals of `<id>: <factory>` entries, found at
// - `.push([[<chunk ids>],{…}` (chunk files: webpackChunk<name>.push), and
// - `<x>={<id>:…` (the runtime's own modules, e.g. library.js).
// A small tokenizer (strings, templates, comments, regex literals) finds the
// literal's closing brace; the literal alone is then evaluated in a fresh VM
// context, which only creates the factory functions (nothing runs), so
// `source` is exactly Function.prototype.toString of the factory, as a
// finder sees it in the running page (require.m[id]).
// Module maps are object literals of `<id>: <factory>`, found at
// `.push([[<chunk ids>],{…}` (chunk files) and `<x>={<id>:…` (the runtime's
// own modules). A small tokenizer finds the closing brace; the literal alone
// is evaluated in a fresh VM context (defines factories, runs nothing), so
// `source` equals Function.prototype.toString as a finder sees it.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';