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
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/<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
`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/<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
`home-manager generations` lists previous generations; run
+7 -2
View File
@@ -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"
+4 -1
View File
@@ -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
} ];
};
}
+24 -9
View File
@@ -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)';
+1 -1
View File
@@ -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;
+2
View File
@@ -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 }:
+4 -1
View File
@@ -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
} ];
};
}
+46 -11
View File
@@ -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 = () => {
+16 -2
View File
@@ -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/<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: {
inherit (p) name endpoint patch unpatch;
inherit (p) name endpoint patch unpatch state;
target = lib.filterAttrs (_: v: v != null) p.target;
};
+68 -2
View File
@@ -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/<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 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(); // "<endpoint> <target id>" -> 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 = () => {