VR keyboard: Shift+Tab moves the focus backwards

Steam sends Tab as the text "\t" and drops Shift. With Shift, Tab now
goes to the xdotool helper (shift+Tab, ISO_Left_Tab on any keymap); the
suggestions text model resets on it like on Shift+arrows. The helper's
allowlist moves to allowlist.mjs with a test (flake check
vr-keyboard-extra-keys).
This commit is contained in:
Pierre Kisters committed 2026-09-30 22:53:24 +02:00
1 parent 50e5991c59
commit b2babb3859
11 files changed
+94 -43

No files matched your search

+1 -1
View File
@@ -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)):
+2
View File
@@ -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
+13 -4
View File
@@ -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
+5 -3
View File
@@ -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
+14 -5
View File
@@ -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"
@@ -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:<combo> 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:<char> one character Steam's key emulation can't produce: non-ASCII
// (äöü߀§°´…) or | @ { [ ] } \ ~ ^ `
// down:<mod> / up:<mod> 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);
}
+6
View File
@@ -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
''
+4 -3
View File
@@ -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('+'));
@@ -0,0 +1,17 @@
// allowlist.mjs: what the xdotool helper accepts from Steam's UI JS.
// usage: node allowlist.test.mjs <allowlist.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`);
@@ -8,6 +8,7 @@
// usage: node xdotool-helper.mjs <patch.js> <unpatch.js> [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:<combo> the extra keys (Esc, Del, Home, End, PgUp/PgDn, arrows),
// optionally with modifiers; or ctrl and/or alt (+ optional
// shift) with one key
// type:<char> one character Steam's key emulation can't produce: non-ASCII
// (äöü߀§°´…) or | @ { [ ] } \ ~ ^ `
// down:<mod> / up:<mod> 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));
+4 -4
View File
@@ -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(); }
}