Files
Pierre Kisters c16fe05309 Passthrough toggle: room view button cycles Off -> colour -> mono
steamFrame.dashboard.passthroughToggle: the SteamVR dashboard's room view
button cycles Off -> colour (Arcturus Vision camera) -> monochrome IR -> Off,
with a purple eye in colour mode. A Steam UI patch on the dock bar windows,
a CDP relay and a small Rust helper that switches the camera source through
SteamVR's IVRCameraPassthroughInternal_001 (vrclient.so from the SteamVR
install, nothing vendored). Cleanup artifact for SteamVR's client log.

Adapted from toorux/steam-frame-passthrough-toggle and
KominoVR/frame-passthrough-shortcuts (MIT), see README Credits.
2026-10-15 05:05:12 +02:00

253 lines
11 KiB
JavaScript

// passthrough-toggle: turns the VR dashboard's stock room-view button into a
// three-way cycle Off -> colour -> mono -> Off (colour needs an Arcturus RGB
// accessory; otherwise it quietly stays mono -- adapted from upstream
// toorux/steam-frame-passthrough-toggle, see the README's Credits). mkPatch
// patch (steam-ui-patches/lib/default.nix) on SharedJSContext; opts:
// { invocation, icon, iconActive }, the room-view action's shape, so a
// future Steam renumbering is a module option, not a code change.
//
// The button is found like launcher-menu's patches: the dock's own bar
// popups (g_PopupManager, "valve.steam.gamepadui.bar.<n>_...", never the
// "+" menu's "...gamepadui.barpopup") share SharedJSContext's realm; the
// action is identified by its invocation/icon/icon_active shape (opts), not
// its id (changes across SteamVR restarts), via find.findFiberUp on the
// button's React fiber. Each bar popup is its own window though (only the
// JS realm is shared), so the capture-phase click listener and icon
// MutationObserver go on every bar popup's own window/document, attached by
// scan() as popups appear -- a click there never reaches SharedJSContext's.
//
// Every press is swallowed (capture phase) and queued; one job at a time
// decides from live state, never a cached guess: off -> ask the relay
// (window.__sfuiPassthroughCall, relay.mjs) to set rgb (the source byte is
// held while off, so it comes up in colour), replay the stock click, wait
// for `active`; on+rgb -> set mono; on+mono -> replay the stock click
// (off). Queued presses wait their turn rather than being dropped, and
// status refreshes share the same queue, so no reply can overtake the
// decision it's for.
// The three states show from confirmed state only (the action's own
// `active` plus the helper's last read-back source): off/mono are the
// stock icons, colour is the active icon tinted purple (#a855f7), via a
// data-sfui-passthrough attribute plus a per-window stylesheet (so a
// re-rendered SVG doesn't undo it). A MutationObserver per bar window, a
// 50 ms poll while a press waits for SteamVR, and the 1 s scan all re-set
// the attribute, so the icon follows within a tick of each change.
// State on window.__sfuiPassthrough (id, version, mode, reply() for
// relay.mjs, scan(), stop()); unpatch.js calls stop().
((find, sigs, opts) => {
const NAME = 'passthrough-toggle';
const VERSION = 6;
const STYLE_ID = 'sfui-passthrough-style';
const ATTR = 'data-sfui-passthrough';
const CSS = `
[${ATTR}="rgb"] svg { color: #a855f7 !important; }
[${ATTR}="rgb"] svg :not([fill="none"]) { fill: #a855f7 !important; }`;
const RELAY_TIMEOUT_MS = 9000; // a bit over the helper's own 8 s
const SETTLE_TIMEOUT_MS = 3000; // how long to wait for SteamVR to flip `active`
const POLL_MS = 50;
const VERIFY_DELAY_MS = 1000; // re-read the source after turning colour on
const MAX_QUEUED = 3; // presses beyond this while busy are dropped
const SIG = { invocation: opts.invocation ?? 2, icon: opts.icon ?? 2, iconActive: opts.iconActive ?? 1 };
const ID = `${VERSION}:${SIG.invocation}:${SIG.icon}:${SIG.iconActive}`;
const prev = window.__sfuiPassthrough;
if (prev?.id === ID) { prev.scan(); return 'unchanged'; }
prev?.stop?.();
if (!window.g_PopupManager) return 'g_PopupManager missing';
const guard = (f) => (...a) => { try { return f(...a); } catch (e) { console.error(`sfui ${NAME}:`, e); } };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const isRoomViewAction = (action) => action?.invocation === SIG.invocation &&
action?.icon?.enum === SIG.icon && action?.icon_active?.enum === SIG.iconActive;
// A DOM node's __reactFiber$ may be the stale alternate of a pair, whose
// memoizedProps lag one render (`active` seen live to catch up only on
// the next unrelated re-render). Prefer whichever of a fiber/alternate
// pair is in the committed tree (its .return chain's FiberRoot.current).
const committed = (f) => {
let top = f;
for (let i = 0; top.return && i < 200; i++) top = top.return;
return top.stateNode?.current === top;
};
const current = (f) => (f.alternate && !committed(f) && committed(f.alternate) ? f.alternate : f);
const actionFor = (el) => {
const f = find.findFiberUp(el, (x) => isRoomViewAction(x.memoizedProps?.action), 40);
return f ? current(f).memoizedProps?.action ?? null : null;
};
// The dock's own bar popups, not the "+" menu's "...gamepadui.barpopup".
const isBarPopup = (name) => typeof name === 'string' && /^valve\.steam\.gamepadui\.bar\.\d+_/.test(name) && !name.includes('barpopup');
function buttons() {
const out = [];
for (const p of window.g_PopupManager.GetPopups()) {
if (!isBarPopup(p.m_strName)) continue;
let doc;
try { doc = p.window?.document; } catch { continue; }
if (!doc) continue;
for (const el of doc.querySelectorAll('[role="button"]')) if (actionFor(el)) { out.push(el); break; }
}
return out;
}
const button = () => buttons()[0] ?? null;
const isActive = () => !!actionFor(button())?.active;
// ---- relay calls (window.__sfuiPassthroughCall, installed by relay.mjs
// as a CDP binding once it is connected) ----
let nextId = 1;
const pending = new Map();
function call(op, source) {
if (typeof window.__sfuiPassthroughCall !== 'function') return Promise.resolve({ ok: false, error: 'relay not connected' });
return new Promise((resolve) => {
const id = nextId++;
const timer = setTimeout(() => { if (pending.delete(id)) resolve({ ok: false, error: 'relay timed out' }); }, RELAY_TIMEOUT_MS);
pending.set(id, (result) => { clearTimeout(timer); resolve(result); });
window.__sfuiPassthroughCall(JSON.stringify(source ? { id, op, source } : { id, op }));
});
}
function reply(id, result) {
const cb = pending.get(id);
if (!cb) return;
pending.delete(id);
cb(result);
}
// ---- confirmed state & icon ----
// The helper's last read-back source ('rgb' | 'mono'); only meaningful
// while room view is active, which is always read live.
let source = 'mono';
const mode = () => (isActive() ? source : 'off');
async function helper(op, src) {
const result = await call(op, src);
if (result?.ok) source = result.source === 'rgb' ? 'rgb' : 'mono';
decorateAll();
return result;
}
// What the icon shows: always mode(), i.e. confirmed state only.
let shown = 'off';
function decorate(target) {
if (target && target.getAttribute(ATTR) !== shown) target.setAttribute(ATTR, shown);
}
const decorateAll = () => {
const next = mode();
shown = next;
for (const b of buttons()) decorate(b);
};
// ---- the job queue: presses and status refreshes, one at a time ----
let stopped = false;
let tail = Promise.resolve();
let queued = 0;
const enqueue = (job) => { tail = tail.then(guard(job), guard(job)).then(() => {}, () => {}); return tail; };
async function waitActive(want) {
for (let waited = 0; isActive() !== want && waited < SETTLE_TIMEOUT_MS; waited += POLL_MS) await sleep(POLL_MS);
decorateAll();
return isActive() === want;
}
let replaying = false;
function stockClick(target) {
if (stopped) return;
replaying = true;
try { target.click(); } finally { replaying = false; }
}
async function press() {
queued--;
if (stopped) return;
const target = button();
if (!target) return;
try { await handle(target); } finally { decorateAll(); }
}
async function handle(target) {
if (!isActive()) {
await helper('set', 'rgb');
stockClick(target);
await waitActive(true);
// Re-read once a moment later, so the icon follows if SteamVR's
// camera renderer resets the source while it finishes opening.
setTimeout(() => { if (!stopped) enqueue(() => helper('status')); }, VERIFY_DELAY_MS);
return;
}
const status = await helper('status');
if (status?.ok && status.source === 'rgb') {
await helper('set', 'mono');
return;
}
stockClick(target);
await waitActive(false);
}
function onClick(event) {
if (replaying) return; // our own replay of the stock click
const target = button();
if (!target || !target.contains(event.target)) return;
event.preventDefault();
event.stopImmediatePropagation();
if (queued >= MAX_QUEUED) return;
queued++;
enqueue(press);
}
// Bar popup windows with our click listener and icon observer attached.
const attached = new Map(); // window -> MutationObserver
function attach() {
// p.window can throw once its popup is torn down (same as buttons()
// and the loop below); treat that like closed, so one dead reference
// can't stop every other bar popup from attaching.
for (const [w, obs] of attached) {
let closed;
try { closed = w.closed; } catch { closed = true; }
if (closed) { obs.disconnect(); attached.delete(w); }
}
for (const p of window.g_PopupManager.GetPopups()) {
if (!isBarPopup(p.m_strName)) continue;
let w;
try { w = p.window; if (!w?.document?.documentElement || attached.has(w)) continue; } catch { continue; }
find.ensureStyle(w.document, STYLE_ID, CSS);
w.addEventListener('click', onClick, true);
const obs = new MutationObserver(guard(decorateAll));
obs.observe(w.document.documentElement, { childList: true, subtree: true });
attached.set(w, obs);
}
}
// scan: attach to new bar windows, refresh the source when room view
// turns on (e.g. a controller binding) or a bar appears, decorate.
let wasActive = false;
let sawBar = false;
function scan() {
attach();
const barVisible = !!button();
const active = isActive();
if (active && (!wasActive || (barVisible && !sawBar))) enqueue(() => helper('status'));
wasActive = active;
sawBar = barVisible;
decorateAll();
}
const timer = setInterval(guard(scan), 1000);
const stop = () => {
stopped = true;
for (const [w, obs] of attached) {
obs.disconnect();
try {
w.removeEventListener('click', onClick, true);
w.document.getElementById(STYLE_ID)?.remove();
} catch {}
}
attached.clear();
clearInterval(timer);
for (const cb of pending.values()) cb({ ok: false, error: 'patch stopped' });
pending.clear();
for (const b of buttons()) b.removeAttribute(ATTR);
if (window.__sfuiPassthrough === state) delete window.__sfuiPassthrough;
};
const state = { id: ID, version: VERSION, get mode() { return mode(); }, reply, scan, stop };
window.__sfuiPassthrough = state;
scan();
return 'patched';
})