Files
Pierre Kisters 5d15902ba1 Clearer file names and layout; docs/development.md
- steam-keyboard-patch.nix -> vr-keyboard-extra-keys.nix (option
  keyboard.vr.extraKeys), helper.mjs -> xdotool-helper.mjs;
  homeManagerModules.steam-keyboard-patch stays as an alias.
- vr-keyboard: panel.js/unpatch-panel.js/relay.mjs ->
  suggestions-panel/{patch,unpatch}.js + relay.mjs; decoder.js ->
  swipe-decoder.js; build.nix split into dictionary.nix and check.nix.
- modules/lib -> modules/steam-ui-patches/lib (the UI patch library).
- docs/development.md: repository layout (what runs where), runtime names.

Patch names, user services and state files are unchanged.
2026-09-29 01:42:34 +02:00

161 lines
5.6 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"), kept when the patch goes and removed by
# steam-frame-nix-cleanup --all. Patch calling convention (mkPatch) and the
# finder library: steam-ui-patches/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): `window.__sfuiStore.get(name)` / `.set(name, value)` in the
page, kept in $XDG_STATE_HOME/steam-frame-nix/ui-patches/<name>.json
across SteamVR restarts, reboots and disabling the patch; removed
by `steam-frame-nix-cleanup --all`.
'';
};
};
};
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 ./cleanup.nix ];
options.steamFrame.uiPatches.lib = mkOption {
type = types.attrsOf types.raw;
readOnly = true;
default = import ./steam-ui-patches/lib { inherit pkgs; };
defaultText = lib.literalMD "the helpers of `modules/steam-ui-patches/lib`";
description = ''
Patch helpers from modules/steam-ui-patches/lib/default.nix:
`mkPatch { name, src, signatures ? …, opts ? { }, extraArgs ? [ ] }`
(calls `src` as `(find, sigs, opts, hooks, ...extraArgs) => …`; 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" ];
})
];
}