mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +02:00
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.
79 lines
4.0 KiB
JavaScript
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; }
|
|
}
|
|
})();
|
|
}
|