diff --git a/modules/dashboard-windows/patch.js b/modules/dashboard-windows/patch.js index 3113ea1..f574af8 100644 --- a/modules/dashboard-windows/patch.js +++ b/modules/dashboard-windows/patch.js @@ -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: ()(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; diff --git a/modules/dashboard-windows/unpatch.js b/modules/dashboard-windows/unpatch.js index 11d566b..a4642e0 100644 --- a/modules/dashboard-windows/unpatch.js +++ b/modules/dashboard-windows/unpatch.js @@ -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; diff --git a/modules/frame-controls/patch.js b/modules/frame-controls/patch.js index 0c43df5..d5be84f 100644 --- a/modules/frame-controls/patch.js +++ b/modules/frame-controls/patch.js @@ -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: ()(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 (type 2 = action button, -// 1 = spacer); the items register while rendering and the frame's -// 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- from `bottom`, and, while -// the menu is open, #legacy-frame-controls-additional-options- 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 +// (2 = action, 1 = spacer); 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- (bar) and +// #legacy-frame-controls-additional-options- (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:" (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:" (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: // translation=...> -// buildNode: a panel with the bar/menu panel's own properties +// 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(). diff --git a/modules/frame-controls/unpatch.js b/modules/frame-controls/unpatch.js index 2c53338..d20f222 100644 --- a/modules/frame-controls/unpatch.js +++ b/modules/frame-controls/unpatch.js @@ -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'; diff --git a/modules/launcher-menu/grid/patch.js b/modules/launcher-menu/grid/patch.js index 5a32805..a9c22c8 100644 --- a/modules/launcher-menu/grid/patch.js +++ b/modules/launcher-menu/grid/patch.js @@ -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: ()(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="" 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="" 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) => { diff --git a/modules/launcher-menu/icon-fallbacks.sh b/modules/launcher-menu/icon-fallbacks.sh index 4d49775..03b5927 100644 --- a/modules/launcher-menu/icon-fallbacks.sh +++ b/modules/launcher-menu/icon-fallbacks.sh @@ -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=/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/.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=/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/.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 diff --git a/modules/launcher-menu/launch/patch.js b/modules/launcher-menu/launch/patch.js index 5142c52..709e9b5 100644 --- a/modules/launcher-menu/launch/patch.js +++ b/modules/launcher-menu/launch/patch.js @@ -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: ()(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'; diff --git a/modules/launcher-menu/order/patch.js b/modules/launcher-menu/order/patch.js index 37677c9..53ea5bb 100644 --- a/modules/launcher-menu/order/patch.js +++ b/modules/launcher-menu/order/patch.js @@ -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'; diff --git a/modules/launcher-menu/pinned-desktop/patch.js b/modules/launcher-menu/pinned-desktop/patch.js index f1a4220..6da2331 100644 --- a/modules/launcher-menu/pinned-desktop/patch.js +++ b/modules/launcher-menu/pinned-desktop/patch.js @@ -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: ()(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; diff --git a/modules/launcher-menu/show-all/patch.js b/modules/launcher-menu/show-all/patch.js index 1e7a2d8..5854a1a 100644 --- a/modules/launcher-menu/show-all/patch.js +++ b/modules/launcher-menu/show-all/patch.js @@ -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: ()(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; diff --git a/modules/launcher-menu/show-all/unpatch.js b/modules/launcher-menu/show-all/unpatch.js index bb773f8..1ea57f5 100644 --- a/modules/launcher-menu/show-all/unpatch.js +++ b/modules/launcher-menu/show-all/unpatch.js @@ -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'; diff --git a/modules/lib/default.nix b/modules/lib/default.nix index a5d4381..e1bb57e 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -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: ()(, , , ). +# - 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) => +# (trailing parameters may be omitted), called as +# ()(, , , ) +# 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} diff --git a/modules/lib/finders.js b/modules/lib/finders.js index 5221652..47a04d7 100644 --- a/modules/lib/finders.js +++ b/modules/lib/finders.js @@ -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); diff --git a/modules/lib/hooks.js b/modules/lib/hooks.js index eea6bb2..a09a2ea 100644 --- a/modules/lib/hooks.js +++ b/modules/lib/hooks.js @@ -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; diff --git a/modules/steam-close-button/patch.js b/modules/steam-close-button/patch.js index 32cdb7c..fd5a714 100644 --- a/modules/steam-close-button/patch.js +++ b/modules/steam-close-button/patch.js @@ -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: ()(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; diff --git a/modules/steam-close-button/unpatch.js b/modules/steam-close-button/unpatch.js index 870e5de..3d8fb02 100644 --- a/modules/steam-close-button/unpatch.js +++ b/modules/steam-close-button/unpatch.js @@ -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'; diff --git a/modules/steam-keyboard-patch/patch.js b/modules/steam-keyboard-patch/patch.js index 9d79257..ad830cc 100644 --- a/modules/steam-keyboard-patch/patch.js +++ b/modules/steam-keyboard-patch/patch.js @@ -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(":"), -// 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(":"), +// 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: ()(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?.()) && diff --git a/modules/steam-keyboard-patch/unpatch.js b/modules/steam-keyboard-patch/unpatch.js index c5ea673..98d5ca5 100644 --- a/modules/steam-keyboard-patch/unpatch.js +++ b/modules/steam-keyboard-patch/unpatch.js @@ -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'; diff --git a/modules/window-curvature/patch.js b/modules/window-curvature/patch.js index 7fb08e0..d055c6d 100644 --- a/modules/window-curvature/patch.js +++ b/modules/window-curvature/patch.js @@ -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: ()(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::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::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-, -// 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-, 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 = () => { diff --git a/modules/window-curvature/unpatch.js b/modules/window-curvature/unpatch.js index 914419a..59c9f96 100644 --- a/modules/window-curvature/unpatch.js +++ b/modules/window-curvature/unpatch.js @@ -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'; diff --git a/scripts/check-signatures.mjs b/scripts/check-signatures.mjs index a9383a9..ce82fce 100644 --- a/scripts/check-signatures.mjs +++ b/scripts/check-signatures.mjs @@ -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'; diff --git a/scripts/webpack-modules.mjs b/scripts/webpack-modules.mjs index fcc92be..4b937ce 100644 --- a/scripts/webpack-modules.mjs +++ b/scripts/webpack-modules.mjs @@ -5,14 +5,11 @@ // const mods = loadBundles('/home/deck/.local/share/Steam/steamui'); // // Map "" -> { id, file, factory, source } // -// Module maps are object literals of `: ` entries, found at -// - `.push([[],{…}` (chunk files: webpackChunk.push), and -// - `={:…` (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 `: `, found at +// `.push([[],{…}` (chunk files) and `={:…` (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';