mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +02:00
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:
1 parent
48d10cc483
commit
7fe68e055c
10 files changed
+215
-34
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
} ];
|
||||
};
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 }:
|
||||
|
||||
@@ -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
|
||||
} ];
|
||||
};
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in new issue
Block a user