Files
lhns--steam-frame-nix/modules/pet/cli.mjs
T
Pierre Kisters 4034fd4ded VR pet: a cat or dog in SteamVR's scene (steamFrame.pet)
A SteamVR dashboard patch (vr-pet) drawing a baked 3D pet next to the
windows: the Toon Cat in five coats, a Shiba Inu, a Fox and a Dachshund,
plus models from steamFrame.pet.extraModels. It walks around, follows,
sits, lies and sleeps, can be picked up by its grip bar, petted and
switched in its menu; its spot, pose and model are saved state.

Also the vr-pet command, "Pet" in the "+" menu with the current model's
icon (steam-frame-nix-pet-icon), the flake outputs pet-models,
pet-icons and pet-preview, and the check pet. A built-in model with
"private": true fails evaluation; private models belong in extraModels.
2026-10-01 04:33:39 +02:00

129 lines
6.8 KiB
JavaScript

// vr-pet show [--summon] | hide | status | models | model <id>
//
// Shows / hides the VR pet (systemui.js) from outside SteamVR, e.g. the "+"
// menu's Pet entry (vr-pet.desktop runs `vr-pet show`). Talks to the
// systemui page over CEF DevTools (127.0.0.1:8087: /json/list, the target
// titled "systemui", one Runtime.evaluate) and calls only the patch's own API,
// window.__sfuiPet.api (its version checked); nothing else of the page is
// touched.
//
// show the cat back at its spot while that is within 3 m and in view,
// else 1 m in front of you (a shown cat: the same rule)
// --summon always 1 m in front of you
// hide the cat leaves the scene (as its close button)
// status JSON: hidden, pose, spot, distance, model, ...
// models the models, the cat's coats and the other animals (* the
// current one): id and name
// model <id> switch to that model (kept across restarts; a hidden pet
// switches too and is shown as that)
//
// Exit: 0 done, 2 SteamVR / DevTools unreachable (or timeout, 3 s), 3 the cat
// is not injected or has another API version, 4 error, usage or an unknown
// model.
export const API_VERSION = 2;
export const ENDPOINT = 'http://127.0.0.1:8087';
export const TITLE = 'systemui';
export const EXIT = { ok: 0, unreachable: 2, notInjected: 3, error: 4 };
const USAGE = 'usage: vr-pet show [--summon] | hide | status | models | model <id>';
// CLI command -> API method
const METHOD = { show: 'show', hide: 'hide', status: 'status', models: 'models', model: 'setModel' };
// Thrown by a transport: SteamVR / DevTools not reachable (exit 2).
export class Unreachable extends Error {}
// The expression evaluated in the page: only window.__sfuiPet.api.
export function expression(method, args = {}) {
return `(async () => {
const api = window.__sfuiPet?.api;
if (!api) return { status: 'not-injected' };
if (api.version !== ${API_VERSION}) return { status: 'version', version: api.version };
return { status: 'ok', value: await api[${JSON.stringify(method)}](${JSON.stringify(args)}) };
})()`;
}
// Default transport: fetch + the WebSocket of node >= 22.
export const cdp = {
async list(endpoint, signal) {
let r;
try { r = await fetch(`${endpoint}/json/list`, { signal }); } catch (e) { throw new Unreachable(`${endpoint}: ${e.cause?.code ?? e.message}`); }
if (!r.ok) throw new Unreachable(`${endpoint}/json/list: HTTP ${r.status}`);
return r.json();
},
evaluate(target, expr, signal) {
return new Promise((resolve, reject) => {
let ws;
try { ws = new WebSocket(target.webSocketDebuggerUrl); } catch (e) { reject(new Unreachable(e.message)); return; }
let opened = false;
const done = (f, v) => { try { ws.close(); } catch { /* closed */ } f(v); };
signal?.addEventListener('abort', () => done(reject, new Unreachable('timeout')));
ws.onopen = () => {
opened = true;
ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression: expr, awaitPromise: true, returnByValue: true } }));
};
ws.onmessage = (m) => {
let msg;
try { msg = JSON.parse(m.data); } catch { return; }
if (msg.id === 1) done(resolve, msg);
};
ws.onerror = () => done(reject, opened ? new Error('DevTools connection error') : new Unreachable('DevTools websocket refused'));
ws.onclose = () => reject(new Unreachable('DevTools websocket closed'));
});
},
};
// argv (without node and script) -> { code, out: [lines], err: [lines] }
export async function run(argv, { transport = cdp, endpoint = ENDPOINT, timeoutMs = 3000 } = {}) {
const out = [], err = [];
const [cmd, ...rest] = argv;
const flags = new Set(rest);
const known = { show: ['--summon'], hide: [], status: [], models: [] };
const idOk = cmd === 'model' && rest.length === 1 && /^[A-Za-z0-9_-]+$/.test(rest[0]);
if (!idOk && (!(cmd in known) || rest.some((f) => !known[cmd].includes(f)))) { err.push(USAGE); return { code: EXIT.error, out, err }; }
const args = cmd === 'show' ? { summon: flags.has('--summon') } : cmd === 'model' ? { id: rest[0] } : {};
const ac = new AbortController();
let timer;
const timeout = new Promise((_, no) => { timer = setTimeout(() => { ac.abort(); no(new Unreachable(`no answer within ${timeoutMs / 1000} s`)); }, timeoutMs); });
try {
const reply = await Promise.race([(async () => {
const targets = await transport.list(endpoint, ac.signal);
const target = Array.isArray(targets) && targets.find((t) => t.title === TITLE && t.webSocketDebuggerUrl);
if (!target) throw new Unreachable(`no "${TITLE}" page at ${endpoint} (SteamVR not running?)`);
return transport.evaluate(target, expression(METHOD[cmd], args), ac.signal);
})(), timeout]);
const ex = reply?.result?.exceptionDetails;
if (reply?.error || ex) {
err.push(`vr-pet: ${reply.error?.message ?? ex.exception?.description ?? ex.text ?? 'exception'}`);
return { code: EXIT.error, out, err };
}
const v = reply?.result?.result?.value;
if (v?.status === 'not-injected') { err.push('vr-pet: the cat is not in SteamVR (patch not injected yet?)'); return { code: EXIT.notInjected, out, err }; }
if (v?.status === 'version') { err.push(`vr-pet: the cat's API is version ${v.version}, this is ${API_VERSION}`); return { code: EXIT.notInjected, out, err }; }
if (v?.status !== 'ok') { err.push(`vr-pet: unexpected reply ${JSON.stringify(reply?.result?.result ?? reply)}`); return { code: EXIT.error, out, err }; }
const r = v.value ?? {};
if (r.error) { err.push(`vr-pet: ${r.error}${r.models ? ` (models: ${r.models.join(', ')})` : ''}`); return { code: EXIT.error, out, err }; }
if (cmd === 'status') out.push(JSON.stringify(r));
else if (cmd === 'models') {
const w = Math.max(0, ...(r.models ?? []).map((m) => m.id.length)); // names in one column
for (const m of r.models ?? []) out.push(`${m.id === r.current ? '*' : ' '} ${m.id.padEnd(w)} ${m.name}`);
}
else if (cmd === 'model') out.push(r.was === r.model ? `vr-pet: already ${r.name}` : `vr-pet: now ${r.name}${r.hidden ? ' (hidden; shown as that)' : ''}`);
else if (cmd === 'hide') out.push(r.was === 'hidden' ? 'vr-pet: already hidden' : 'vr-pet: hidden');
else out.push(`vr-pet: ${r.was === 'hidden' ? 'shown' : 'already shown'}${r.summoned ? ' (summoned in front of you)' : ''}`);
return { code: EXIT.ok, out, err };
} catch (e) {
if (e instanceof Unreachable) { err.push(`vr-pet: ${e.message}`); return { code: EXIT.unreachable, out, err }; }
err.push(`vr-pet: ${e.message ?? e}`);
return { code: EXIT.error, out, err };
} finally {
clearTimeout(timer);
}
}
// node cli.mjs <args>
if (import.meta.url === `file://${process.argv[1]}`) {
const r = await run(process.argv.slice(2));
for (const l of r.out) console.log(l);
for (const l of r.err) console.error(l);
process.exit(r.code);
}