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/<name>.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.
This commit is contained in:
Pierre Kisters committed 2026-09-28 23:33:12 +02:00
1 parent 48d10cc483
commit 7fe68e055c
10 files changed
+215 -34

No files matched your search

+43 -5
View File
@@ -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 Uninstall stops the Home Manager user services (reverting the Steam keyboard
patch), runs `home-manager uninstall`, then removes Nix and per-user Nix 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`. 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). | | `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). | | `patch` | | JS file evaluated in every matching page (awaited). |
| `unpatch` | `null` | JS file evaluated when the service stops. | | `unpatch` | `null` | JS file evaluated when the service stops. |
| `state` | `false` | Give the patch one persistent JSON value (below). |
```nix ```nix
steamFrame.uiPatches.patches = [ { 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 stock UI without a Steam restart. The service exists only while the list is
non-empty and is restarted on every switch. 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/<name>.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 **DevTools on the LAN:** Steam's Developer Mode enables
`steam-web-debug-portforward` (`0.0.0.0:8081` → `8080`) and `steam-web-debug-portforward` (`0.0.0.0:8081` → `8080`) and
`steamvr-web-debug-portforward` (`0.0.0.0:8088` → `8087`), and firewalld `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, 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, 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 then goes to the previous window or the bar instead of Steam. This
survives dashboard reopens and patch-service restarts; a SteamVR restart survives dashboard reopens, patch-service restarts, SteamVR restarts and
starts with Steam. Debugging: `window.__sfuiSteamClose.plan()` / 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. `homePlan()` and `window.__sfuiSteamCloseState` in the `systemui` page.
**Limitations:** SteamVR's rarer "go home" paths (Now Playing after a game **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 - **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 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**. 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 - `inBar` / `inMenu` set where controls start; a popup choice wins until
that control's entry changes. that control's entry changes.
- `floatInTheater` gives theater windows the "Float" control. - `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 after the ring shows; once the ring shows, the drag needs 3× the usual travel
(`dragThresholdPixels`, counted from where the press started), so laser (`dragThresholdPixels`, counted from where the press started), so laser
drift during the hold doesn't cancel it. Debugging: `window.__sfuiFrameControls.dump()`, `.placement()`, 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 **Limitations:** laser only (no right-click or thumbstick click reaches the
dashboard; gamepad navigation sees stock controls); placements are per 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 decoder fails; for streams that decode with artifacts, disable the option
(or set `hwdec = "auto-copy"`, Jellyfin's own value). (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/<name>.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 ## Rollback
`home-manager generations` lists previous generations; run `home-manager generations` lists previous generations; run
+7 -2
View File
@@ -59,8 +59,9 @@ Commands:
uninstall [--yes] [--keep-nix] uninstall [--yes] [--keep-nix]
Stop Home Manager's user services, uninstall Home Manager, uninstall Stop Home Manager's user services, uninstall Home Manager, uninstall
Nix (unless --keep-nix) and remove per-user Nix leftovers. Your Nix (unless --keep-nix) and remove per-user Nix leftovers and the
configuration directory is never deleted. dashboard patches' saved state (~/.local/state/steam-frame-nix/ui-patches).
Your configuration directory is never deleted.
status status
Show Nix, Home Manager and user service state. Show Nix, Home Manager and user service state.
@@ -470,6 +471,10 @@ remove_leftovers() { # keep_nix
fi fi
fi fi
remove_path "$state/home-manager" 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 if [[ -L $HM_CONFIG_LINK ]]; then
info "$HM_CONFIG_LINK pointed to $(readlink "$HM_CONFIG_LINK")" info "$HM_CONFIG_LINK pointed to $(readlink "$HM_CONFIG_LINK")"
remove_path "$HM_CONFIG_LINK" remove_path "$HM_CONFIG_LINK"
+4 -1
View File
@@ -26,7 +26,9 @@ in {
moving SteamVR dashboard window controls between the bottom bar and the 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" 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 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 { longPressMs = mkOption {
type = types.ints.between 300 10000; type = types.ints.between 300 10000;
default = 1500; default = 1500;
@@ -70,6 +72,7 @@ in {
opts = removeAttrs cfg [ "enable" ]; opts = removeAttrs cfg [ "enable" ];
}; };
unpatch = ./frame-controls/unpatch.js; unpatch = ./frame-controls/unpatch.js;
state = true; # popup placements, kept across restarts
} ]; } ];
}; };
} }
+24 -9
View File
@@ -25,7 +25,13 @@
// frame, app window keys per app start). Effective: popup choice, else // 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 // opts.inBar/inMenu, else stock; a popup choice is dropped when that control's
// option changes. State in window.__sfuiFrameControlsState (kept across // 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 // Long press: the dashboard only gets primary-button laser input (no right
// click; thumbstick click arrives as nothing). Holding changes nothing; the // click; thumbstick click arrives as nothing). Holding changes nothing; the
@@ -63,9 +69,9 @@
// setPlacement(name or "icon:N", 'bar' | 'menu' | null), reset(). // setPlacement(name or "icon:N", 'bar' | 'menu' | null), reset().
((find, sigs, opts) => { ((find, sigs, opts) => {
const NAME = 'frame-controls'; const NAME = 'frame-controls';
const VERSION = 4; const VERSION = 5;
const T_SPACER = 1, T_ACTION = 2; 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. // 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 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 }; const ICON = { more: 38, float: 26, dashboard: 27 };
@@ -109,20 +115,28 @@
prev?.teardown?.(); // other version or options: back to stock first prev?.teardown?.(); // other version or options: back to stock first
// ---- state + log ------------------------------------------------------------------- // ---- 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) { if (S?.schema !== 1) {
let saved = null; let saved = store?.get(NAME), from = 'file';
try { saved = JSON.parse(localStorage.getItem(LS_KEY) ?? 'null'); } catch { /* none */ } if (saved?.schema !== 1) {
const ok = saved?.schema === 1; from = 'localStorage';
S = window.__sfuiFrameControlsState = { schema: 1, placement: ok ? saved.placement ?? {} : {}, nixSeen: ok ? saved.nixSeen ?? {} : {}, log: [] }; 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 logBuf = S.log ??= [];
const log = (msg, data) => { const log = (msg, data) => {
logBuf.push({ t: new Date().toISOString().slice(11, 23), msg, ...(data !== undefined ? { 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 (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 = () => { 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)])) { for (const k of new Set([...Object.keys(optMap), ...Object.keys(S.nixSeen)])) {
if (S.nixSeen[k] !== optMap[k] && k in S.placement) { if (S.nixSeen[k] !== optMap[k] && k in S.placement) {
@@ -610,6 +624,7 @@
document.getElementById(STYLE_ID)?.remove(); document.getElementById(STYLE_ID)?.remove();
}; };
const check = () => { const check = () => {
if (unsaved) save();
if (findOurs()) return 'unchanged'; if (findOurs()) return 'unchanged';
install(); captureExisting(); applyAll(); install(); captureExisting(); applyAll();
return 'patched (wrapper reinstalled)'; return 'patched (wrapper reinstalled)';
+1 -1
View File
@@ -1,7 +1,7 @@
// Reverts frame-controls/patch.js: ends a long press, closes the popup, restores // Reverts frame-controls/patch.js: ends a long press, closes the popup, restores
// stock control lists, removes wrappers (a wrapper wrapped by something else // stock control lists, removes wrappers (a wrapper wrapped by something else
// since stays inert in the chain), listeners, style and the theater Float // 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. // Safe when not patched.
(() => { (() => {
const s = window.__sfuiFrameControls; const s = window.__sfuiFrameControls;
+2
View File
@@ -16,6 +16,8 @@
# "unchanged" when already applied (not logged) and tear down/re-apply when # "unchanged" when already applied (not logged) and tear down/re-apply when
# its VERSION or options differ. Bump VERSION whenever the patch code changes. # its VERSION or options differ. Bump VERSION whenever the patch code changes.
# A matching unpatch.js (plain expression) reverts it when the service stops. # 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 # Patches needing none of the arguments may be plain expressions without
# mkPatch (e.g. launcher-menu/order). # mkPatch (e.g. launcher-menu/order).
{ pkgs }: { pkgs }:
+4 -1
View File
@@ -11,7 +11,9 @@ in {
options.steamFrame.dashboard.steamCloseButton.enable = lib.mkEnableOption '' options.steamFrame.dashboard.steamCloseButton.enable = lib.mkEnableOption ''
a Close (X) button on the dashboard's Steam window. It hides Steam 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 (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 { config = lib.mkIf cfg.enable {
steamFrame.uiPatches.patches = [ { steamFrame.uiPatches.patches = [ {
@@ -23,6 +25,7 @@ in {
src = ./steam-close-button/patch.js; src = ./steam-close-button/patch.js;
}; };
unpatch = ./steam-close-button/unpatch.js; unpatch = ./steam-close-button/unpatch.js;
state = true; # "Steam hidden", kept across restarts
} ]; } ];
}; };
} }
+46 -11
View File
@@ -22,14 +22,26 @@
// passed on without the key. // passed on without the key.
// Rarer stock "go home" paths (Now Playing, message overlay) still show Steam. // 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, // Contract: state in window.__sfuiSteamCloseState (schema 1: steamHidden,
// barOnly, history, redirected, redirects; older versions' fields are left // barOnly, history, redirected, redirects, restorePending; older versions'
// for a rollback) survives re-injection; teardown restores all overrides and // fields are left for a rollback) survives re-injection; teardown restores
// never switches frames. // all overrides and never switches frames.
// window.__sfuiSteamClose: plan(), homePlan(), steamHidden, barOnly, state. // window.__sfuiSteamClose: plan(), homePlan(), steamHidden, barOnly, state.
((find, sigs, opts) => { ((find, sigs, opts) => {
const NAME = 'steam-close-button'; const NAME = 'steam-close-button';
const VERSION = 7; const VERSION = 8;
const MAIN_KEY = 'valve.steam.gamepadui.main'; // sigs.overlayKeys const MAIN_KEY = 'valve.steam.gamepadui.main'; // sigs.overlayKeys
const FRAME_ALIVE = 2; // sigs.frame const FRAME_ALIVE = 2; // sigs.frame
const REASON = 'sfui steam-close-button'; const REASON = 'sfui steam-close-button';
@@ -55,17 +67,26 @@
prev?.teardown?.(); // other VERSION; never switches frames prev?.teardown?.(); // other VERSION; never switches frames
const store = window.__sfuiStore; // injector's persistent store (absent: in-page only)
const S = (() => { const S = (() => {
const s = window.__sfuiSteamCloseState; const s = window.__sfuiSteamCloseState;
if (s?.schema !== 1) return (window.__sfuiSteamCloseState = if (s?.schema !== 1) { // fresh page: restore from the store
{ schema: 1, steamHidden: false, barOnly: false, history: [], redirected: 0, redirects: [] }); 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.steamHidden ??= !!s.barOnly; // version <= 5: bar-only implied hidden
s.barOnly = !!s.barOnly && DS.activeFrame == null; 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; 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; 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 const note = (id) => { // recently active frames, most recent last
if (id == null) return; if (id == null) return;
@@ -115,6 +136,13 @@
if (p.to != null && window.Dashboard?.switchToFrameInternal) window.Dashboard.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON); if (p.to != null && window.Dashboard?.switchToFrameInternal) window.Dashboard.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON);
else barOnly(); 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 ------ // ---- overrides: a Dashboard instance method and its mailbox handlers ------
const methodWrappers = { const methodWrappers = {
@@ -187,7 +215,7 @@
const onClose = (frame) => { const onClose = (frame) => {
const p = plan(frame); const p = plan(frame);
const dash = window.Dashboard; 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.dock) frame.docking.SetDockLocation(DOCK_DASHBOARD);
if (p.action === 'switch') dash.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON); if (p.action === 'switch') dash.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON);
else if (p.action === 'bar-only' && guard()) barOnly(); else if (p.action === 'bar-only' && guard()) barOnly();
@@ -236,7 +264,9 @@
const f = DS.mainSteamFrame; const f = DS.mainSteamFrame;
if (closing && closing.frame !== f) unpatchFrame(); // main frame recreated if (closing && closing.frame !== f) unpatchFrame(); // main frame recreated
guard(); 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 r = patchFrame(f);
const notes = [S.steamHidden && 'Steam hidden', S.barOnly && 'bar-only', const notes = [S.steamHidden && 'Steam hidden', S.barOnly && 'bar-only',
S.redirected && `${S.redirected} redirect${S.redirected === 1 ? '' : 's'}`].filter(Boolean); S.redirected && `${S.redirected} redirect${S.redirected === 1 ? '' : 's'}`].filter(Boolean);
@@ -246,7 +276,12 @@
const disposers = [ const disposers = [
mx.reaction(() => DS.activeFrameID, note), mx.reaction(() => DS.activeFrameID, note),
mx.reaction(() => DS.activeFrameID != null, (active) => { if (active) S.barOnly = false; }), 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); } }), mx.reaction(() => DS.m_unMainSteamFrameID, () => { try { apply(); } catch (e) { console.error(NAME, e); } }),
]; ];
const teardown = () => { const teardown = () => {
+16 -2
View File
@@ -5,7 +5,8 @@
# 15 s, and runs the unpatches on stop; Steam's files are untouched. # 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 # 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. # 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, ... }: { config, pkgs, lib, ... }:
let let
cfg = config.steamFrame.uiPatches; cfg = config.steamFrame.uiPatches;
@@ -62,11 +63,24 @@ let
safe on an unpatched page. 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/<name>.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: { entry = p: {
inherit (p) name endpoint patch unpatch; inherit (p) name endpoint patch unpatch state;
target = lib.filterAttrs (_: v: v != null) p.target; target = lib.filterAttrs (_: v: v != null) p.target;
}; };
+68 -2
View File
@@ -11,7 +11,8 @@
// "endpoint": "http://127.0.0.1:8080", // "endpoint": "http://127.0.0.1:8080",
// "target": { "title": "SharedJSContext" }, // or titleRegex / urlRegex // "target": { "title": "SharedJSContext" }, // or titleRegex / urlRegex
// "patch": "/nix/store/...-patch.js", // "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 // 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 // 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. // logged when it changes, e.g. "patched" once, then "unchanged" silently.
// On SIGTERM/SIGINT every live session evaluates its patches' unpatch // On SIGTERM/SIGINT every live session evaluates its patches' unpatch
// expressions (if any) (each with a timeout), then the process exits. // 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/<name>.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 cfg = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const POLL = cfg.pollMs ?? 5000; const POLL = cfg.pollMs ?? 5000;
@@ -28,6 +44,41 @@ const REINJECT = cfg.reinjectMs ?? 15000;
const DEBOUNCE = cfg.debounceMs ?? 3000; const DEBOUNCE = cfg.debounceMs ?? 3000;
const UNPATCH_TIMEOUT = cfg.unpatchTimeoutMs ?? 2000; const UNPATCH_TIMEOUT = cfg.unpatchTimeoutMs ?? 2000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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 patches = cfg.patches.map((p) => {
const t = p.target ?? {}; const t = p.target ?? {};
@@ -39,8 +90,10 @@ const patches = cfg.patches.map((p) => {
(!titleRe || titleRe.test(x.title)) && (!urlRe || urlRe.test(x.url)), (!titleRe || titleRe.test(x.title)) && (!urlRe || urlRe.test(x.url)),
patch: readFileSync(p.patch, 'utf8'), patch: readFileSync(p.patch, 'utf8'),
unpatch: p.unpatch ? readFileSync(p.unpatch, 'utf8') : null, 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(); // "<endpoint> <target id>" -> session const sessions = new Map(); // "<endpoint> <target id>" -> session
let stopping = false; let stopping = false;
@@ -63,6 +116,7 @@ function open(endpoint, target, list) {
const evaluate = (expression) => call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); const evaluate = (expression) => call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
const inject = async () => { const inject = async () => {
for (const p of list) { for (const p of list) {
if (p.state) await evaluate(prelude(p.name));
const v = String(value(await evaluate(p.patch))); const v = String(value(await evaluate(p.patch)));
if (last.get(p.name) !== v && v !== 'unchanged') console.log(`${tag} ${p.name}: ${v}`); if (last.get(p.name) !== v && v !== 'unchanged') console.log(`${tag} ${p.name}: ${v}`);
last.set(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)}`); console.log(`${tag} ${p.name} unpatch: ${value(r)}`);
})); }));
const session = { unpatch }; 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); sessions.set(key, session);
ws.onopen = async () => { ws.onopen = async () => {
connected = true; connected = true;
console.log(`${tag} connected (${endpoint}): ${list.map((p) => p.name).join(', ')}`); console.log(`${tag} connected (${endpoint}): ${list.map((p) => p.name).join(', ')}`);
await call('Runtime.enable'); await call('Runtime.enable');
if (stateful.size) await call('Runtime.addBinding', { name: BINDING });
await inject().catch((e) => console.error(`${tag} inject:`, e.message)); await inject().catch((e) => console.error(`${tag} inject:`, e.message));
timer = setInterval(() => inject().catch(() => {}), REINJECT); timer = setInterval(() => inject().catch(() => {}), REINJECT);
}; };
@@ -87,6 +152,7 @@ function open(endpoint, target, list) {
const m = JSON.parse(e.data); const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } 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.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.onerror = () => {}; // followed by onclose
ws.onclose = () => { ws.onclose = () => {