mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 03:00:13 +02:00
Opt-in with steamFrame.keyboard.vr.enable; swipe, autocorrect and completion suggestions, Backspace drag and haptics are on under it. - vr-keyboard/patch.js (Steam UI): swipe decoder, a model of what the keyboard typed, suggestions that only replace what they typed, Backspace drag with word detents and retyping; checks its Steam internals by signature (signatures.json: vr-keyboard) and stays stock otherwise. - vr-keyboard/panel.js (SteamVR systemui) + relay.mjs (user service vr-keyboard-relay): the suggestion strip as a dashboard panel below or above the keyboard (signatures: vr-keyboard-panel). - Dictionary built from nixpkgs' wordfreq and hunspellDicts; default: the keyboard.layout language (de, fr, es, it, nl, pt, sv) plus English. - Tests: keyboard.vr.checks and the flake check vr-keyboard.
77 lines
3.9 KiB
JavaScript
77 lines
3.9 KiB
JavaScript
// relay.mjs: carries the suggestion strip between Steam's keyboard page
|
|
// (SharedJSContext, 8080) and SteamVR's systemui page (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.
|
|
// 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, 8).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; }
|
|
}
|
|
})();
|
|
}
|