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

+8 -3
View File
@@ -115,6 +115,7 @@ menu list every desktop entry, including terminals such as Konsole.
| `steamFrame.outerBusEnv` | str, read-only | `"env DBUS_SESSION_BUS_ADDRESS=${userBus}"` | Prefix for launchers that must use the outer bus. | | `steamFrame.outerBusEnv` | str, read-only | `"env DBUS_SESSION_BUS_ADDRESS=${userBus}"` | Prefix for launchers that must use the outer bus. |
| `steamFrame.userServices.start` | list of str | `[ ]` | User units started on switch if not running (outer user manager). | | `steamFrame.userServices.start` | list of str | `[ ]` | User units started on switch if not running (outer user manager). |
| `steamFrame.userServices.restart` | list of str | `[ ]` | User units restarted on every switch (outer user manager). | | `steamFrame.userServices.restart` | list of str | `[ ]` | User units restarted on every switch (outer user manager). |
| `steamFrame.userServices.stop` | list of str | `[ ]` | User units stopped on switch if still running, e.g. of a feature just disabled. |
| `steamFrame.portalFix.enable` | bool | `true` | Working portal config for the Steam session (OpenURI). | | `steamFrame.portalFix.enable` | bool | `true` | Working portal config for the Steam session (OpenURI). |
| `steamFrame.keyboardLayout` | null or str | `null` | XKB layout for the Steam session, e.g. `"de"`. `null` = no drop-in (US). | | `steamFrame.keyboardLayout` | null or str | `null` | XKB layout for the Steam session, e.g. `"de"`. `null` = no drop-in (US). |
| `steamFrame.keyboardVariant` | null or str | `null` | XKB variant for the Steam session. | | `steamFrame.keyboardVariant` | null or str | `null` | XKB variant for the Steam session. |
@@ -138,7 +139,8 @@ running"): new or changed user units are neither reloaded nor started.
**Fix:** the activation entry `steamFrameUserServices` exports the outer **Fix:** the activation entry `steamFrameUserServices` exports the outer
session's `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS` and runs session's `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS` and runs
`/usr/bin/systemctl --user daemon-reload`, then `start` for `/usr/bin/systemctl --user daemon-reload`, then `start` for
`userServices.start` and `restart` for `userServices.restart`. The entry `userServices.start`, `stop` for `userServices.stop` and `restart` for
`userServices.restart`. The entry
always runs (also with both lists empty), so unit files you define yourself always runs (also with both lists empty), so unit files you define yourself
are at least reloaded. are at least reloaded.
@@ -185,8 +187,11 @@ keymap (`| @ { [ ] } \ ~ ^`, backtick, `ä ö ü €`) come out as `1`.
`vrkbd-patch.js` into Steam's UI at runtime through Steam's CEF DevTools port `vrkbd-patch.js` into Steam's UI at runtime through Steam's CEF DevTools port
(`127.0.0.1:8080`; SteamOS starts Steam with `-cef-enable-debugging`) and (`127.0.0.1:8080`; SteamOS starts Steam with `-cef-enable-debugging`) and
re-injects it after Steam restarts or the keyboard popup is recreated. re-injects it after Steam restarts or the keyboard popup is recreated.
Steam's files are never modified; without the service, a restart of Steam Steam's files are never modified. Enabling and disabling take effect on
gives the stock keyboard. The service is restarted on every switch (via `home-manager switch`, no reboot or Steam restart needed: when the helper
stops (service stopped, or the option disabled, which stops it via
`userServices.stop`) it reverts the patch in Steam's running UI
(`vrkbd-unpatch.js`). The service is restarted on every switch (via
`userServices.restart`) so a changed patch is re-injected. `userServices.restart`) so a changed patch is re-injected.
- Bottom row becomes `Esc Ctrl Alt [Space] AltGr ← ↑ ↓ → Close`. - Bottom row becomes `Esc Ctrl Alt [Space] AltGr ← ↑ ↓ → Close`.
+37 -8
View File
@@ -13,7 +13,7 @@ in {
options.steamFrame.clipboardSync = { options.steamFrame.clipboardSync = {
enable = lib.mkEnableOption '' enable = lib.mkEnableOption ''
clipboard-sync between the Steam session and the nested desktop 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 { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
@@ -42,15 +42,44 @@ in {
NoDisplay=true NoDisplay=true
''; '';
# (Re)start on switch if not running the current build. # Keep exactly one instance of the current build, preferably one started in
# Run `home-manager switch` from a desktop terminal so it gets the desktop env. # 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" ] '' home.activation.startClipboardSync = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
want="${cfg.package}/bin/clipboard-sync" want="${cfg.package}/bin/clipboard-sync"
pid="$(${pkgs.procps}/bin/pgrep -x clipboard-sync | head -n1 || true)" desktop=() other=()
have="$( [ -n "$pid" ] && readlink "/proc/$pid/exe" || true )" for pid in $(${pkgs.procps}/bin/pgrep -x clipboard-sync || true); do
if [ "$have" != "$want" ]; then # clipboard-sync forks workers; only look at top-level instances.
${pkgs.procps}/bin/pkill -x clipboard-sync || true ppid="$(${pkgs.procps}/bin/ps -o ppid= -p "$pid" | tr -d ' ')"
run ${pkgs.util-linux}/bin/setsid -f "$want" >/dev/null 2>&1 [ "$(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 fi
''; '';
}; };
+12 -2
View File
@@ -12,8 +12,8 @@
# running"). The `steamFrameUserServices` activation entry does it instead, # running"). The `steamFrameUserServices` activation entry does it instead,
# pointed at the outer session: it always runs `systemctl --user # pointed at the outer session: it always runs `systemctl --user
# daemon-reload` (so changed unit files are picked up even when both lists are # 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 # empty), then starts the units in `userServices.start`, stops those in
# `userServices.restart`. # `userServices.stop` and restarts those in `userServices.restart`.
{ config, lib, ... }: { config, lib, ... }:
let let
cfg = config.steamFrame; cfg = config.steamFrame;
@@ -58,6 +58,14 @@ in {
example = [ "vr-keyboard.service" ]; example = [ "vr-keyboard.service" ];
description = "User units restarted on every switch (outer user manager)."; 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 run /usr/bin/systemctl --user daemon-reload
'' + lib.optionalString (cfg.userServices.start != [ ]) '' '' + lib.optionalString (cfg.userServices.start != [ ]) ''
run /usr/bin/systemctl --user start ${units 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 != [ ]) '' '' + lib.optionalString (cfg.userServices.restart != [ ]) ''
run /usr/bin/systemctl --user restart ${units 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) 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 = { systemd.user.services.vr-keyboard = {
Unit.Description = "Modifier keys for Steam's VR keyboard (CEF patch + xdotool)"; Unit.Description = "Modifier keys for Steam's VR keyboard (CEF patch + xdotool)";
Service = { Service = {
@@ -32,11 +33,13 @@
"${pkgs.nodejs}/bin/node" "${pkgs.nodejs}/bin/node"
"${./vr-keyboard/vrkbd-helper.mjs}" "${./vr-keyboard/vrkbd-helper.mjs}"
"${./vr-keyboard/vrkbd-patch.js}" "${./vr-keyboard/vrkbd-patch.js}"
"${./vr-keyboard/vrkbd-unpatch.js}"
"${pkgs.xdotool}/bin/xdotool" "${pkgs.xdotool}/bin/xdotool"
]; ];
Environment = "VRKBD_DISPLAY=:0"; Environment = "VRKBD_DISPLAY=:0";
Restart = "always"; Restart = "always";
RestartSec = 5; RestartSec = 5;
TimeoutStopSec = 5; # the helper unpatches Steam's UI on SIGTERM
}; };
Install.WantedBy = [ "default.target" ]; Install.WantedBy = [ "default.target" ];
}; };
@@ -44,5 +47,11 @@
# Restart on every switch so a changed patch is re-injected (it replaces # Restart on every switch so a changed patch is re-injected (it replaces
# the older version). # the older version).
steamFrame.userServices.restart = [ "vr-keyboard.service" ]; 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 // 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. // 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 { readFileSync } from 'node:fs';
import { execFile } from 'node:child_process'; 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 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 CDP = 'http://127.0.0.1:8080/json/list';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ENV = { ...process.env, DISPLAY: process.env.VRKBD_DISPLAY || ':0', LC_ALL: 'C.UTF-8' }; 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.bindingCalled' && m.params.name === '__vrkbdKey') handle(m.params.payload);
else if (m.method === 'Runtime.executionContextCreated') setTimeout(inject, 3000); 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.enable');
await call('Runtime.addBinding', { name: '__vrkbdKey' }); await call('Runtime.addBinding', { name: '__vrkbdKey' });
// Nothing is held by this helper instance; make the page re-send holds. // Nothing is held by this helper instance; make the page re-send holds.
@@ -92,7 +97,14 @@ async function session() {
releaseAll(); 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 (;;) { for (;;) {
try { await session(); console.log('disconnected'); } 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';
})()