Files
lhns--steam-frame-nix/modules/steam-ui-patches.nix
T
Pierre Kisters 7fe68e055c 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.
2026-09-28 23:33:12 +02:00

160 lines
5.4 KiB
Nix

# Runtime patches of Steam's and SteamVR's web UIs over local CEF DevTools
# (Steam 127.0.0.1:8080; SteamVR's vrwebhelper 127.0.0.1:8087, see
# steamvr-debugger.nix). The steam-ui-patches service (injector.mjs) injects
# each registered patch into its target pages, re-injects on reload and every
# 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.
# 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;
inherit (lib) mkOption types;
patchType = types.submodule {
options = {
name = mkOption {
type = types.strMatching "[A-Za-z0-9_.-]+";
example = "launcher-menu-order";
description = "Unique name, used in the service's log.";
};
endpoint = mkOption {
type = types.str;
default = "http://127.0.0.1:8080";
example = "http://127.0.0.1:8087";
description = ''
DevTools base URL (its /json/list is polled): Steam 8080, SteamVR
8087.
'';
};
target = {
title = mkOption {
type = types.nullOr types.str;
default = null;
example = "SharedJSContext";
description = "Exact page title of the targets to patch.";
};
titleRegex = mkOption {
type = types.nullOr types.str;
default = null;
description = "JavaScript regex the page title must match.";
};
urlRegex = mkOption {
type = types.nullOr types.str;
default = null;
description = "JavaScript regex the page URL must match.";
};
};
patch = mkOption {
type = types.path;
description = ''
JavaScript evaluated (awaited) in every matching page after reloads
and every 15 s, so it must be idempotent. Its result is logged when
it changes ("unchanged" never is). Build with `lib.mkPatch` to get
the finder library.
'';
};
unpatch = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
JavaScript that reverts the patch when the service stops; must be
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 state;
target = lib.filterAttrs (_: v: v != null) p.target;
};
configFile = pkgs.writeText "steam-ui-patches.json" (builtins.toJSON {
pollMs = 5000;
reinjectMs = 15000;
debounceMs = 3000;
unpatchTimeoutMs = 2000;
patches = map entry cfg.patches;
});
in {
imports = [ ./session.nix ];
options.steamFrame.uiPatches.lib = mkOption {
type = types.attrsOf types.raw;
readOnly = true;
default = import ./lib { inherit pkgs; };
defaultText = lib.literalMD "the helpers of `modules/lib`";
description = ''
Patch helpers from modules/lib/default.nix: `mkPatch { name, src,
signatures ? …, opts ? { } }` (calls `src` as
`(find, sigs, opts, hooks) => …`; see there), plus `finders`, `hooks`
(paths) and `signatures` (parsed signatures.json).
'';
};
options.steamFrame.uiPatches.patches = mkOption {
type = types.listOf patchType;
default = [ ];
example = lib.literalExpression ''
[ {
name = "my-patch";
target.title = "SharedJSContext";
patch = ./my-patch/patch.js;
unpatch = ./my-patch/unpatch.js;
} ]
'';
description = ''
Runtime patches of Steam's web UIs, kept injected by the
steam-ui-patches service and reverted when it stops. Every page
matching all given target criteria is patched.
'';
};
config = lib.mkMerge [
(lib.mkIf (cfg.patches != [ ]) {
assertions = [ {
assertion = lib.allUnique (map (p: p.name) cfg.patches);
message = "steamFrame.uiPatches.patches: names must be unique.";
} ];
systemd.user.services.steam-ui-patches = {
Unit.Description = "Runtime patches of Steam's UI (CEF DevTools)";
Service = {
ExecStart = lib.escapeShellArgs [
"${pkgs.nodejs}/bin/node"
"${./steam-ui-patches/injector.mjs}"
"${configFile}"
];
Restart = "always";
RestartSec = 5;
TimeoutStopSec = 5; # the injector unpatches Steam's UI on SIGTERM
};
Install.WantedBy = [ "default.target" ];
};
# Restart on switch: re-inject changed patches; the old instance reverts
# removed ones.
steamFrame.session.services.restart = [ "steam-ui-patches.service" ];
})
# No patches: stopping the injector reverts them right away.
(lib.mkIf (cfg.patches == [ ]) {
steamFrame.session.services.stop = [ "steam-ui-patches.service" ];
})
];
}