diff --git a/README.md b/README.md index e5e630f..50ddcc2 100644 --- a/README.md +++ b/README.md @@ -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.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.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.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. | @@ -138,7 +139,8 @@ running"): new or changed user units are neither reloaded nor started. **Fix:** the activation entry `steamFrameUserServices` exports the outer session's `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS` and runs `/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 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 (`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. -Steam's files are never modified; without the service, a restart of Steam -gives the stock keyboard. The service is restarted on every switch (via +Steam's files are never modified. Enabling and disabling take effect on +`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. - Bottom row becomes `Esc Ctrl Alt [Space] AltGr ← ↑ ↓ → Close`. diff --git a/modules/clipboard-sync.nix b/modules/clipboard-sync.nix index b16b9bd..17eb12f 100644 --- a/modules/clipboard-sync.nix +++ b/modules/clipboard-sync.nix @@ -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 ''; }; diff --git a/modules/session.nix b/modules/session.nix index 4007904..b1ac20d 100644 --- a/modules/session.nix +++ b/modules/session.nix @@ -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} ''); diff --git a/modules/vr-keyboard.nix b/modules/vr-keyboard.nix index c6def33..c88e557 100644 --- a/modules/vr-keyboard.nix +++ b/modules/vr-keyboard.nix @@ -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" ]; + }) + ]; } diff --git a/modules/vr-keyboard/vrkbd-helper.mjs b/modules/vr-keyboard/vrkbd-helper.mjs index 0d8af8f..144e2a8 100644 --- a/modules/vr-keyboard/vrkbd-helper.mjs +++ b/modules/vr-keyboard/vrkbd-helper.mjs @@ -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 [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 [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'); } diff --git a/modules/vr-keyboard/vrkbd-unpatch.js b/modules/vr-keyboard/vrkbd-unpatch.js new file mode 100644 index 0000000..ac8dc5d --- /dev/null +++ b/modules/vr-keyboard/vrkbd-unpatch.js @@ -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'; +})()