From 9f5538003f2bd7adcc2f8a14721f028cf3894e04 Mon Sep 17 00:00:00 2001 From: Pierre Kisters <1524059+lhns@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:06:37 +0200 Subject: [PATCH] Add dashboard-windows (resize/grab-distance limits) and steamvr-debugger dashboard-windows: steamFrame.dashboard.windowMaxScale and windowDistance.{world,theater,dashboard}.{min,max}, one runtime patch of the SteamVR dashboard (systemui, 127.0.0.1:8087) that rewrites frame-resize-scale-max and grab min/max-distance in the scene graph sent to vrcompositor, then asks for one resend so changes apply immediately. Registered only when an option is set. The mailbox class and the scene-graph scheduler are found by signature (mkPatch, "dashboard-windows" in lib/signatures.json, bundle vrwebui-systemui), so scripts/check-signatures.mjs checks them offline too. steamvr-debugger: steamFrame.steamvrDebugger.enable, defaulting to true when a UI patch uses port 8087; merges VRWebHelper.DebuggerEnabled into steamvr.vrsettings before every SteamVR start, and reverts it (only if it set it) when turned off. --- README.md | 100 +++++++++++++++++++-- flake.nix | 2 + modules/dashboard-windows.nix | 101 ++++++++++++++++++++++ modules/dashboard-windows/patch.js | 125 +++++++++++++++++++++++++++ modules/dashboard-windows/unpatch.js | 15 ++++ modules/lib/signatures.json | 26 ++++++ modules/steamvr-debugger.nix | 110 +++++++++++++++++++++++ template/home.nix | 7 ++ 8 files changed, 480 insertions(+), 6 deletions(-) create mode 100644 modules/dashboard-windows.nix create mode 100644 modules/dashboard-windows/patch.js create mode 100644 modules/dashboard-windows/unpatch.js create mode 100644 modules/steamvr-debugger.nix diff --git a/README.md b/README.md index 0320fb9..36ddf5f 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ [Home Manager](https://github.com/nix-community/home-manager) modules for the Valve Steam Frame: SteamOS on `aarch64-linux`, standalone home-manager on a non-NixOS system. They work around quirks of the Frame's two graphical -sessions (portal config, keyboard layout, VR keyboard, "+" menu, clipboard, -Firefox) +sessions (portal config, keyboard layout, VR keyboard, "+" menu, VR +dashboard windows, clipboard, Firefox) declaratively, so every change can be reverted by activating an older home-manager generation. @@ -133,6 +133,10 @@ the two files below). closeOnLaunch = true; launchDebounce = 10; }; + dashboard = { + windowMaxScale = 4.0; + windowDistance.world.max = 10.0; + }; firefox.enable = true; # Only relevant with Steam Developer Mode on (see hidden-apps below). hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ]; @@ -156,7 +160,7 @@ home-manager switch --flake .#steamos ``` Individual modules are available as -`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,clipboard-sync,firefox}`; +`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,dashboard-windows,clipboard-sync,firefox}`; `default` imports all of them. **Steam Developer Mode** (a Steam setting, not managed here) makes the "+" @@ -182,6 +186,9 @@ menu list every desktop entry, including terminals such as Konsole. | `steamFrame.launcherMenu.pinDesktop` | null or `"top"` / `"bottom"` | `null` | Pin "Desktop" above or below the "+" menu's scrolling list (always visible). `null`: a normal list entry. | | `steamFrame.launcherMenu.closeOnLaunch` | bool | `false` | Close the "+" menu as soon as a program in it is clicked. | | `steamFrame.launcherMenu.launchDebounce` | unsigned int (seconds) | `0` | Ignore repeated launches of the same program from the "+" menu within this time. `0`: off. | +| `steamFrame.dashboard.windowMaxScale` | null or number | `null` | Largest resize-handle scale of SteamVR dashboard windows, relative to their default size. `null`: stock (2). See [Dashboard windows](#dashboard-windows-dashboard). | +| `steamFrame.dashboard.windowDistance.{world,theater,dashboard}.{min,max}` | null or number (m) | `null` | How close / far grabbed windows can be pulled in / pushed back. `null`: stock (world 0.25-5, theater 1-6, dashboard 0.3-4 m). | +| `steamFrame.steamvrDebugger.enable` | bool | `true` if a UI patch uses port 8087, else `false` | SteamVR dashboard DevTools on `127.0.0.1:8087` (`VRWebHelper/DebuggerEnabled`), needed by dashboard patches. See [SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable). | | `steamFrame.hiddenApps` | list of str | `[ ]` | Desktop entry ids (without `.desktop`) to hide from the "+" and KDE menus. | | `steamFrame.clipboardSync.enable` | bool | `true` | Clipboard bridge between the Steam session and the nested desktop. | | `steamFrame.clipboardSync.package` | package | built from `dnut/clipboard-sync` | The clipboard-sync package. | @@ -306,7 +313,7 @@ Steam's client UI (and SteamVR's dashboard, `vrwebhelper`) are web pages in CEF with a local DevTools port: `127.0.0.1:8080` for Steam (SteamOS starts it with `-cef-enable-debugging`), `127.0.0.1:8087` for SteamVR when its debugger is enabled (`VRWebHelper/DebuggerEnabled` in -`steamvr.vrsettings`). The `steam-ui-patches` user service (`injector.mjs`, +`steamvr.vrsettings`, see [SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable)). The `steam-ui-patches` user service (`injector.mjs`, Node) uses them to patch the running UI; Steam's files are never modified. Each entry of `steamFrame.uiPatches.patches`: @@ -423,7 +430,9 @@ nix shell nixpkgs#nodejs -c node scripts/check-signatures.mjs ``` It reads the bundles Steam's UI page loads (`~/.local/share/Steam/steamui`: -the scripts of `index.html` and every chunk its webpack runtime can load), +the scripts of `index.html` and every chunk its webpack runtime can load; for +[Dashboard windows](#dashboard-windows-dashboard) also SteamVR's dashboard, +`/opt/steamvr/resources/webinterface/dashboard/systemui.html`), extracts all webpack module factories and evaluates every signature of `modules/lib/signatures.json` against them with the same finder code the patches use (exports are checked by running the matched module in an inert @@ -445,7 +454,7 @@ If something is missing, look at the module that used to match (the new bundle's module sources: `scripts/webpack-modules.mjs`) and adjust the signature in `signatures.json` (bump the patch's `VERSION` if its code changes). Options: `--signatures FILE` adds your own signatures (same -format; the bundle `vrwebui-systemui` is SteamVR's dashboard), +format; bundles `steamui` and `vrwebui-systemui`, SteamVR's dashboard), `--dir steamui=DIR` checks another copy, `--patch NAME`, `--strict` (fail on warnings), `--json`. Live, `journalctl --user -u steam-keyboard-patch -u steam-ui-patches` shows what each patch reported. @@ -492,6 +501,85 @@ patches use Steam APIs (`SteamClient.Apps`) and React props rather than webpack modules; the offline checker verifies those anchors too. Tested with Steam client 1790377368. +### Dashboard windows (`dashboard.*`) + +**Problem:** SteamVR dashboard windows (Steam, app windows, overlays, the +theater screen) can only be enlarged to twice their default size, and +grabbed windows can only be pushed back to 5 m (6 m in theater mode), too +close for a big virtual screen. + +**Fix:** a UI patch (see above) of SteamVR's dashboard page (`vrwebhelper`, +`127.0.0.1:8087`, title `systemui`); it turns on the +[SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable). The dashboard +describes its windows to `vrcompositor` as a scene graph +(`update_scene_graph` messages over its mailbox WebSocket), and the +compositor enforces the limits it finds there. They are constants in the +dashboard's JS, so the patch wraps the mailbox's `SendMessage` and rewrites +them in every outgoing scene graph: + +| Option | Scene-graph property | Stock | +|---|---|---| +| `windowMaxScale` | `frame-resize-scale-max` of every window frame | 2 (range 0.25-2, relative to the window's default size; the theater screen's default is 2.8x larger) | +| `windowDistance.world.{min,max}` | `min-distance` / `max-distance` of `grab-scale` nodes: windows placed in the world | 0.25-5 m | +| `windowDistance.theater.{min,max}` | same, `grab-transform` of the theater screen | 1-6 m | +| `windowDistance.dashboard.{min,max}` | same, `grab-transform` of the dashboard itself | 0.3-4 m | +| (not patched) | `grab-transform` of the keyboard | 0.2-1 m | + +Distances are how close / far a grabbed window can be pulled in / pushed +back (thumbstick or scroll while dragging). `null` (default) keeps the stock +value; unset `min`/`max` of a set range stay stock. The patch is only +registered when at least one option is set. + +After patching, the dashboard is asked to send its (unchanged) scene graph +once more, so changes take effect immediately, without touching any window; +turning the options off reverts to stock the same way (next switch). + +```nix +steamFrame.dashboard = { + windowMaxScale = 4.0; # resize up to 4x (theater: 11.2x) + windowDistance.world.max = 10.0; # push windows back up to 10 m + windowDistance.theater.max = 12.0; +}; +``` + +**Caveats:** depends on SteamVR UI internals: the mailbox class and the +scene-graph scheduler are found by signature (`dashboard-windows` in +`modules/lib/signatures.json`, checked offline by +`scripts/check-signatures.mjs` after a SteamVR update); if one doesn't match, +the patch reports it and leaves the dashboard stock. Grab nodes are recognized by their type *and* their +exact stock values, so if a SteamVR update changes those, the distance +options silently do nothing (the scale option still applies). The patch's +state, including counters of rewritten nodes per kind, is +`window.__sfuiDashboardWindows` in the `systemui` page (DevTools). + +### SteamVR debugger (`steamvrDebugger.enable`) + +Patches of the SteamVR dashboard need its DevTools port, which SteamVR only +opens with the setting `VRWebHelper/DebuggerEnabled` (port +`VRWebHelper/DebuggerPort`, default 8087). It is enabled automatically +(`mkDefault`) as soon as a patch in `steamFrame.uiPatches.patches` uses port +8087 (e.g. [Dashboard windows](#dashboard-windows-dashboard)). + +SteamVR rewrites `~/.config/openvr/config/steamvr.vrsettings` while it runs +and on exit, so the file can't be managed by Home Manager, and edits while it +runs are lost. Instead the `steamvr-webhelper-debugger` oneshot merges just +that key (with `jq`, other settings untouched) before every SteamVR start (a +drop-in on SteamOS's `steamvr.service` makes it Want/After the oneshot). +SteamVR reads the setting only at startup: **the first time, restart SteamVR +once** (e.g. reboot) before dashboard patches work; until then the +`steam-ui-patches` service keeps polling `127.0.0.1:8087`. + +Turning it off sets the key back to `false` at the next SteamVR start, but +only if this module set it (marker in +`~/.local/state/steam-frame-nix/`); a setting you made yourself is left +alone. + +**Security:** SteamOS's `steamvr-web-debug-portforward.service` forwards +`0.0.0.0:8088` to this port, so with the debugger on, anyone on the same +network could run code in the SteamVR dashboard. Masking it is recommended +(see "DevTools on the LAN" in [UI patches](#ui-patches-uipatchespatches)); +the patches only use `127.0.0.1`. + ### Hidden apps (`hiddenApps`) **Problem:** with Steam Developer Mode on, the "+" menu lists every desktop diff --git a/flake.nix b/flake.nix index 60f198a..b4371c9 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,8 @@ hidden-apps = ./modules/hidden-apps.nix; steam-ui-patches = ./modules/steam-ui-patches.nix; launcher-menu = ./modules/launcher-menu.nix; + steamvr-debugger = ./modules/steamvr-debugger.nix; + dashboard-windows = ./modules/dashboard-windows.nix; # Built with the consumer's pkgs; only the source comes from our input. # The key lets the module system deduplicate it when it is imported both # directly and through `default`. diff --git a/modules/dashboard-windows.nix b/modules/dashboard-windows.nix new file mode 100644 index 0000000..ca08f5e --- /dev/null +++ b/modules/dashboard-windows.nix @@ -0,0 +1,101 @@ +# Resize and grab-distance limits of SteamVR dashboard windows (Steam, app +# windows, overlays, the theater screen, the dashboard itself), through a +# runtime patch of SteamVR's dashboard page (vrwebhelper "systemui", DevTools +# 127.0.0.1:8087; see dashboard-windows/patch.js for how it works). It is +# registered with steam-ui-patches.nix only when an option is set, and turns +# on the SteamVR web helper debugger (steamvr-debugger.nix), which needs one +# SteamVR restart the first time. Changes apply immediately (the dashboard +# resends its scene graph); unsetting all options reverts to stock. +# Depends on SteamVR UI internals, found by signature (lib/signatures.json, +# "dashboard-windows"; scripts/check-signatures.mjs checks them offline). +{ config, lib, pkgs, ... }: +let + cfg = config.steamFrame.dashboard; + inherit (lib) mkOption types; + inherit (import ./lib { inherit pkgs; }) mkPatch; + + # Stock grab distance ranges (meters), matched by the patch. + stockDistance = { + world = { min = 0.25; max = 5; }; + theater = { min = 1; max = 6; }; + dashboard = { min = 0.3; max = 4; }; + }; + + rangeOption = kind: what: { + min = mkOption { + type = types.nullOr types.number; + default = null; + description = '' + Closest distance in meters ${what} can be pulled in to while grabbed. + null = stock (${toString stockDistance.${kind}.min} m). + ''; + }; + max = mkOption { + type = types.nullOr types.number; + default = null; + example = stockDistance.${kind}.max * 2; + description = '' + Farthest distance in meters ${what} can be pushed back to while + grabbed. null = stock (${toString stockDistance.${kind}.max} m). + ''; + }; + }; + + effective = kind: { + min = if cfg.windowDistance.${kind}.min != null then cfg.windowDistance.${kind}.min else stockDistance.${kind}.min; + max = if cfg.windowDistance.${kind}.max != null then cfg.windowDistance.${kind}.max else stockDistance.${kind}.max; + }; + + distanceSet = lib.any (r: r.min != null || r.max != null) (lib.attrValues cfg.windowDistance); + enabled = cfg.windowMaxScale != null || distanceSet; +in { + imports = [ ./steam-ui-patches.nix ./steamvr-debugger.nix ]; + + options.steamFrame.dashboard = { + windowMaxScale = mkOption { + type = types.nullOr types.number; + default = null; + example = 4.0; + description = '' + Largest size, relative to their default size, that SteamVR dashboard + windows (Steam, app windows, overlays, the theater screen) can be + enlarged to with the resize handle. null = stock (2; the smallest is + 0.25). The theater screen's default size is 2.8x that of a normal + window, so its limit is 2.8x this value. Runtime patch of the SteamVR + dashboard (steamFrame.uiPatches), applied immediately. + ''; + }; + windowDistance = { + world = rangeOption "world" "windows placed in the world (not attached to the dashboard)"; + theater = rangeOption "theater" "the theater-mode screen"; + dashboard = rangeOption "dashboard" "the dashboard itself"; + }; + }; + + config = lib.mkIf enabled { + assertions = + [ { + assertion = cfg.windowMaxScale == null || cfg.windowMaxScale > 0; + message = "steamFrame.dashboard.windowMaxScale must be positive."; + } ] + ++ map (kind: let r = effective kind; in { + assertion = r.min > 0 && r.max >= r.min; + message = "steamFrame.dashboard.windowDistance.${kind}: need 0 < min <= max (stock values fill in unset ones), got ${toString r.min}-${toString r.max}."; + }) (lib.attrNames stockDistance); + + steamFrame.uiPatches.patches = [ { + name = "dashboard-windows"; + endpoint = "http://127.0.0.1:8087"; + target.title = "systemui"; + patch = mkPatch { + name = "dashboard-windows"; + src = ./dashboard-windows/patch.js; + opts = { + maxScale = cfg.windowMaxScale; + distance = cfg.windowDistance; + }; + }; + unpatch = ./dashboard-windows/unpatch.js; + } ]; + }; +} diff --git a/modules/dashboard-windows/patch.js b/modules/dashboard-windows/patch.js new file mode 100644 index 0000000..2b7c1f5 --- /dev/null +++ b/modules/dashboard-windows/patch.js @@ -0,0 +1,125 @@ +// 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), with find the finder +// library (lib/finders.js), sigs this patch's module signatures +// (lib/signatures.json, "dashboard-windows") and opts the options from +// dashboard-windows.nix (null = stock), e.g. +// { 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, +// 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): +// 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 wraps the mailbox class's SendMessage (on its +// prototype, which existing instances use) 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. +// +// State: window.__sfuiDashboardWindows (mailbox prototype, resend function, +// the active rewrite, per-kind rewrite counters in .hits). The wrapper +// (__sfuiPatch = NAME, original in __sfuiOrig) looks the rewrite up per +// call; unpatch.js restores the original and resends the stock graph. +// 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. +((find, sigs, opts) => { + const NAME = 'dashboard-windows'; + const VERSION = 2; + const GRAB = { + world: { type: 'grab-scale', min: 0.25, max: 5 }, + theater: { type: 'grab-transform', min: 1, max: 6 }, + dashboard: { type: 'grab-transform', min: 0.3, max: 4 }, + }; + + const maxScale = opts?.maxScale ?? null; + if (maxScale !== null && !(maxScale > 0)) return `invalid maxScale ${maxScale}`; + const rules = []; + for (const [kind, stock] of Object.entries(GRAB)) { + const o = opts?.distance?.[kind] ?? {}; + const min = o.min ?? stock.min, max = o.max ?? stock.max; + if (!(min > 0 && max >= min)) return `invalid ${kind} distance range ${min}-${max}`; + if (min !== stock.min || max !== stock.max) rules.push({ kind, ...stock, newMin: min, newMax: max }); + } + const id = JSON.stringify([VERSION, maxScale, rules]); + + let st = window.__sfuiDashboardWindows; + if (!st) { + let mods; + try { + mods = find.resolveAll(find.getWebpackRequire('webpackChunkvrwebui'), sigs); + } catch (e) { + return `signature not found, dashboard left unpatched: ${e.message}`; + } + // Mailbox: SendMessage(target, msg) -> WebSocketSend("mailbox_send ..."). + // resend: debounced "send the scene graph again" (0 args, NextSGID(), setTimeout). + st = window.__sfuiDashboardWindows = { + proto: mods.mailbox.exports.Mailbox.prototype, resend: mods.sceneGraph.exports.resend, + id: null, rewrite: null, hits: {}, + }; + } + + const cur = st.proto.SendMessage; + const installed = cur.__sfuiPatch === NAME && cur.__sfuiVersion === VERSION; + if (installed && st.id === id) return 'unchanged'; + if (!installed) { + const orig = cur.__sfuiPatch === NAME ? cur.__sfuiOrig : cur; + const f = function SendMessage(target, msg, ...rest) { + const rewrite = window.__sfuiDashboardWindows?.rewrite; + if (rewrite && msg?.type === 'update_scene_graph') { + try { rewrite(msg.scene_graph); } catch { /* never break the dashboard */ } + } + return orig.call(this, target, msg, ...rest); + }; + Object.assign(f, { __sfuiPatch: NAME, __sfuiVersion: VERSION, __sfuiOrig: orig }); + st.proto.SendMessage = f; + } + + const hits = st.hits = {}; + const hit = (k) => { hits[k] = (hits[k] ?? 0) + 1; }; + const walk = (n) => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { n.forEach(walk); return; } + const p = n.properties; + if (p) { + if (maxScale !== null && typeof p['frame-resize-scale-max'] === 'number') { + // Never below the frame's own minimum. + p['frame-resize-scale-max'] = Math.max(maxScale, p['frame-resize-scale-min'] ?? 0); + hit('scale'); + } + if (typeof p['max-distance'] === 'number') { + const r = rules.find((r) => r.type === n.type && r.min === p['min-distance'] && r.max === p['max-distance']); + if (r) { p['min-distance'] = r.newMin; p['max-distance'] = r.newMax; hit(r.kind); } + } + } + if (n.children) walk(n.children); + }; + st.id = id; + st.rewrite = maxScale === null && rules.length === 0 ? null : walk; + try { st.resend(); } catch { /* applies with the next scene-graph update */ } + + const desc = [ + ...(maxScale !== null ? [`max scale ${maxScale}`] : []), + ...rules.map((r) => `${r.kind} ${r.newMin}-${r.newMax} m`), + ].join(', ') || 'all stock'; + return `patched (${desc})`; +}) diff --git a/modules/dashboard-windows/unpatch.js b/modules/dashboard-windows/unpatch.js new file mode 100644 index 0000000..219cfb0 --- /dev/null +++ b/modules/dashboard-windows/unpatch.js @@ -0,0 +1,15 @@ +// Reverts dashboard-windows/patch.js: restores the mailbox SendMessage and +// resends the scene graph, so vrcompositor gets the stock limits again right +// away. If something else has wrapped SendMessage since, our wrapper stays in +// its chain but is inert (it looks the rewrite up in the removed state). +// Safe when not patched. +(() => { + const NAME = 'dashboard-windows'; + const st = window.__sfuiDashboardWindows; + if (!st) return 'not patched'; + delete window.__sfuiDashboardWindows; + const f = st.proto.SendMessage; + if (f?.__sfuiPatch === NAME) st.proto.SendMessage = f.__sfuiOrig; + try { st.resend?.(); } catch { /* ignore */ } + return 'unpatched'; +})() diff --git a/modules/lib/signatures.json b/modules/lib/signatures.json index 2d4d0b2..ddb008f 100644 --- a/modules/lib/signatures.json +++ b/modules/lib/signatures.json @@ -99,6 +99,32 @@ "module": { "includes": ["\"valve.steam.gamepadui.barpopup\""] } } } + }, + "dashboard-windows": { + "bundle": "vrwebui-systemui", + "modules": { + "mailbox": { + "module": { "includes": ["\"mailbox_send \"", "WebSocketSend(", "\"vrcompositor_systemlayer\""] }, + "exports": { + "Mailbox": { "type": "class", "protoMethods": ["SendMessage", "WebSocketSend"] } + } + }, + "sceneGraph": { + "module": { "includes": ["\"update_scene_graph\"", "NextSGID()", "scene_graph:"] }, + "exports": { + "resend": { "type": "function", "length": 0, "includes": ["NextSGID()", "setTimeout"] } + }, + "expects": ["\"vrcompositor_systemlayer\"", ".SendMessage("] + }, + "windowFrame": { + "checkOnly": true, + "module": { "includes": ["\"frame-resize-scale-max\"", "\"frame-resize-scale-min\""] } + }, + "grabScale": { + "checkOnly": true, + "module": { "includes": ["\"grab-scale\"", "\"min-distance\"", "\"max-distance\""] } + } + } } } } diff --git a/modules/steamvr-debugger.nix b/modules/steamvr-debugger.nix new file mode 100644 index 0000000..4371cdc --- /dev/null +++ b/modules/steamvr-debugger.nix @@ -0,0 +1,110 @@ +# Enables the SteamVR web helper debugger: Chromium DevTools of vrwebhelper, +# which renders the SteamVR dashboard, on 127.0.0.1:8087, so steam-ui-patches +# can patch the dashboard the way it patches Steam's UI on 127.0.0.1:8080. +# Turned on automatically (mkDefault) when a UI patch targets port 8087. +# +# SteamVR only opens the port when /settings/VRWebHelper/DebuggerEnabled is +# true (settingsschema.vrsettings: requires_restart; port from +# VRWebHelper/DebuggerPort, default 8087). User settings live in +# ~/.config/openvr/config/steamvr.vrsettings, which SteamVR rewrites at +# runtime and on exit (from memory), so it can't be a read-only store link, +# and editing it while SteamVR runs would be overwritten. +# steamvr-webhelper-debugger.service merges just that key with jq (atomic +# write, other keys untouched, file created if missing), before every start +# of steamvr.service (SteamVR's systemd user unit): a drop-in makes it +# Want + start After the oneshot. (Its own ExecStartPre= chain already starts +# vrserver, so an added ExecStartPre= would run too late.) Hence the setting +# takes effect with the next SteamVR start, once. +# +# The service is installed even when disabled: if this module set the key +# before (marker in $XDG_STATE_HOME/steam-frame-nix), it sets it back to +# false at the next SteamVR start and removes the marker; otherwise it +# leaves the file alone (a setting made by hand is kept). +# +# Security: SteamOS's steamvr-web-debug-portforward.service forwards +# 0.0.0.0:8088 to this port (see README, "DevTools on the LAN"); masking it +# is recommended. Our patches only use 127.0.0.1. +{ config, lib, pkgs, ... }: +let + cfg = config.steamFrame.steamvrDebugger; + + usesDebugger = p: builtins.match "[a-z]+://[^/]*:8087(/.*)?" p.endpoint != null; + + merge = pkgs.writeShellApplication { + name = "steamvr-webhelper-debugger"; + runtimeInputs = [ pkgs.jq pkgs.coreutils ]; + text = '' + f="$HOME/.config/openvr/config/steamvr.vrsettings" + marker="''${XDG_STATE_HOME:-$HOME/.local/state}/steam-frame-nix/steamvr-debugger" + ${if cfg.enable then '' + want=true + '' else '' + if [ ! -e "$marker" ]; then + echo "not managed (VRWebHelper.DebuggerEnabled left as is)" + exit 0 + fi + want=false + ''} + mkdir -p "$(dirname "$f")" + [ -s "$f" ] || printf '{}\n' > "$f" + if [ "$(jq '.VRWebHelper.DebuggerEnabled' "$f")" = "$want" ]; then + echo "VRWebHelper.DebuggerEnabled already $want" + else + tmp="$(mktemp "$f.XXXXXX")" + trap 'rm -f "$tmp"' EXIT + jq --indent 3 --argjson v "$want" '.VRWebHelper.DebuggerEnabled = $v' "$f" > "$tmp" + chmod --reference="$f" "$tmp" + mv "$tmp" "$f" + echo "VRWebHelper.DebuggerEnabled set to $want (effective from this SteamVR start)" + fi + if [ "$want" = true ]; then + mkdir -p "$(dirname "$marker")" + touch "$marker" + else + rm -f "$marker" + fi + ''; + }; +in { + imports = [ ./steam-ui-patches.nix ]; + + options.steamFrame.steamvrDebugger.enable = lib.mkOption { + type = lib.types.bool; + default = false; + defaultText = lib.literalMD "`true` if a patch in `steamFrame.uiPatches.patches` uses port 8087, else `false`"; + description = '' + Enable SteamVR's web helper debugger (DevTools of the SteamVR dashboard + on 127.0.0.1:8087, setting VRWebHelper/DebuggerEnabled in + ~/.config/openvr/config/steamvr.vrsettings), needed by patches of the + SteamVR dashboard. Set before each SteamVR start, so it takes effect + after SteamVR is restarted once. Turning it off sets the key back to + false at the next SteamVR start. Note: SteamOS's + steamvr-web-debug-portforward.service exposes the port on 0.0.0.0:8088; + masking it is recommended. + ''; + }; + + config = { + steamFrame.steamvrDebugger.enable = + lib.mkDefault (lib.any usesDebugger config.steamFrame.uiPatches.patches); + + systemd.user.services.steamvr-webhelper-debugger = { + Unit = { + Description = "Set SteamVR VRWebHelper/DebuggerEnabled (steam-frame-nix)"; + Before = [ "steamvr.service" ]; + }; + Service = { + Type = "oneshot"; + ExecStart = "${merge}/bin/steamvr-webhelper-debugger"; + }; + }; + + # Wants= (not Requires=): if the merge fails (e.g. broken JSON), SteamVR + # still starts, just without the debugger. + xdg.configFile."systemd/user/steamvr.service.d/webhelper-debugger.conf".text = '' + [Unit] + Wants=steamvr-webhelper-debugger.service + After=steamvr-webhelper-debugger.service + ''; + }; +} diff --git a/template/home.nix b/template/home.nix index d762855..d13e42d 100644 --- a/template/home.nix +++ b/template/home.nix @@ -25,6 +25,13 @@ # closeOnLaunch = true; # launchDebounce = 10; # }; + # # SteamVR dashboard windows: resizable up to 4x (stock 2x), pushed + # # back up to 10 m in the world / 12 m in theater mode (stock 5 / 6 m): + # dashboard = { + # windowMaxScale = 4.0; + # windowDistance.world.max = 10.0; + # windowDistance.theater.max = 12.0; + # }; # firefox.enable = true; # launcher for the Firefox Flatpak # # Hidden from the "+" menu (with Steam Developer Mode on): # hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];