Files
lhns--steam-frame-nix/modules/vr-keyboard/suggestions-panel/relay.mjs
T
Pierre Kisters aa8ccc67c9 VR keyboard F-keys: F1-F12 in the suggestion strip while AltGr is active
keyboard.vr.functionKeys.enable (default off): while AltGr (Fn on layouts
without AltGr) is one-shot, locked or held, the strip shows F1-F12 instead
of the suggestions. A tap types the extra key VKX_F<n>, which the extra
keys' patch presses with xdotool together with the active Ctrl/Alt/Shift
(Alt+F4, Ctrl+F5) and releases the toggles; the text model resets, like
for Esc. The suggestions are kept as they are meanwhile, so when AltGr goes
off the strip shows the same items and selection again.

- vr-keyboard/function-keys.js: what the strip shows, the F-key keys.
- patch.js (VERSION 27): the strip's view, F-key picks, AltGr changes via
  a componentDidUpdate on the keyboard instance (and the poll).
- Allowlist: F1-F12 with any modifiers; Enter still refused.
- relay.mjs: up to 12 strip items.
- Assertions: needs keyboard.vr.enable, extraKeys.enable and a strip
  position "above"/"below" ("inside" would hide the AltGr number row).
- Tests: function-keys.test.mjs (switching and restoring, keys against
  the allowlist, text model), allowlist F-key cases.
2026-10-01 03:53:01 +02:00

79 lines
4.0 KiB
JavaScript

// relay.mjs (user service vr-keyboard-relay): carries the suggestion strip
// between Steam's keyboard page (../patch.js in SharedJSContext, 8080) and
// SteamVR's systemui page (patch.js here, 8087) for suggestions.position
// "above" / "below". CDP bindings: the keyboard page calls
// __sfuiStripOut(state json) -> systemui __sfuiKbdStrip.show(state); the
// panel calls __sfuiStripPick({ seq, index } json) -> keyboard page
// __sfuiSwipe.remote.pick(seq, index). Only these two messages, validated
// (up to 12 items: the F-keys of keyboard.vr.functionKeys).
// On (re)connects and page reloads the keyboard page re-sends its state; if
// the keyboard page goes away, the panel is hidden.
const SIDES = {
steam: { url: 'http://127.0.0.1:8080/json/list', title: 'SharedJSContext', binding: '__sfuiStripOut' },
vr: { url: 'http://127.0.0.1:8087/json/list', title: 'systemui', binding: '__sfuiStripPick' },
};
const HIDDEN = { seq: 0, visible: false, current: -1, items: [] };
const live = {}; // side -> evaluate(expression)
let lastState = HIDDEN;
// Key style values: numbers, short CSS values that can't end a declaration.
function cleanStyle(st) {
if (!st || typeof st !== 'object') return null;
const out = {};
for (const [k, v] of Object.entries(st)) {
if (!/^[a-zA-Z]{1,20}$/.test(k)) continue;
if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
else if (typeof v === 'string' && v.length < 200 && !/[;{}<>\\]/.test(v)) out[k] = v;
else if (Array.isArray(v) && v.length <= 4 && v.every((x) => typeof x === 'number' && Number.isFinite(x))) out[k] = v;
}
return out;
}
const show = (state) => { lastState = state; live.vr?.(`window.__sfuiKbdStrip?.show(${JSON.stringify(state)})`); };
const resync = () => live.steam?.('window.__sfuiSwipe?.remote?.sync()');
function forward(side, payload) {
let msg;
try { msg = JSON.parse(payload); } catch { return; }
if (side === 'steam' && Array.isArray(msg?.items) && typeof msg.seq === 'number') {
show({ seq: msg.seq, visible: !!msg.visible, current: Number(msg.current) | 0,
items: msg.items.slice(0, 12).map((s) => String(s).slice(0, 64)), style: cleanStyle(msg.style),
haptic: Math.max(0, Math.min(5, Number(msg.haptic) | 0)), position: msg.position === 'below' ? 'below' : 'above' });
} else if (side === 'vr' && typeof msg?.seq === 'number' && typeof msg.index === 'number') {
live.steam?.(`window.__sfuiSwipe?.remote?.pick(${msg.seq | 0}, ${msg.index | 0})`);
}
}
async function connect(side) {
const cfg = SIDES[side];
const list = await (await fetch(cfg.url, { signal: AbortSignal.timeout(3000) })).json();
const t = list.find((x) => x.title === cfg.title && x.webSocketDebuggerUrl);
if (!t) throw new Error(`${cfg.title} not found`);
const ws = new WebSocket(t.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
let id = 0;
const send = (method, params = {}) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id: ++id, method, params })); };
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.method === 'Runtime.bindingCalled' && m.params.name === cfg.binding) forward(side, m.params.payload);
else if (m.method === 'Runtime.executionContextCreated') setTimeout(resync, 3500); // the injector re-patches after ~3 s
};
const closed = new Promise((res) => { ws.onclose = res; });
send('Runtime.enable');
send('Runtime.addBinding', { name: cfg.binding });
live[side] = (expression) => send('Runtime.evaluate', { expression });
console.log(`${side}: connected`);
if (side === 'vr') show(lastState);
resync();
await closed;
delete live[side];
if (side === 'steam') show(HIDDEN); // no keyboard page: no strip
console.log(`${side}: disconnected`);
}
for (const side of Object.keys(SIDES)) {
(async () => {
for (let failed = false; ; await new Promise((r) => setTimeout(r, 3000))) {
try { await connect(side); failed = false; } catch (e) { if (!failed) console.error(`${side}: ${e.message}`); failed = true; }
}
})();
}