clipboard-sync: dedupe on switch; vr-keyboard: revert without reboot

- clipboard-sync: stop stale builds and duplicates, prefer the instance
  started in the nested desktop, only start a new one when switching from
  the nested desktop (an instance inherits the switching shell's session)
- vr-keyboard: the helper reverts the patch on SIGTERM (vrkbd-unpatch.js);
  disabling the module stops the service via the new
  steamFrame.userServices.stop, so the stock keyboard is back immediately
This commit is contained in:
Pierre Kisters committed 2026-09-27 15:17:08 +02:00
1 parent 2dc9ccb83e
commit 4b2885a992
6 files changed
+113 -19

No files matched your search

+37 -8
View File
@@ -13,7 +13,7 @@ in {
options.steamFrame.clipboardSync = {
enable = lib.mkEnableOption ''
clipboard-sync between the Steam session and the nested desktop
(KDE autostart; restarted on switch when the build changed)
(KDE autostart; on switch, stale builds and duplicates are stopped)
'';
package = lib.mkOption {
type = lib.types.package;
@@ -42,15 +42,44 @@ in {
NoDisplay=true
'';
# (Re)start on switch if not running the current build.
# Run `home-manager switch` from a desktop terminal so it gets the desktop env.
# Keep exactly one instance of the current build, preferably one started in
# the nested desktop (XDG_CURRENT_DESKTOP=KDE): it inherits the process
# environment, so a copy started from a Steam-session terminal runs with
# the wrong session's env. Stale builds and duplicate instances are
# stopped (workers of an instance are left alone). A new
# instance is only started when switching from the nested desktop;
# otherwise the autostart entry starts it with the desktop.
home.activation.startClipboardSync = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
want="${cfg.package}/bin/clipboard-sync"
pid="$(${pkgs.procps}/bin/pgrep -x clipboard-sync | head -n1 || true)"
have="$( [ -n "$pid" ] && readlink "/proc/$pid/exe" || true )"
if [ "$have" != "$want" ]; then
${pkgs.procps}/bin/pkill -x clipboard-sync || true
run ${pkgs.util-linux}/bin/setsid -f "$want" >/dev/null 2>&1
desktop=() other=()
for pid in $(${pkgs.procps}/bin/pgrep -x clipboard-sync || true); do
# clipboard-sync forks workers; only look at top-level instances.
ppid="$(${pkgs.procps}/bin/ps -o ppid= -p "$pid" | tr -d ' ')"
[ "$(cat "/proc/$ppid/comm" 2>/dev/null)" = clipboard-sync ] && continue
if [ "$(readlink "/proc/$pid/exe" 2>/dev/null)" != "$want" ]; then
run kill "$pid" || true # stale build
elif tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | grep -qx 'XDG_CURRENT_DESKTOP=KDE'; then
desktop+=("$pid")
else
other+=("$pid")
fi
done
if [ ''${#desktop[@]} -gt 0 ]; then
keep=("''${desktop[0]}")
elif [ "''${XDG_CURRENT_DESKTOP:-}" = KDE ]; then
keep=()
else
keep=("''${other[@]:0:1}")
fi
for pid in "''${desktop[@]}" "''${other[@]}"; do
[[ " ''${keep[*]} " == *" $pid "* ]] || run kill "$pid" || true
done
if [ ''${#keep[@]} -eq 0 ]; then
if [ "''${XDG_CURRENT_DESKTOP:-}" = KDE ]; then
run ${pkgs.util-linux}/bin/setsid -f "$want" >/dev/null 2>&1
else
echo "clipboard-sync: not running; it starts with the nested desktop (autostart)"
fi
fi
'';
};
+12 -2
View File
@@ -12,8 +12,8 @@
# running"). The `steamFrameUserServices` activation entry does it instead,
# pointed at the outer session: it always runs `systemctl --user
# daemon-reload` (so changed unit files are picked up even when both lists are
# empty), then starts the units in `userServices.start` and restarts those in
# `userServices.restart`.
# empty), then starts the units in `userServices.start`, stops those in
# `userServices.stop` and restarts those in `userServices.restart`.
{ config, lib, ... }:
let
cfg = config.steamFrame;
@@ -58,6 +58,14 @@ in {
example = [ "vr-keyboard.service" ];
description = "User units restarted on every switch (outer user manager).";
};
stop = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
User units stopped on switch if still running, e.g. the service of a
feature that was just disabled (its unit file is already gone).
'';
};
};
};
@@ -66,6 +74,8 @@ in {
run /usr/bin/systemctl --user daemon-reload
'' + lib.optionalString (cfg.userServices.start != [ ]) ''
run /usr/bin/systemctl --user start ${units cfg.userServices.start}
'' + lib.optionalString (cfg.userServices.stop != [ ]) ''
run /usr/bin/systemctl --user stop ${units cfg.userServices.stop} || true
'' + lib.optionalString (cfg.userServices.restart != [ ]) ''
run /usr/bin/systemctl --user restart ${units cfg.userServices.restart}
'');
+11 -2
View File
@@ -24,7 +24,8 @@
Steam's UI through its CEF DevTools port, plus an xdotool helper service)
'';
config = lib.mkIf config.steamFrame.vrKeyboard.enable {
config = lib.mkMerge [
(lib.mkIf config.steamFrame.vrKeyboard.enable {
systemd.user.services.vr-keyboard = {
Unit.Description = "Modifier keys for Steam's VR keyboard (CEF patch + xdotool)";
Service = {
@@ -32,11 +33,13 @@
"${pkgs.nodejs}/bin/node"
"${./vr-keyboard/vrkbd-helper.mjs}"
"${./vr-keyboard/vrkbd-patch.js}"
"${./vr-keyboard/vrkbd-unpatch.js}"
"${pkgs.xdotool}/bin/xdotool"
];
Environment = "VRKBD_DISPLAY=:0";
Restart = "always";
RestartSec = 5;
TimeoutStopSec = 5; # the helper unpatches Steam's UI on SIGTERM
};
Install.WantedBy = [ "default.target" ];
};
@@ -44,5 +47,11 @@
# Restart on every switch so a changed patch is re-injected (it replaces
# the older version).
steamFrame.userServices.restart = [ "vr-keyboard.service" ];
};
})
# Disabled: stop a still-running helper, which reverts the patch, so the
# stock keyboard is back right away (no Steam restart or reboot).
(lib.mkIf (!config.steamFrame.vrKeyboard.enable) {
steamFrame.userServices.stop = [ "vr-keyboard.service" ];
})
];
}
+16 -4
View File
@@ -1,11 +1,15 @@
// vrkbd-helper: injects vrkbd-patch.js into Steam's UI via CEF DevTools and
// performs the key requests of the patched VR keyboard with xdotool on :0.
// usage: node vrkbd-helper.mjs <patch.js> [xdotool]
// On SIGTERM/SIGINT it reverts the patch (unpatch.js), so stopping the service
// restores Steam's stock keyboard without restarting Steam.
// usage: node vrkbd-helper.mjs <patch.js> <unpatch.js> [xdotool]
import { readFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
const [, , patchPath, xdotool = 'xdotool'] = process.argv;
const [, , patchPath, unpatchPath, xdotool = 'xdotool'] = process.argv;
const PATCH = readFileSync(patchPath, 'utf8');
const UNPATCH = readFileSync(unpatchPath, 'utf8');
let current = null; // CDP `call` of the live session, if any
const CDP = 'http://127.0.0.1:8080/json/list';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ENV = { ...process.env, DISPLAY: process.env.VRKBD_DISPLAY || ':0', LC_ALL: 'C.UTF-8' };
@@ -78,7 +82,8 @@ async function session() {
else if (m.method === 'Runtime.bindingCalled' && m.params.name === '__vrkbdKey') handle(m.params.payload);
else if (m.method === 'Runtime.executionContextCreated') setTimeout(inject, 3000);
};
const closed = new Promise((res) => { ws.onclose = res; });
const closed = new Promise((res) => { ws.onclose = () => { current = null; res(); }; });
current = call;
await call('Runtime.enable');
await call('Runtime.addBinding', { name: '__vrkbdKey' });
// Nothing is held by this helper instance; make the page re-send holds.
@@ -92,7 +97,14 @@ async function session() {
releaseAll();
}
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { releaseAll(); setTimeout(() => process.exit(0), 200); });
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, async () => {
releaseAll();
if (current) {
const r = await Promise.race([current('Runtime.evaluate', { expression: UNPATCH, returnByValue: true }), sleep(2000)]);
console.log('unpatch:', r?.result?.result?.value ?? 'timeout');
}
setTimeout(() => process.exit(0), 200);
});
for (;;) {
try { await session(); console.log('disconnected'); }
+29
View File
@@ -0,0 +1,29 @@
// Reverts vrkbd-patch.js in Steam's SharedJSContext; evaluated by vrkbd-helper
// when it stops (service stopped, module disabled). Every patched function
// keeps its original as __vrkbdOrig. Safe to run when nothing is patched.
(() => {
const wr = window.__vrkbdWr;
if (!wr) return 'not patched';
const unwrap = (obj, name) => {
const f = obj?.[name];
if (f && f.__vrkbdOrig) obj[name] = f.__vrkbdOrig;
};
const Layouts = wr(40222);
for (const l of [...(Layouts.G$() || []), Layouts.r_()]) unwrap(l, 'rgLayout');
unwrap(SteamClient.Input, 'ControllerKeyboardSendText');
unwrap(wr(5363).PE.prototype, 'DispatchKeypress');
clearInterval(window.__vrkbdHoldTimer);
const inst = window.__vrkbdInst;
if (inst) {
let proto = Object.getPrototypeOf(inst);
while (proto && !Object.prototype.hasOwnProperty.call(proto, 'TypeKeyInternal')) proto = Object.getPrototypeOf(proto);
unwrap(proto, 'TypeKeyInternal');
delete inst.__vrkbd;
inst.setState({ standardLayout: Layouts.r_() });
inst.forceUpdate();
}
for (const p of g_PopupManager.GetPopups?.() || []) p.window?.document.getElementById('vrkbd-style')?.remove();
for (const k of ['__vrkbdHoldTimer', '__vrkbdHeld', '__vrkbdInst', '__vrkbdPatched']) delete window[k];
return 'unpatched';
})()