diff --git a/modules/steam-keyboard-patch.nix b/modules/steam-keyboard-patch.nix index 2b65e24..22cc643 100644 --- a/modules/steam-keyboard-patch.nix +++ b/modules/steam-keyboard-patch.nix @@ -1,20 +1,16 @@ -# Esc/Ctrl/Alt and four arrow keys on Steam's VR keyboard. -# The keyboard is part of Steam's UI (hardcoded layouts in steamui JS), and in -# VR it can only send text; SteamClient.Input.ControllerKeyboardSetKeyState -# throws "Unknown method". So: -# - patch.js is injected at runtime into Steam's UI through its CEF -# DevTools port (127.0.0.1:8080; SteamOS starts Steam with -# -cef-enable-debugging). Steam's files are untouched, so updates don't undo -# it. It rebuilds the bottom row and, while Ctrl/Alt is active (or for Esc), -# hands the key combo to the helper instead of typing text. It also holds -# the real Ctrl/Alt down while the toggle is on (Ctrl+scroll), and routes -# characters Steam's key emulation turns into "1" (non-ASCII, AltGr/dead -# keys on the de keymap: |@{[]}\~^`äöü€…) to the helper. -# - helper.mjs keeps that injection alive (Steam restarts, keyboard popup -# recreated) and presses the combos with `xdotool key` on :0, where gamescope -# keeps X focus on the window selected in VR. It only accepts ctrl/alt -# chords and the extra keys, so the Steam UI can't use it to type text or -# press Enter. +# Runtime patch of Steam's on-screen (VR) keyboard. Steam's layouts are +# hardcoded in its UI, and in VR it can only send text (no Ctrl/Alt/Esc; +# SteamClient.Input.ControllerKeyboardSetKeyState throws "Unknown method"). +# - patch.js is injected into Steam's running UI through its CEF DevTools port +# (127.0.0.1:8080; SteamOS starts Steam with -cef-enable-debugging); Steam's +# files are untouched. Bottom row: Esc Ctrl Alt [space] AltGr ← ↑ ↓ →, with +# AltGr + arrows = Pos1/PgUp/PgDn/End. Chords, Shift+arrows and characters +# Steam's key emulation turns into "1" (non-ASCII, AltGr/dead keys on the +# de keymap) are handed to the helper; Ctrl/Alt are held while toggled. +# - helper.mjs keeps the injection alive (Steam restarts, popup recreated), +# performs those requests with xdotool on :0 (X focus follows the window +# selected in VR) and reverts the patch when stopped (unpatch.js). Its +# allowlist can't type ASCII text or press Enter. # Depends on Steam UI internals; tested with Steam client 1790377368. { config, pkgs, lib, ... }: { imports = [ ./session.nix ]; @@ -39,7 +35,6 @@ "${./steam-keyboard-patch/unpatch.js}" "${pkgs.xdotool}/bin/xdotool" ]; - Environment = "VRKBD_DISPLAY=:0"; Restart = "always"; RestartSec = 5; TimeoutStopSec = 5; # the helper unpatches Steam's UI on SIGTERM diff --git a/modules/steam-keyboard-patch/helper.mjs b/modules/steam-keyboard-patch/helper.mjs index c2cc034..006964e 100644 --- a/modules/steam-keyboard-patch/helper.mjs +++ b/modules/steam-keyboard-patch/helper.mjs @@ -12,6 +12,7 @@ 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)); +// :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 @@ -76,14 +77,25 @@ async function session() { const v = r.result?.result?.value ?? r.result?.exceptionDetails?.exception?.description; if (v !== 'patched' || process.env.VRKBD_VERBOSE) console.log('inject:', v); }; + // New JS contexts (UI reload, popups) come in bursts: inject once after them. + let soon = null; + const injectSoon = () => { clearTimeout(soon); soon = setTimeout(() => inject().catch(() => {}), 3000); }; await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } 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') injectSoon(); }; - const closed = new Promise((res) => { ws.onclose = () => { current = null; res(); }; }); + const closed = new Promise((res) => { + ws.onclose = () => { + current = null; + clearTimeout(soon); + for (const r of pending.values()) r({}); // unblock calls that will never be answered + pending.clear(); + res(); + }; + }); current = call; await call('Runtime.enable'); await call('Runtime.addBinding', { name: '__vrkbdKey' }); diff --git a/modules/steam-keyboard-patch/patch.js b/modules/steam-keyboard-patch/patch.js index 7e3251a..59754a0 100644 --- a/modules/steam-keyboard-patch/patch.js +++ b/modules/steam-keyboard-patch/patch.js @@ -1,140 +1,138 @@ // Injected into Steam's SharedJSContext (CEF, 127.0.0.1:8080) by helper.mjs. // Extends Steam's VR keyboard for gamescope app windows: -// - bottom row: Esc Ctrl Alt [space] AltGr and four separate arrow keys -// - Ctrl/Alt chords and Esc are pressed for real (Steam can't: in VR -// SteamClient.Input.ControllerKeyboardSetKeyState throws "Unknown method") +// - bottom row: Esc Ctrl Alt [space] AltGr ← ↑ ↓ → Close, stable with Shift +// and AltGr; AltGr + arrows = Pos1/PgUp/PgDn/End +// - Ctrl/Alt chords, Esc, the AltGr arrow keys and Shift+arrows are pressed +// for real (Steam can't: in VR SteamClient.Input.ControllerKeyboardSetKeyState +// throws "Unknown method") // - while Ctrl/Alt is active and the keyboard is open, the real modifier is // held down, so e.g. Ctrl+scroll works // - characters Steam's own key emulation (ControllerKeyboardSendText) turns // into "1" -- anything non-ASCII or needing AltGr / a dead key on the X // keymap -- are typed by the helper instead // Everything goes through the CDP binding window.__vrkbdKey(":"), -// executed by helper.mjs with xdotool on :0. +// executed by helper.mjs with xdotool on :0. Every replaced function keeps its +// original as __vrkbdOrig (unpatch.js restores them). // Idempotent: safe to evaluate repeatedly. (() => { - const VERSION = 7; + const VERSION = 8; const send = (msg) => window.__vrkbdKey && window.__vrkbdKey(msg); const wr = window.__vrkbdWr || (webpackChunksteamui.push([[Symbol('vrkbd')], {}, (r) => { window.__vrkbdWr = r; }]), window.__vrkbdWr); const Layouts = wr(40222); // keyboard layouts (exports G$: enabled, r_: current) - const VKM = wr(5363).PE; // VirtualKeyboardManager class const Status = wr(58508).qL; // .VRKeyboardStatus + let changed = false; + + // Replace obj[name] with make(original), once per VERSION. + const wrap = (obj, name, make) => { + if (obj[name].__vrkbd === VERSION) return; + const orig = obj[name].__vrkbdOrig || obj[name]; + const f = make(orig); + f.__vrkbd = VERSION; f.__vrkbdOrig = orig; + obj[name] = f; + changed = true; + }; // ---- bottom row ------------------------------------------------------------ // The keyboard window has a fixed height, so no extra row: Esc/Ctrl/Alt go // left of the space bar, and Steam's arrow keys become four separate keys // (stock layouts pair them as [Left, Up-when-shifted], [Right, Down-when-shifted]). - // Key type "Half" for all added keys; the stylesheet below sets their width. // Every entry is a [normal, shifted, altgr] triple: Steam's stock bottom row // lacks explicit variants, so with Shift the close icon jumps and with AltGr - // keys turn into empty full-width panels. With AltGr the arrows become - // Pos1/Ende/PgUp/PgDn (Shift+arrows stay arrows, for selecting). - const HALF = 2; + // keys turn into empty full-width panels. null as the shifted variant means + // "same key, no shift label". + const HALF = 2; // key type "Half"; widths set by the stylesheet const k = (key, label) => ({ key, label, type: HALF }); - // null as the shifted variant = "same key, no shift label". Steam still draws - // the AltGr variant as a small secondary label; the stylesheet hides that in - // the bottom row (the arrows keep theirs as a Pos1/Ende/Bild hint). const same = (x) => [x, null, x]; - const MODS = [k('VKX_Escape', 'Esc'), k('Control', 'Ctrl'), k('Alt', 'Alt')].map(same); - const half = (a) => ({ ...a, type: HALF }); - const ARROWS = [ // Steam's ArrowLeft/Up/Down/Right keys - [Layouts.Md, k('VKX_Home', 'Pos1')], - [Layouts.GO, k('VKX_Prior', 'Bild↑')], - [Layouts.xl, k('VKX_Next', 'Bild↓')], - [Layouts.B6, k('VKX_End', 'Ende')], - ].map(([a, alt]) => [half(a), null, alt]); + const MODS = [k('VKX_Escape', 'Esc'), k('Control', 'Ctrl'), k('Alt', 'Alt')]; + const ALTGR_ARROWS = [k('VKX_Home', 'Pos1'), k('VKX_Prior', 'Bild↑'), k('VKX_Next', 'Bild↓'), k('VKX_End', 'Ende')]; + const ARROWS = [Layouts.Md, Layouts.GO, Layouts.xl, Layouts.B6] // Steam's ArrowLeft/Up/Down/Right keys + .map((a, i) => [{ ...a, type: HALF }, null, ALTGR_ARROWS[i]]); const first = (x) => (Array.isArray(x) ? x.find((y) => y) : x); - const isArrow = (x) => (Array.isArray(x) ? x : [x]).some((y) => y && typeof y.key === 'string' && y.key.startsWith('Arrow')); + const isArrow = (x) => (Array.isArray(x) ? x : [x]).some((y) => y?.key?.startsWith?.('Arrow')); const bottomRow = (row) => { const out = []; for (const x of row) { const key = first(x); if (!key || isArrow(x)) continue; - if (key.key === ' ') out.push(...MODS); + if (key.key === ' ') out.push(...MODS.map(same)); out.push(same(key)); } out.splice(out.length - 1, 0, ...ARROWS); // before Close/Done (last entry) return out; }; - let changed = false; - const patchLayout = (l) => { - if (!l || typeof l.rgLayout !== 'function' || l.rgLayout.__vrkbd === VERSION) return; - changed = true; - const orig = l.rgLayout.__vrkbdOrig || l.rgLayout; - const f = (opts) => { const rows = orig(opts); return [...rows.slice(0, -1), bottomRow(rows[rows.length - 1])]; }; - f.__vrkbd = VERSION; f.__vrkbdOrig = orig; - l.rgLayout = f; - }; - for (const l of Layouts.G$() || []) patchLayout(l); - patchLayout(Layouts.r_()); + window.__vrkbdLayouts ??= new Set(); // for unpatch.js, incl. layouts disabled since + for (const l of [...(Layouts.G$() || []), Layouts.r_()]) { + if (typeof l?.rgLayout !== 'function') continue; + window.__vrkbdLayouts.add(l); + wrap(l, 'rgLayout', (orig) => (opts) => { + const rows = orig(opts); + return [...rows.slice(0, -1), bottomRow(rows[rows.length - 1])]; + }); + } // ---- text: characters Steam's key emulation can't produce -------------------- // All keyboard text for gamescope windows ends up in // SteamClient.Input.ControllerKeyboardSendText (via several paths, incl. the // VR text override), which only maps plain ASCII on the base/shift levels; - // everything else comes out as "1". Hook that one call and hand those - // characters to the helper, in order; the rest passes through unchanged. - if (VKM.prototype.DispatchKeypress.__vrkbdOrig) { // undo the v4 hook - VKM.prototype.DispatchKeypress = VKM.prototype.DispatchKeypress.__vrkbdOrig; - } + // everything else comes out as "1". Hand those characters to the helper, in + // order; the rest passes through unchanged. (helper.mjs checks the same set.) const viaHelper = (c) => c.codePointAt(0) > 127 || '|@{[]}\\~^`'.includes(c); - const Input = SteamClient.Input; - if (Input.ControllerKeyboardSendText.__vrkbd !== VERSION) { - const orig = Input.ControllerKeyboardSendText.__vrkbdOrig || Input.ControllerKeyboardSendText; - const patched = function (text, ...rest) { - if (typeof text !== 'string') return orig.call(this, text, ...rest); - let run = ''; - for (const c of text) { - if (viaHelper(c)) { - if (run) { orig.call(this, run, ...rest); run = ''; } - send('type:' + c); - } else run += c; - } - if (run) return orig.call(this, run, ...rest); - }; - patched.__vrkbd = VERSION; patched.__vrkbdOrig = orig; - Input.ControllerKeyboardSendText = patched; - } + wrap(SteamClient.Input, 'ControllerKeyboardSendText', (orig) => function (text, ...rest) { + if (typeof text !== 'string') return orig.call(this, text, ...rest); + let run = ''; + for (const c of text) { + if (viaHelper(c)) { + if (run) { orig.call(this, run, ...rest); run = ''; } + send('type:' + c); + } else run += c; + } + if (run) return orig.call(this, run, ...rest); + }); - // ---- key handling ---------------------------------------------------------- + // ---- keyboard component ------------------------------------------------------- const kbPopup = [...(g_PopupManager.GetPopups?.() || [])] .find((p) => p.window?.document.querySelector('[data-key]')); if (!kbPopup) return 'no keyboard popup yet'; - // Widen the added half keys and slim AltGr; the space bar is the only - // flexible key, so it adjusts. Re-applied on every inject (the popup can be recreated). const doc = kbPopup.window.document; + const el = doc.querySelector('[data-key]'); + let fiber = el[Object.keys(el).find((x) => x.startsWith('__reactFiber'))]; + while (fiber && !fiber.stateNode?.TypeKeyInternal) fiber = fiber.return; + const inst = fiber?.stateNode; + if (!inst) return 'no keyboard component'; + let proto = Object.getPrototypeOf(inst); + while (!Object.prototype.hasOwnProperty.call(proto, 'TypeKeyInternal')) proto = Object.getPrototypeOf(proto); + window.__vrkbdInst = inst; // for the hold timer and unpatch.js + window.__vrkbdProto = proto; + + // Stylesheet, re-applied on every inject (the popup can be recreated). The + // space bar is the only flexible key, so it absorbs the width changes. + const sel = (keys, suffix = '') => keys.map((key) => `[data-key="${key}"]${suffix}`).join(','); + const lastRow = Math.max(...[...doc.querySelectorAll('[data-key-row]')].map((e) => +e.getAttribute('data-key-row'))); + const added = [...MODS, ...ARROWS.map(first), ...ALTGR_ARROWS].map((x) => x.key); let style = doc.getElementById('vrkbd-style'); if (!style) { style = doc.createElement('style'); style.id = 'vrkbd-style'; doc.head.appendChild(style); } - style.textContent = ['VKX_Escape', 'Control', 'Alt', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'ArrowRight', - 'VKX_Home', 'VKX_Prior', 'VKX_Next', 'VKX_End'] - .map((key) => `[data-key="${key}"]`).join(',') + '{ width: 43px !important; }' - + '[data-key="AltGr"]{ width: 52px !important; }' - // Secondary (AltGr) labels in the bottom row, except the arrows' hints. - + `[data-key-row="${Math.max(...[...doc.querySelectorAll('[data-key-row]')].map((e) => +e.getAttribute('data-key-row')))}"]` - + ':not([data-key^="Arrow"]) span ~ span { display: none !important; }' - // Arrow keys: smaller icon, raised, and a smaller Pos1/Ende/Bild hint at - // the bottom edge, so the two don't overlap on the half-width keys. - + '[data-key^="Arrow"] span ~ span { font-size: 7px !important; top: auto !important; bottom: 3px !important; line-height: 1 !important; }' - + '[data-key^="Arrow"] span:first-child svg { height: 18px !important; transform: translateY(-4px); }' - // AltGr variants of the arrows: labels slightly smaller than the 16px default. - + ['VKX_Home', 'VKX_Prior', 'VKX_Next', 'VKX_End'].map((key) => `[data-key="${key}"] span`).join(',') - + ' { font-size: 13px !important; }'; - const el = doc.querySelector('[data-key]'); - let f = el[Object.keys(el).find((x) => x.startsWith('__reactFiber'))]; - while (f && !(f.stateNode && f.stateNode.TypeKeyInternal)) f = f.return; - const inst = f?.stateNode; - if (!inst) return 'no keyboard component'; - window.__vrkbdInst = inst; - let proto = Object.getPrototypeOf(inst); - while (proto && !Object.prototype.hasOwnProperty.call(proto, 'TypeKeyInternal')) proto = Object.getPrototypeOf(proto); + style.textContent = [ + `${sel(added)} { width: 43px !important; }`, + '[data-key="AltGr"] { width: 52px !important; }', + // Steam draws the AltGr variant as a small secondary label: hide it in the + // bottom row, except on the arrows, where it's the Pos1/Ende/Bild hint. + `[data-key-row="${lastRow}"]:not([data-key^="Arrow"]) span ~ span { display: none !important; }`, + // Arrow keys: smaller (still centered) icon and a small hint at the bottom edge. + '[data-key^="Arrow"] span ~ span { font-size: 7px !important; top: auto !important; bottom: 3px !important; line-height: 1 !important; }', + '[data-key^="Arrow"] span:first-child svg { height: 18px !important; }', + `${sel(ALTGR_ARROWS.map((x) => x.key), ' span')} { font-size: 13px !important; }`, + ].join('\n'); + // ---- key handling ---------------------------------------------------------- const active = (v) => (v & 7) !== 0; // toggle state bits: 1 one-shot, 2 locked const release = (v) => { const z = v & 3; return (z === 1 ? 0 : z) | (v & 4); }; + const TOGGLES = ['Shift', 'CapsLock', 'Control', 'Alt', 'AltGr']; const SYMS = { ' ': 'space', Backspace: 'BackSpace', Enter: 'Return', Tab: 'Tab', '.': 'period', ',': 'comma', '-': 'minus', '+': 'plus', '#': 'numbersign', '<': 'less', '/': 'slash', 'ß': 'ssharp', 'ü': 'udiaeresis', 'ö': 'odiaeresis', 'ä': 'adiaeresis', - ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', ArrowDown: 'Down', // only with Ctrl/Alt; plain arrows stay Steam's + ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', ArrowDown: 'Down', }; const keysym = (key) => { if (key.startsWith('VKX_')) return key.slice(4); @@ -142,32 +140,19 @@ if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase(); return null; }; - - if (proto.TypeKeyInternal.__vrkbd !== VERSION) { - const orig = proto.TypeKeyInternal.__vrkbdOrig || proto.TypeKeyInternal; - const patched = function (st) { - 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')); - const isToggle = key && ['Shift', 'CapsLock', 'Control', 'Alt', 'AltGr'].includes(key); - if (key && !isToggle && (special || ctrl || alt)) { - const sym = keysym(key); - if (sym) { - send('key:' + [ctrl && 'ctrl', alt && 'alt', shift && 'shift', sym].filter(Boolean).join('+')); - this.setState((s) => ({ ...s, toggleStates: { ...s.toggleStates, - Shift: release(s.toggleStates.Shift), Control: release(s.toggleStates.Control), - Alt: release(s.toggleStates.Alt), AltGr: release(s.toggleStates.AltGr) } })); - return; - } - } - return orig.call(this, st); - }; - patched.__vrkbd = VERSION; patched.__vrkbdOrig = orig; - proto.TypeKeyInternal = patched; - changed = true; - } + wrap(proto, 'TypeKeyInternal', (orig) => function (st) { + 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')); + 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('+')); + this.setState((s) => ({ ...s, toggleStates: { ...s.toggleStates, + Shift: release(s.toggleStates.Shift), Control: release(s.toggleStates.Control), + Alt: release(s.toggleStates.Alt), AltGr: release(s.toggleStates.AltGr) } })); + }); // ---- held modifiers --------------------------------------------------------- // Keep the real Ctrl/Alt pressed while the toggle is active and the keyboard is @@ -175,8 +160,7 @@ if (!window.__vrkbdHoldTimer) { window.__vrkbdHeld = { ctrl: false, alt: false }; window.__vrkbdHoldTimer = setInterval(() => { - const i = window.__vrkbdInst; - const ts = i?.state?.toggleStates || {}; + const ts = window.__vrkbdInst?.state?.toggleStates || {}; const open = !!Status.VRKeyboardStatus?.bIsOpen; for (const [mod, state] of [['ctrl', ts.Control], ['alt', ts.Alt]]) { const want = open && active(state); @@ -194,6 +178,5 @@ inst.setState({ standardLayout: Layouts.r_() }); inst.forceUpdate(); } - window.__vrkbdPatched = VERSION; return 'patched'; })() diff --git a/modules/steam-keyboard-patch/unpatch.js b/modules/steam-keyboard-patch/unpatch.js index 3872c48..985425f 100644 --- a/modules/steam-keyboard-patch/unpatch.js +++ b/modules/steam-keyboard-patch/unpatch.js @@ -2,28 +2,23 @@ // 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'; + if (!window.__vrkbdWr) return 'not patched'; const unwrap = (obj, name) => { const f = obj?.[name]; - if (f && f.__vrkbdOrig) obj[name] = f.__vrkbdOrig; + if (f?.__vrkbdOrig) obj[name] = f.__vrkbdOrig; }; - const Layouts = wr(40222); - for (const l of [...(Layouts.G$() || []), Layouts.r_()]) unwrap(l, 'rgLayout'); + for (const l of window.__vrkbdLayouts || []) unwrap(l, 'rgLayout'); unwrap(SteamClient.Input, 'ControllerKeyboardSendText'); - unwrap(wr(5363).PE.prototype, 'DispatchKeypress'); - + unwrap(window.__vrkbdProto, 'TypeKeyInternal'); 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.setState({ standardLayout: window.__vrkbdWr(40222).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]; + for (const k of ['__vrkbdHoldTimer', '__vrkbdHeld', '__vrkbdInst', '__vrkbdProto', '__vrkbdLayouts']) delete window[k]; return 'unpatched'; })()