From 7fe68e055cca384e73bcec749d159d6bbe205368 Mon Sep 17 00:00:00 2001 From: Pierre Kisters <1524059+lhns@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:33:12 +0200 Subject: [PATCH] Dashboard patches: persist frame-controls placements and "Steam hidden" on disk SteamOS's steamvr.service runs `rm -rf ~/.cache/SteamVR` on every SteamVR start, which deletes vrwebhelper's browser profile and with it the localStorage frame-controls kept its placements in, so they were lost on every SteamVR restart and reboot. steam-ui-patches: per-patch persistent state (`state = true`). The injector keeps ~/.local/state/steam-frame-nix/ui-patches/.json, seeds window.__sfuiStore in a fresh page before the patch runs and writes the file (atomically, on change, name-checked, max 64 KiB) when the page calls the CDP binding __sfuiStoreSave. frame-controls v5: placements saved there (v4 localStorage copy migrated once, then removed). steam-close-button v8: "Steam hidden" saved there; a restored hidden state hides Steam once if SteamVR already shows it when the patch attaches (or first shows it before the patch sees Steam's frame). README: "Changes outside Nix" lists the directory; install.sh uninstall removes it. --- README.md | 48 ++++++++++++++++-- install.sh | 9 +++- modules/frame-controls.nix | 5 +- modules/frame-controls/patch.js | 33 +++++++++---- modules/frame-controls/unpatch.js | 2 +- modules/lib/default.nix | 2 + modules/steam-close-button.nix | 5 +- modules/steam-close-button/patch.js | 57 +++++++++++++++++----- modules/steam-ui-patches.nix | 18 ++++++- modules/steam-ui-patches/injector.mjs | 70 ++++++++++++++++++++++++++- 10 files changed, 215 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 57db56d..89ba36e 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- uninstall # --keep-nix Uninstall stops the Home Manager user services (reverting the Steam keyboard patch), runs `home-manager uninstall`, then removes Nix and per-user Nix -state. Your configuration, `*.hm-backup-*` files, app data and Flatpaks stay. +state, and the dashboard patches' saved state (see +[Changes outside Nix](#changes-outside-nix)). Your configuration, +`*.hm-backup-*` files, app data and Flatpaks stay. Manual setup: `nix flake init -t github:lhns/steam-frame-nix`. @@ -297,6 +299,7 @@ dashboard features are such patches, and you can add your own: | `target.title` / `target.titleRegex` / `target.urlRegex` | `null` | Pages to patch; all given criteria must match (JS regexes). | | `patch` | | JS file evaluated in every matching page (awaited). | | `unpatch` | `null` | JS file evaluated when the service stops. | +| `state` | `false` | Give the patch one persistent JSON value (below). | ```nix steamFrame.uiPatches.patches = [ { @@ -314,6 +317,22 @@ Patches are evaluated on attach, after new JS contexts (reloads) and every stock UI without a Steam restart. The service exists only while the list is non-empty and is restarted on every switch. +**Persistent state (`state = true`):** a patch's choices made in the UI +can't be kept in the page's own storage: SteamOS's `steamvr.service` runs +`rm -rf ~/.cache/SteamVR` (vrwebhelper's browser profile, incl. +localStorage) on every SteamVR start. So the service keeps one JSON value +per such patch in `~/.local/state/steam-frame-nix/ui-patches/.json` +(`$XDG_STATE_HOME`): before each evaluation it defines +`window.__sfuiStore.get(name)` / `.set(name, value)` in the page and fills +`get` from the file only while the page has no value yet (a fresh page after +a SteamVR restart, reboot or reload). `set` goes through the DevTools +binding `window.__sfuiStoreSave`; the service writes the file atomically, +only on change, only for that page's `state` patches, at most 64 KiB. Used +by the [window control bar](#window-control-bar-dashboardframecontrols) +and the [Steam close button](#steam-close-button-dashboardsteamclosebuttonenable). +The file is user data, not generated by Nix; see +[Changes outside Nix](#changes-outside-nix). + **DevTools on the LAN:** Steam's Developer Mode enables `steam-web-debug-portforward` (`0.0.0.0:8081` → `8080`) and `steamvr-web-debug-portforward` (`0.0.0.0:8088` → `8087`), and firewalld @@ -665,8 +684,14 @@ dashboard window, or **just the dashboard bar** if there is none. Steam stays hidden until you bring it back (Steam tab, a Steam menu pick, SteamVR asking for it): closing the active window, or a theater window, then goes to the previous window or the bar instead of Steam. This -survives dashboard reopens and patch-service restarts; a SteamVR restart -starts with Steam. Debugging: `window.__sfuiSteamClose.plan()` / +survives dashboard reopens, patch-service restarts, SteamVR restarts and +reboots ("Steam hidden" is saved in +`~/.local/state/steam-frame-nix/ui-patches/steam-close-button.json`, see +[persistent state](#ui-patches-uipatchespatches)). After a restart the +patch attaches a few seconds after the dashboard appears, possibly after +SteamVR has already shown Steam: if Steam is (or first becomes) the active +window then, it is hidden once like with X (only the bar at that point); +otherwise it just stays hidden. Debugging: `window.__sfuiSteamClose.plan()` / `homePlan()` and `window.__sfuiSteamCloseState` in the `systemui` page. **Limitations:** SteamVR's rarer "go home" paths (Now Playing after a game @@ -737,7 +762,10 @@ controller), and theater windows have no "Float". - **long press** a bar icon or menu row (`longPressMs`; a progress ring shows from half the time, at most after 1 s), then **Show in bar** in the popup moves that control between bar and menu **for all windows**. - Placements survive SteamVR restarts. + Placements survive SteamVR restarts and reboots (saved in + `~/.local/state/steam-frame-nix/ui-patches/frame-controls.json`, see + [persistent state](#ui-patches-uipatchespatches); version 4 kept them in + localStorage, which SteamVR wipes on every start, and is migrated once). - `inBar` / `inMenu` set where controls start; a popup choice wins until that control's entry changes. - `floatInTheater` gives theater windows the "Float" control. @@ -756,7 +784,7 @@ on the curvature control adjusts curvature and cancels the long press, also after the ring shows; once the ring shows, the drag needs 3× the usual travel (`dragThresholdPixels`, counted from where the press started), so laser drift during the hold doesn't cancel it. Debugging: `window.__sfuiFrameControls.dump()`, `.placement()`, -`.reset()` (forget choices), `.log`. +`.reset()` (forget choices), `.log`, `window.__sfuiFrameControlsState`. **Limitations:** laser only (no right-click or thumbstick click reaches the dashboard; gamepad navigation sees stock controls); placements are per @@ -894,6 +922,16 @@ services.flatpak.packages = [ "org.jellyfin.JellyfinDesktop" ]; decoder fails; for streams that decode with artifacts, disable the option (or set `hwdec = "auto-copy"`, Jellyfin's own value). +## Changes outside Nix + +Everything else is generated by Nix and linked from the store, or held in +memory; these files are written at runtime and outlive a switch that turns +the feature off: + +| Path | What, why | Cleanup | +|---|---|---| +| `~/.local/state/steam-frame-nix/ui-patches/.json` | Choices made in the dashboard: window control bar placements (`frame-controls.json`), "Steam hidden" (`steam-close-button.json`). Written by `steam-ui-patches` because SteamOS's `steamvr.service` deletes `~/.cache/SteamVR` (the dashboard's own browser storage) on every SteamVR start. Kept when a feature is disabled (your choices come back when you re-enable it). | `rm -rf ~/.local/state/steam-frame-nix/ui-patches` (also done by `install.sh uninstall`) | + ## Rollback `home-manager generations` lists previous generations; run diff --git a/install.sh b/install.sh index e2fa2fb..c7adc7c 100755 --- a/install.sh +++ b/install.sh @@ -59,8 +59,9 @@ Commands: uninstall [--yes] [--keep-nix] Stop Home Manager's user services, uninstall Home Manager, uninstall - Nix (unless --keep-nix) and remove per-user Nix leftovers. Your - configuration directory is never deleted. + Nix (unless --keep-nix) and remove per-user Nix leftovers and the + dashboard patches' saved state (~/.local/state/steam-frame-nix/ui-patches). + Your configuration directory is never deleted. status Show Nix, Home Manager and user service state. @@ -470,6 +471,10 @@ remove_leftovers() { # keep_nix fi fi remove_path "$state/home-manager" + # Saved state of the dashboard UI patches (steam-ui-patches, state = true). + # Only this subdirectory: the directory's other files have their own rules. + remove_path "$state/steam-frame-nix/ui-patches" + rmdir "$state/steam-frame-nix" 2>/dev/null || true if [[ -L $HM_CONFIG_LINK ]]; then info "$HM_CONFIG_LINK pointed to $(readlink "$HM_CONFIG_LINK")" remove_path "$HM_CONFIG_LINK" diff --git a/modules/frame-controls.nix b/modules/frame-controls.nix index 6a42a62..e913bab 100644 --- a/modules/frame-controls.nix +++ b/modules/frame-controls.nix @@ -26,7 +26,9 @@ in { moving SteamVR dashboard window controls between the bottom bar and the three-dot menu: long press an icon or menu row for a "Show in bar" popup. Applies to that control in all windows, kept across SteamVR - restarts. SteamVR dashboard patch; off restores stock (next switch)''; + restarts and reboots (in + ~/.local/state/steam-frame-nix/ui-patches/frame-controls.json). + SteamVR dashboard patch; off restores stock (next switch)''; longPressMs = mkOption { type = types.ints.between 300 10000; default = 1500; @@ -70,6 +72,7 @@ in { opts = removeAttrs cfg [ "enable" ]; }; unpatch = ./frame-controls/unpatch.js; + state = true; # popup placements, kept across restarts } ]; }; } diff --git a/modules/frame-controls/patch.js b/modules/frame-controls/patch.js index 6cd6eae..019caf0 100644 --- a/modules/frame-controls/patch.js +++ b/modules/frame-controls/patch.js @@ -25,7 +25,13 @@ // 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). +// re-patch and unpatch), saved on every change through the injector's +// persistent store (window.__sfuiStore, patch registered with state = true: +// ~/.local/state/steam-frame-nix/ui-patches/frame-controls.json), which +// seeds a fresh page (SteamVR restart, reboot, reload). localStorage can't +// do that: steamvr.service deletes ~/.cache/SteamVR (vrwebhelper's profile) +// on every start. Version 4's localStorage copy is read once as a fallback +// (migration) and removed once the file has the state. // // Long press: the dashboard only gets primary-button laser input (no right // click; thumbstick click arrives as nothing). Holding changes nothing; the @@ -63,9 +69,9 @@ // setPlacement(name or "icon:N", 'bar' | 'menu' | null), reset(). ((find, sigs, opts) => { const NAME = 'frame-controls'; - const VERSION = 4; + const VERSION = 5; const T_SPACER = 1, T_ACTION = 2; - const LS_KEY = 'sfui.frameControls.v1'; + const LS_KEY = 'sfui.frameControls.v1'; // version <= 4 (migration only) // Action icon enums (sigs.controls anchors them): names for the options. const NAMES = { keyboard: 22, float: 26, dashboard: 27, theater: 28, dockLeft: 29, dockRight: 30, close: 31, curvature: 40 }; const ICON = { more: 38, float: 26, dashboard: 27 }; @@ -109,20 +115,28 @@ prev?.teardown?.(); // other version or options: back to stock first // ---- state + log ------------------------------------------------------------------- - let S = window.__sfuiFrameControlsState; + const store = window.__sfuiStore; // injector's persistent store (absent: in-page only) + let S = window.__sfuiFrameControlsState, restored = null; if (S?.schema !== 1) { - let saved = null; - try { saved = JSON.parse(localStorage.getItem(LS_KEY) ?? 'null'); } catch { /* none */ } - const ok = saved?.schema === 1; - S = window.__sfuiFrameControlsState = { schema: 1, placement: ok ? saved.placement ?? {} : {}, nixSeen: ok ? saved.nixSeen ?? {} : {}, log: [] }; + let saved = store?.get(NAME), from = 'file'; + if (saved?.schema !== 1) { + from = 'localStorage'; + try { saved = JSON.parse(localStorage.getItem(LS_KEY) ?? 'null'); } catch { saved = null; } + } + const ok = saved?.schema === 1 && saved.placement && typeof saved.placement === 'object'; + restored = ok ? from : 'nothing'; + S = window.__sfuiFrameControlsState = { schema: 1, placement: ok ? { ...saved.placement } : {}, nixSeen: ok ? { ...saved.nixSeen } : {}, log: [] }; } const logBuf = S.log ??= []; const log = (msg, data) => { logBuf.push({ t: new Date().toISOString().slice(11, 23), msg, ...(data !== undefined ? { data } : {}) }); if (logBuf.length > 200) logBuf.splice(0, logBuf.length - 200); }; + if (restored) log('state restored', { from: restored, popup: S.placement }); + let unsaved = true; // last save not sent (no injector binding): retried by check() const save = () => { - try { localStorage.setItem(LS_KEY, JSON.stringify({ schema: 1, placement: S.placement, nixSeen: S.nixSeen })); } catch { /* in-page only */ } + unsaved = !store?.set?.(NAME, { schema: 1, placement: { ...S.placement }, nixSeen: { ...S.nixSeen } }); + if (!unsaved) { try { localStorage.removeItem(LS_KEY); } catch { /* none */ } } }; for (const k of new Set([...Object.keys(optMap), ...Object.keys(S.nixSeen)])) { if (S.nixSeen[k] !== optMap[k] && k in S.placement) { @@ -610,6 +624,7 @@ document.getElementById(STYLE_ID)?.remove(); }; const check = () => { + if (unsaved) save(); if (findOurs()) return 'unchanged'; install(); captureExisting(); applyAll(); return 'patched (wrapper reinstalled)'; diff --git a/modules/frame-controls/unpatch.js b/modules/frame-controls/unpatch.js index d20f222..226908b 100644 --- a/modules/frame-controls/unpatch.js +++ b/modules/frame-controls/unpatch.js @@ -1,7 +1,7 @@ // 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. +// actions. Keeps window.__sfuiFrameControlsState and the saved state file. // Safe when not patched. (() => { const s = window.__sfuiFrameControls; diff --git a/modules/lib/default.nix b/modules/lib/default.nix index 00908fd..618295d 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -16,6 +16,8 @@ # "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. +# State that must outlive the page (SteamVR restarts, reboots) goes through +# window.__sfuiStore (patch registered with state = true; injector.mjs). # Patches needing none of the arguments may be plain expressions without # mkPatch (e.g. launcher-menu/order). { pkgs }: diff --git a/modules/steam-close-button.nix b/modules/steam-close-button.nix index eccd720..6fe20a0 100644 --- a/modules/steam-close-button.nix +++ b/modules/steam-close-button.nix @@ -11,7 +11,9 @@ in { options.steamFrame.dashboard.steamCloseButton.enable = lib.mkEnableOption '' a Close (X) button on the dashboard's Steam window. It hides Steam (previous window or just the bar) until shown explicitly, e.g. via the - Steam tab. SteamVR dashboard patch; off removes it (next switch)''; + Steam tab, also across SteamVR restarts and reboots (in + ~/.local/state/steam-frame-nix/ui-patches/steam-close-button.json). + SteamVR dashboard patch; off removes it (next switch)''; config = lib.mkIf cfg.enable { steamFrame.uiPatches.patches = [ { @@ -23,6 +25,7 @@ in { src = ./steam-close-button/patch.js; }; unpatch = ./steam-close-button/unpatch.js; + state = true; # "Steam hidden", kept across restarts } ]; }; } diff --git a/modules/steam-close-button/patch.js b/modules/steam-close-button/patch.js index fa9021c..e3e9109 100644 --- a/modules/steam-close-button/patch.js +++ b/modules/steam-close-button/patch.js @@ -22,14 +22,26 @@ // passed on without the key. // Rarer stock "go home" paths (Now Playing, message overlay) still show Steam. // +// Restarts: "Steam hidden" is saved through the injector's persistent store +// (window.__sfuiStore, patch registered with state = true: +// ~/.local/state/steam-frame-nix/ui-patches/steam-close-button.json; +// { schema: 1, steamHidden }) and restored into a fresh page (SteamVR +// restart, reboot, dashboard reload). Frame ids, bar-only and the counters +// are per run and not saved. The injector attaches within ~5 s of the page +// appearing, so Steam's window may already be up and active then; a restored +// hidden state therefore stays pending (restorePending) until Steam's frame +// is seen: active at that point (or when it first becomes active while +// pending) -> hidden once, like X (previous window, else bar only); inactive +// -> just stays hidden (the overrides below keep stock from showing it). +// // Contract: state in window.__sfuiSteamCloseState (schema 1: steamHidden, -// barOnly, history, redirected, redirects; older versions' fields are left -// for a rollback) survives re-injection; teardown restores all overrides and -// never switches frames. +// barOnly, history, redirected, redirects, restorePending; older versions' +// fields are left for a rollback) survives re-injection; teardown restores +// all overrides and never switches frames. // window.__sfuiSteamClose: plan(), homePlan(), steamHidden, barOnly, state. ((find, sigs, opts) => { const NAME = 'steam-close-button'; - const VERSION = 7; + const VERSION = 8; const MAIN_KEY = 'valve.steam.gamepadui.main'; // sigs.overlayKeys const FRAME_ALIVE = 2; // sigs.frame const REASON = 'sfui steam-close-button'; @@ -55,17 +67,26 @@ prev?.teardown?.(); // other VERSION; never switches frames + const store = window.__sfuiStore; // injector's persistent store (absent: in-page only) const S = (() => { const s = window.__sfuiSteamCloseState; - if (s?.schema !== 1) return (window.__sfuiSteamCloseState = - { schema: 1, steamHidden: false, barOnly: false, history: [], redirected: 0, redirects: [] }); + if (s?.schema !== 1) { // fresh page: restore from the store + const saved = store?.get(NAME), hidden = saved?.schema === 1 && saved.steamHidden === true; + return (window.__sfuiSteamCloseState = { schema: 1, steamHidden: hidden, barOnly: false, history: [], + redirected: 0, redirects: [], restorePending: hidden, restored: saved?.schema === 1 ? { steamHidden: hidden } : null }); + } s.steamHidden ??= !!s.barOnly; // version <= 5: bar-only implied hidden s.barOnly = !!s.barOnly && DS.activeFrame == null; - s.history ??= []; s.redirected ??= 0; s.redirects ??= []; + s.history ??= []; s.redirected ??= 0; s.redirects ??= []; s.restorePending = !!s.restorePending && s.steamHidden; return s; })(); + let persisted; // last steamHidden sent to the store + const persist = () => { + if (persisted === S.steamHidden) return; + if (store?.set?.(NAME, { schema: 1, steamHidden: S.steamHidden })) persisted = S.steamHidden; + }; const mainActive = () => DS.activeFrameID != null && DS.activeFrameID === DS.m_unMainSteamFrameID; - if (mainActive()) S.steamHidden = false; + if (mainActive() && !S.restorePending) S.steamHidden = false; const note = (id) => { // recently active frames, most recent last if (id == null) return; @@ -115,6 +136,13 @@ if (p.to != null && window.Dashboard?.switchToFrameInternal) window.Dashboard.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON); else barOnly(); }; + // Restored "hidden" (fresh page): settled once Steam's frame exists (and the + // overrides are in): hidden once if it is active, else it just stays hidden. + const settleRestore = () => { + if (!S.restorePending || !DS.mainSteamFrame || !window.Dashboard) return; + S.restorePending = false; + if (mainActive()) goHome(DS.mainSteamFrame, 'restored after restart'); + }; // ---- overrides: a Dashboard instance method and its mailbox handlers ------ const methodWrappers = { @@ -187,7 +215,7 @@ const onClose = (frame) => { const p = plan(frame); const dash = window.Dashboard; - if (p.action !== 'none' || p.dock) { guard(); S.steamHidden = true; } + if (p.action !== 'none' || p.dock) { guard(); S.steamHidden = true; S.restorePending = false; persist(); } if (p.dock) frame.docking.SetDockLocation(DOCK_DASHBOARD); if (p.action === 'switch') dash.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON); else if (p.action === 'bar-only' && guard()) barOnly(); @@ -236,7 +264,9 @@ const f = DS.mainSteamFrame; if (closing && closing.frame !== f) unpatchFrame(); // main frame recreated guard(); - if (!f) return 'main Steam frame not found'; + settleRestore(); + persist(); + if (!f) return `main Steam frame not found${S.restorePending ? ' (Steam hidden, pending)' : ''}`; const r = patchFrame(f); const notes = [S.steamHidden && 'Steam hidden', S.barOnly && 'bar-only', S.redirected && `${S.redirected} redirect${S.redirected === 1 ? '' : 's'}`].filter(Boolean); @@ -246,7 +276,12 @@ const disposers = [ mx.reaction(() => DS.activeFrameID, note), mx.reaction(() => DS.activeFrameID != null, (active) => { if (active) S.barOnly = false; }), - mx.reaction(mainActive, (on) => { if (on) S.steamHidden = false; }), + mx.reaction(mainActive, (on) => { + if (!on) return; + if (S.restorePending) return settleRestore(); // stock showed Steam before we saw it + S.steamHidden = false; + persist(); + }), mx.reaction(() => DS.m_unMainSteamFrameID, () => { try { apply(); } catch (e) { console.error(NAME, e); } }), ]; const teardown = () => { diff --git a/modules/steam-ui-patches.nix b/modules/steam-ui-patches.nix index 104f406..d0bbae4 100644 --- a/modules/steam-ui-patches.nix +++ b/modules/steam-ui-patches.nix @@ -5,7 +5,8 @@ # 15 s, and runs the unpatches on stop; Steam's files are untouched. # It exists while `patches` is non-empty, restarts on every switch (the old # instance reverts removed patches) and is stopped once the list is empty. -# Patch calling convention (mkPatch): lib/default.nix. +# Patches with `state = true` get a persistent JSON value (injector.mjs: +# "Persistent state"). Patch calling convention (mkPatch): lib/default.nix. { config, pkgs, lib, ... }: let cfg = config.steamFrame.uiPatches; @@ -62,11 +63,24 @@ let safe on an unpatched page. ''; }; + state = mkOption { + type = types.bool; + default = false; + description = '' + Give the patch one persistent JSON value (e.g. choices made in its + UI), kept by the service in + $XDG_STATE_HOME/steam-frame-nix/ui-patches/.json across + SteamVR restarts and reboots: the page reads it with + `window.__sfuiStore.get(name)` and writes it with + `window.__sfuiStore.set(name, value)` (see injector.mjs). The file + is user data and stays when the patch is removed. + ''; + }; }; }; entry = p: { - inherit (p) name endpoint patch unpatch; + inherit (p) name endpoint patch unpatch state; target = lib.filterAttrs (_: v: v != null) p.target; }; diff --git a/modules/steam-ui-patches/injector.mjs b/modules/steam-ui-patches/injector.mjs index 492e01f..9f1a380 100644 --- a/modules/steam-ui-patches/injector.mjs +++ b/modules/steam-ui-patches/injector.mjs @@ -11,7 +11,8 @@ // "endpoint": "http://127.0.0.1:8080", // "target": { "title": "SharedJSContext" }, // or titleRegex / urlRegex // "patch": "/nix/store/...-patch.js", -// "unpatch": "/nix/store/...-unpatch.js" } ] } // unpatch optional +// "unpatch": "/nix/store/...-unpatch.js", // optional +// "state": true } ] } // optional, see below // // Every target (page of an endpoint's /json/list) matched by at least one // patch gets one DevTools session, which evaluates all of its patches: right @@ -20,7 +21,22 @@ // logged when it changes, e.g. "patched" once, then "unchanged" silently. // On SIGTERM/SIGINT every live session evaluates its patches' unpatch // expressions (if any) (each with a timeout), then the process exits. -import { readFileSync } from 'node:fs'; +// +// Persistent state ("state": true): one JSON value per patch, kept in +// $XDG_STATE_HOME/steam-frame-nix/ui-patches/.json (default +// ~/.local/state/...), so it survives SteamVR restarts (steamvr.service wipes +// ~/.cache/SteamVR, the pages' localStorage, on every start) and reboots. +// Before each evaluation of such a patch a prelude defines, in the page, +// window.__sfuiStore.get(name) the stored value (undefined: none) +// window.__sfuiStore.set(name, value) store it (JSON; true if sent) +// and seeds get(name) from the file only when the page has no value yet (a +// fresh page after a SteamVR restart or reload): the page's value is +// authoritative from then on. set() goes through the CDP binding +// window.__sfuiStoreSave('{"name","value"}'), which this process writes to the +// file (atomically, only on change, only for "state" patches of that target, +// at most 64 KiB). The file is user data: nothing deletes it. +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; const cfg = JSON.parse(readFileSync(process.argv[2], 'utf8')); const POLL = cfg.pollMs ?? 5000; @@ -28,6 +44,41 @@ const REINJECT = cfg.reinjectMs ?? 15000; const DEBOUNCE = cfg.debounceMs ?? 3000; const UNPATCH_TIMEOUT = cfg.unpatchTimeoutMs ?? 2000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const STATE_DIR = cfg.stateDir ?? join(process.env.XDG_STATE_HOME || join(process.env.HOME ?? '.', '.local/state'), 'steam-frame-nix/ui-patches'); +const STATE_MAX = 64 * 1024; +const BINDING = '__sfuiStoreSave'; + +// name -> JSON text of the stored value (absent: none), read once at start. +const stored = new Map(); +const stateFile = (name) => join(STATE_DIR, `${name}.json`); +function loadState(name) { + let text; + try { text = readFileSync(stateFile(name), 'utf8'); } catch { return; } // none yet + try { stored.set(name, JSON.stringify(JSON.parse(text))); } catch (e) { console.error(`${name}: ignoring ${stateFile(name)}: ${e.message}`); } +} +function saveState(name, json) { + if (stored.get(name) === json) return false; + mkdirSync(STATE_DIR, { recursive: true }); + const tmp = `${stateFile(name)}.tmp`; + writeFileSync(tmp, `${json}\n`); + renameSync(tmp, stateFile(name)); + stored.set(name, json); + return true; +} +// Page side of the store (idempotent), seeding `name` if the page has no value. +const prelude = (name) => `(() => { + const s = window.__sfuiStore ??= { + data: {}, + get(n) { return this.data[n]; }, + set(n, v) { + this.data[n] = v; + try { if (typeof window.${BINDING} !== 'function') return false; window.${BINDING}(JSON.stringify({ name: n, value: v })); return true; } catch { return false; } + }, + }; + const n = ${JSON.stringify(name)}; + if (!(n in s.data) && ${stored.has(name)}) s.data[n] = ${stored.get(name) ?? 'undefined'}; + return 'ok'; +})()`; const patches = cfg.patches.map((p) => { const t = p.target ?? {}; @@ -39,8 +90,10 @@ const patches = cfg.patches.map((p) => { (!titleRe || titleRe.test(x.title)) && (!urlRe || urlRe.test(x.url)), patch: readFileSync(p.patch, 'utf8'), unpatch: p.unpatch ? readFileSync(p.unpatch, 'utf8') : null, + state: !!p.state, }; }); +for (const p of patches) if (p.state) loadState(p.name); const sessions = new Map(); // " " -> session let stopping = false; @@ -63,6 +116,7 @@ function open(endpoint, target, list) { const evaluate = (expression) => call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); const inject = async () => { for (const p of list) { + if (p.state) await evaluate(prelude(p.name)); const v = String(value(await evaluate(p.patch))); if (last.get(p.name) !== v && v !== 'unchanged') console.log(`${tag} ${p.name}: ${v}`); last.set(p.name, v); @@ -74,12 +128,23 @@ function open(endpoint, target, list) { console.log(`${tag} ${p.name} unpatch: ${value(r)}`); })); const session = { unpatch }; + const stateful = new Set(list.filter((p) => p.state).map((p) => p.name)); + const onSave = (payload) => { + try { + if (payload.length > STATE_MAX) throw new Error(`${payload.length} bytes, max ${STATE_MAX}`); + const { name, value: v } = JSON.parse(payload); + if (!stateful.has(name)) throw new Error(`no state for ${JSON.stringify(name)} here`); + if (v === undefined) throw new Error('no value'); + if (saveState(name, JSON.stringify(v))) console.log(`${tag} ${name}: state saved`); + } catch (e) { console.error(`${tag} state save refused: ${e.message}`); } + }; sessions.set(key, session); ws.onopen = async () => { connected = true; console.log(`${tag} connected (${endpoint}): ${list.map((p) => p.name).join(', ')}`); await call('Runtime.enable'); + if (stateful.size) await call('Runtime.addBinding', { name: BINDING }); await inject().catch((e) => console.error(`${tag} inject:`, e.message)); timer = setInterval(() => inject().catch(() => {}), REINJECT); }; @@ -87,6 +152,7 @@ function open(endpoint, target, list) { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } else if (m.method === 'Runtime.executionContextCreated' && !stopping) injectSoon(); + else if (m.method === 'Runtime.bindingCalled' && m.params?.name === BINDING && stateful.size) onSave(String(m.params.payload)); }; ws.onerror = () => {}; // followed by onclose ws.onclose = () => {