diff --git a/README.md b/README.md index f6b9ea9..f904a72 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ configuration, limitations and how it works. **VR keyboard** ([docs/keyboard.md](docs/keyboard.md)): -- [Extra keys](docs/keyboard.md#extra-keys) (`keyboard.vr.extraKeys`): Esc/Ctrl/Alt, arrows, Delete, real chords and AltGr/non-ASCII characters. +- [Extra keys](docs/keyboard.md#extra-keys) (`keyboard.vr.extraKeys`): Esc/Ctrl/Alt, arrows, Delete, Shift+Tab, real chords and AltGr/non-ASCII characters. - [Swipe and suggestions](docs/keyboard.md#swipe-and-suggestions) (`keyboard.vr`): swipe typing, corrections, completions, Backspace drag. **VR "+" menu** ([docs/launcher-menu.md](docs/launcher-menu.md)): diff --git a/docs/development.md b/docs/development.md index 0b540c9..2768b01 100644 --- a/docs/development.md +++ b/docs/development.md @@ -91,6 +91,8 @@ modules/ vr-keyboard-extra-keys/ patch.js, unpatch.js Steam: the extra bottom row and key routing xdotool-helper.mjs service steam-keyboard-patch: own injector + xdotool keys + allowlist.mjs service: the keys the helper may send + check.nix, tests/ test: the allowlist vr-keyboard.nix swipe, suggestions, Backspace drag (keyboard.vr) vr-keyboard/ patch.js, unpatch.js Steam: gestures, text model, suggestion strip diff --git a/docs/keyboard.md b/docs/keyboard.md index 62f8ab8..79dd793 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -25,6 +25,10 @@ backtick, `ä ö ü €`) come out as `1`. AltGr and layout keys. - AltGr + arrows: Home, End, Page Up, Page Down (hinted on the keys); Shift + arrows select text. +- Shift + Tab moves the focus backwards (stock Steam drops the Shift); + Ctrl + Shift + Tab works too (e.g. previous Firefox tab). A locked Shift + is released after it, like a tapped one. Shift + Enter and + Shift + Backspace stay plain Enter and Backspace. - AltGr + the key left of Backspace (`´` on German, `=` on US): Delete, labelled like Steam's Delete key in its language (`Entf`; `Del` if that is longer), hinted on the key without AltGr; repeats while held. Layouts with @@ -46,8 +50,10 @@ after Steam restarts. Turning it off reverts the keyboard. is harmless, and Esc/Ctrl/Alt/arrows work regardless. **Security:** the helper service that presses the keys only accepts -single-key Ctrl/Alt chords, the extra keys, Ctrl/Alt hold/release and single -non-ASCII/AltGr characters; it cannot type ASCII text or press Enter. +single-key Ctrl/Alt chords, the extra keys, Shift + Tab, Ctrl/Alt +hold/release and single non-ASCII/AltGr characters; it cannot type ASCII +text or press Enter (`allowlist.mjs`, tested by the flake check +`vr-keyboard-extra-keys`). **Caveat:** depends on Steam UI internals; after a Steam update that changes them the keyboard stays stock and the journal says why (see @@ -63,8 +69,11 @@ Steam client 1790377368 (UI build 11041156). Steam's `SharedJSContext` over DevTools (`127.0.0.1:8080`) and re-injects it after Steam restarts; stopping it (or disabling the option) runs the unpatch. -- Ctrl/Alt chords and Esc are sent with `xdotool key` on `:0` (focus follows - the VR-selected window); a toggled Ctrl/Alt is held down with `xdotool`. +- Ctrl/Alt chords, Esc, Shift + arrows and Shift + Tab are sent with + `xdotool key` on `:0` (focus follows the VR-selected window); a toggled + Ctrl/Alt is held down with `xdotool`. Shift is never held as a real key + (Steam types capitals itself); `shift+Tab` is `ISO_Left_Tab` on any + keymap. - Problem characters (non-ASCII, AltGr/dead-key characters on the German keymap) are typed with `xdotool type`; everything else goes through Steam's own text emulation. On other keymaps those characters are still diff --git a/flake.nix b/flake.nix index 5f889fb..54126f8 100644 --- a/flake.nix +++ b/flake.nix @@ -65,9 +65,10 @@ }) self.packages; # Tests of the VR keyboard (text model, corrector, swipe decoder on the - # default German + English dictionary), the Jellyfin mpv shim, the - # Firefox wrapper, the launchers, install.sh cleanup, the - # applications.menu link and the portal config: nix flake check + # default German + English dictionary), the extra keys' xdotool + # allowlist, the Jellyfin mpv shim, the Firefox wrapper, the launchers, + # install.sh cleanup, the applications.menu link and the portal config: + # nix flake check checks = forSystems (pkgs: { applications-menu = import ./modules/applications-menu/check.nix { inherit pkgs; }; cleanup = import ./modules/cleanup/check.nix { inherit pkgs; }; @@ -76,6 +77,7 @@ launchers = import ./modules/launchers/check.nix { inherit pkgs; }; portal = import ./modules/portal/check.nix { inherit pkgs; }; vr-keyboard = import ./modules/vr-keyboard/check.nix { inherit pkgs; }; + vr-keyboard-extra-keys = import ./modules/vr-keyboard-extra-keys/check.nix { inherit pkgs; }; }); # nix flake init -t github:lhns/steam-frame-nix diff --git a/modules/vr-keyboard-extra-keys.nix b/modules/vr-keyboard-extra-keys.nix index aa0b45c..cffb2fa 100644 --- a/modules/vr-keyboard-extra-keys.nix +++ b/modules/vr-keyboard-extra-keys.nix @@ -4,12 +4,13 @@ # "Unknown method" in VR), and its layouts are hardcoded. # - patch.js (injected over CEF DevTools, 127.0.0.1:8080) adds a bottom row # Esc Ctrl Alt [space] AltGr ← ↑ ↓ → (AltGr+arrows = Pos1/PgUp/PgDn/End) and -# hands chords, Shift+arrows and characters Steam would turn into "1" -# (non-ASCII, AltGr/dead keys) to the helper. AltGr + the key left of -# Backspace = Delete. +# hands chords, Shift+arrows, Shift+Tab and characters Steam would turn +# into "1" (non-ASCII, AltGr/dead keys) to the helper. AltGr + the key left +# of Backspace = Delete. # - xdotool-helper.mjs keeps it injected (its own injector, not the # steam-ui-patches service), sends those keys with xdotool on :0 (an -# allowlist: no ASCII text, no Enter) and unpatches on stop. +# allowlist, allowlist.mjs: no ASCII text, no Enter) and unpatches on stop. +# Tests: vr-keyboard-extra-keys/check.nix (flake check vr-keyboard-extra-keys). # Tested with Steam client 1790377368 (UI build 11041156). { config, pkgs, lib, ... }: let @@ -17,6 +18,14 @@ let name = "steam-keyboard-patch"; src = ./vr-keyboard-extra-keys/patch.js; }; + # The helper imports allowlist.mjs from its own directory. + helper = lib.fileset.toSource { + root = ./vr-keyboard-extra-keys; + fileset = lib.fileset.unions [ + ./vr-keyboard-extra-keys/xdotool-helper.mjs + ./vr-keyboard-extra-keys/allowlist.mjs + ]; + }; cfg = config.steamFrame.keyboard.vr.extraKeys; in { imports = [ @@ -38,7 +47,7 @@ in { Service = { ExecStart = lib.escapeShellArgs [ "${pkgs.nodejs}/bin/node" - "${./vr-keyboard-extra-keys/xdotool-helper.mjs}" + "${helper}/xdotool-helper.mjs" "${patch}" "${./vr-keyboard-extra-keys/unpatch.js}" "${pkgs.xdotool}/bin/xdotool" diff --git a/modules/vr-keyboard-extra-keys/allowlist.mjs b/modules/vr-keyboard-extra-keys/allowlist.mjs new file mode 100644 index 0000000..3a0a87e --- /dev/null +++ b/modules/vr-keyboard-extra-keys/allowlist.mjs @@ -0,0 +1,27 @@ +// Allowlist of xdotool-helper.mjs: requests come from Steam's UI JS, so the +// helper must not be able to type ASCII text or press Enter on its behalf. +// Accepted: +// key: the extra keys (Esc, Del, Home, End, PgUp/PgDn, arrows), +// optionally with modifiers; ctrl and/or alt (+ optional +// shift) with one key; or shift+Tab +// type: one character Steam's key emulation can't produce: non-ASCII +// (äöü߀§°´…) or | @ { [ ] } \ ~ ^ ` +// down: / up: hold/release ctrl or alt (checked in the helper) +const SPECIAL = new Set(['Escape', 'Delete', 'Home', 'End', 'Prior', 'Next', 'Left', 'Right', 'Up', 'Down']); +const KEY = /^([a-z0-9]|space|BackSpace|Tab|period|comma|minus|plus|numbersign|less|slash|ssharp|udiaeresis|odiaeresis|adiaeresis)$/; + +export function allowedCombo(combo) { + const parts = combo.split('+'); + const key = parts.pop(); + const mods = new Set(parts); + if (parts.length !== mods.size || ![...mods].every((m) => ['ctrl', 'alt', 'shift'].includes(m))) return false; + if (SPECIAL.has(key)) return true; + if (key === 'Tab' && mods.size === 1 && mods.has('shift')) return true; // backwards focus + return KEY.test(key) && (mods.has('ctrl') || mods.has('alt')); +} + +export function allowedChar(c) { + if ([...c].length !== 1) return false; + const cp = c.codePointAt(0); + return (cp > 0xa0 && !/\s|\p{C}/u.test(c)) || '|@{[]}\\~^`'.includes(c); +} diff --git a/modules/vr-keyboard-extra-keys/check.nix b/modules/vr-keyboard-extra-keys/check.nix new file mode 100644 index 0000000..20ad0e9 --- /dev/null +++ b/modules/vr-keyboard-extra-keys/check.nix @@ -0,0 +1,6 @@ +# Tests of the extra keys (flake check `vr-keyboard-extra-keys`): the xdotool +# helper's allowlist (tests/allowlist.test.mjs). +{ pkgs }: +pkgs.runCommand "vr-keyboard-extra-keys-checks" { nativeBuildInputs = [ pkgs.nodejs ]; } '' + node ${./tests/allowlist.test.mjs} ${./allowlist.mjs} | tee $out +'' diff --git a/modules/vr-keyboard-extra-keys/patch.js b/modules/vr-keyboard-extra-keys/patch.js index 542c85a..1398a5c 100644 --- a/modules/vr-keyboard-extra-keys/patch.js +++ b/modules/vr-keyboard-extra-keys/patch.js @@ -23,7 +23,7 @@ // mkPatch patch (see steam-ui-patches/lib/default.nix); no options. On a // signature mismatch it returns an error and changes nothing. Idempotent. ((find, sigs) => { - const VERSION = 22; + const VERSION = 23; const send = (msg) => window.__vrkbdKey && window.__vrkbdKey(msg); const mods = find.resolvePatch('webpackChunksteamui', sigs); if (typeof mods === 'string') return mods; @@ -252,8 +252,9 @@ const key = st?.strKey; const ts = this.state?.toggleStates || {}; const ctrl = active(ts.Control), alt = active(ts.Alt), shift = active(ts.Shift); - // Extra keys always; Shift+arrow too (Steam's own arrow path drops Shift). - const special = key?.startsWith('VKX_') || (shift && key?.startsWith('Arrow')); + // Extra keys always; Shift+arrow and Shift+Tab too (Steam sends arrows + // and Tab without Shift: Tab as the text "\t"). + const special = key?.startsWith('VKX_') || (shift && (key?.startsWith('Arrow') || key === 'Tab')); const sym = key && !TOGGLES.includes(key) && (special || ctrl || alt) && keysym(key); if (!sym) return orig.call(this, st); send('key:' + [ctrl && 'ctrl', alt && 'alt', shift && 'shift', sym].filter(Boolean).join('+')); diff --git a/modules/vr-keyboard-extra-keys/tests/allowlist.test.mjs b/modules/vr-keyboard-extra-keys/tests/allowlist.test.mjs new file mode 100644 index 0000000..891261c --- /dev/null +++ b/modules/vr-keyboard-extra-keys/tests/allowlist.test.mjs @@ -0,0 +1,17 @@ +// allowlist.mjs: what the xdotool helper accepts from Steam's UI JS. +// usage: node allowlist.test.mjs +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; + +const { allowedCombo, allowedChar } = await import(pathToFileURL(process.argv[2]).href); +const accept = ['shift+Tab', 'ctrl+Tab', 'ctrl+shift+Tab', 'ctrl+shift+t', 'alt+f', + 'shift+Left', 'Escape', 'Delete', 'shift+End', 'ctrl+BackSpace']; +const reject = ['Tab', 'shift+a', 'a', 'Return', 'shift+Return', 'ctrl+Return', 'shift+BackSpace', + 'shift+shift+Tab', 'super+Tab', 'shift+space', 'ctrl+a+b']; +const acceptChar = ['ä', '€', '|', '@', '\\', '`']; +const rejectChar = ['a', 'A', '1', ' ', '\n', '\t', 'ab', ' ']; +for (const c of accept) assert.equal(allowedCombo(c), true, `accepts key:${c}`); +for (const c of reject) assert.equal(allowedCombo(c), false, `rejects key:${c}`); +for (const c of acceptChar) assert.equal(allowedChar(c), true, `accepts type:${c}`); +for (const c of rejectChar) assert.equal(allowedChar(c), false, `rejects type:${JSON.stringify(c)}`); +console.log(`allowlist: ${accept.length + reject.length + acceptChar.length + rejectChar.length} cases passed`); diff --git a/modules/vr-keyboard-extra-keys/xdotool-helper.mjs b/modules/vr-keyboard-extra-keys/xdotool-helper.mjs index 2fd5413..4be6142 100644 --- a/modules/vr-keyboard-extra-keys/xdotool-helper.mjs +++ b/modules/vr-keyboard-extra-keys/xdotool-helper.mjs @@ -8,6 +8,7 @@ // usage: node xdotool-helper.mjs [xdotool] import { readFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; +import { allowedCombo, allowedChar } from './allowlist.mjs'; const [, , patchPath, unpatchPath, xdotool = 'xdotool'] = process.argv; const PATCH = readFileSync(patchPath, 'utf8'); @@ -19,30 +20,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // :0 is the Steam session's Xwayland, where the VR app windows live. const ENV = { ...process.env, DISPLAY: process.env.VRKBD_DISPLAY || ':0', LC_ALL: 'C.UTF-8' }; -// Allowlist: requests come from Steam's UI JS, so the helper must not be able -// to type ASCII text or press Enter on its behalf. Accepted: -// key: the extra keys (Esc, Del, Home, End, PgUp/PgDn, arrows), -// optionally with modifiers; or ctrl and/or alt (+ optional -// shift) with one key -// type: one character Steam's key emulation can't produce: non-ASCII -// (äöü߀§°´…) or | @ { [ ] } \ ~ ^ ` -// down: / up: hold/release ctrl or alt -const SPECIAL = new Set(['Escape', 'Delete', 'Home', 'End', 'Prior', 'Next', 'Left', 'Right', 'Up', 'Down']); -const KEY = /^([a-z0-9]|space|BackSpace|Tab|period|comma|minus|plus|numbersign|less|slash|ssharp|udiaeresis|odiaeresis|adiaeresis)$/; const MODKEY = { ctrl: 'Control_L', alt: 'Alt_L' }; -function allowedCombo(combo) { - const parts = combo.split('+'); - const key = parts.pop(); - const mods = new Set(parts); - if (parts.length !== mods.size || ![...mods].every((m) => ['ctrl', 'alt', 'shift'].includes(m))) return false; - if (SPECIAL.has(key)) return true; - return KEY.test(key) && (mods.has('ctrl') || mods.has('alt')); -} -function allowedChar(c) { - if ([...c].length !== 1) return false; - const cp = c.codePointAt(0); - return (cp > 0xa0 && !/\s|\p{C}/u.test(c)) || '|@{[]}\\~^`'.includes(c); -} const held = new Set(); // modifiers currently held down via keydown const xdo = (args) => execFile(xdotool, args, { env: ENV }, (err) => err && console.error('xdotool', args.join(' '), err.message)); diff --git a/modules/vr-keyboard/patch.js b/modules/vr-keyboard/patch.js index be19a6b..4663385 100644 --- a/modules/vr-keyboard/patch.js +++ b/modules/vr-keyboard/patch.js @@ -28,7 +28,7 @@ // Steam's events. Debugging: __sfuiSwipeLog, __sfuiSwipePaths // (scripts/vr-keyboard-replay.mjs). ((find, sigs, opts, hooks, D, T, C, DICT) => { - const VERSION = 23; + const VERSION = 24; const G = window; const O = opts; @@ -186,9 +186,9 @@ const key = st?.strKey, ts = this.state?.toggleStates || {}; if (typeof key === 'string' && !TOGGLES.has(key) && !/^(SwitchKeys_|IME_)/.test(key)) { // Extra keys typed by keyboard.vr.extraKeys with xdotool (chords, Esc, - // arrows, AltGr Delete: text after the cursor) and dead keys: the - // model can't follow them. - if (on(ts.Control) || on(ts.Alt) || key.startsWith('VKX_') || st.strDeadKeyNext || (on(ts.Shift) && key.startsWith('Arrow'))) { + // arrows, Shift+Tab, AltGr Delete: text after the cursor) and dead + // keys: the model can't follow them. + if (on(ts.Control) || on(ts.Alt) || key.startsWith('VKX_') || st.strDeadKeyNext || (on(ts.Shift) && (key.startsWith('Arrow') || key === 'Tab'))) { reset(`key ${key}`); } else { model.freeze(); refreshLater(); } }