mirror of
https://github.com/MichaelScottsman/LiquidAss-Frame.git
synced 2026-10-19 11:01:08 +02:00
637 lines
29 KiB
JavaScript
637 lines
29 KiB
JavaScript
// Glass Shell phase 2, P3 interaction runtime: input mode, sounds, haptics (dry run).
|
|
// Contract: docs/phase2/contracts/interaction.md §1, §5, §6. Evidence: docs/phase2/wp/P3.md.
|
|
// Promoted from device/proto/input_mode.js (capabilities/input-mode.md, IM).
|
|
//
|
|
// html.lgs-input-pad | html.lgs-input-laser on every Steam popup window
|
|
// html[data-lgs-vr-mode="gamepad"|"laser"] SteamVR's own mode (absent when unknown)
|
|
// rt.input { mode, vrMode, source, state(), onChange(fn), stub(mode, opts), windows(), sound, haptic, hub }
|
|
// rt.sound(event) Steam's own sound bus, Steam's ENavSound for the same event (IM §6.4)
|
|
// rt.haptic(kind) dry run unless flag `haptics` === true (S17)
|
|
//
|
|
// Runs in SharedJSContext. Memory only; remove() restores every subscription and class.
|
|
(function lgsP3Input() {
|
|
'use strict';
|
|
const RT = (typeof __LGS_RT !== 'undefined') ? __LGS_RT : window.__LGS_RT;
|
|
if (!RT || typeof RT.define !== 'function') return;
|
|
|
|
const H = window;
|
|
const SOURCE = ['unknown', 'gamepad', 'keyboard-sim', 'mouse', 'touch', 'lpad', 'rpad']; // EInputSourceType
|
|
const SURFACE_PREFIX = 'valve.steam.gamepadui.';
|
|
const STUB_TTL_MS = 120000;
|
|
|
|
let R = null; // the (scoped) runtime object install() got
|
|
let live = null; // per-install state; null when removed
|
|
// The public runtime object (window.__LGS_RT); a scoped rt inherits from it.
|
|
const PUB = () => (H.__LGS_RT && typeof H.__LGS_RT.define === 'function') ? H.__LGS_RT : RT;
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
// P1's scoped logger: rt.log(msg, data) / rt.warn / rt.error (contracts/runtime.md §3.1).
|
|
function log(level, msg, data) {
|
|
try {
|
|
const f = R && (level === 'error' ? R.error : level === 'warn' ? R.warn : R.log);
|
|
if (typeof f === 'function') { f(msg, data); return; }
|
|
} catch (_) { /* fall through */ }
|
|
try { H.console[level === 'error' ? 'error' : 'log']('[lgs input] ' + msg, data === undefined ? '' : data); } catch (_) { /* none */ }
|
|
}
|
|
function flag(name) {
|
|
try {
|
|
const f = R && R.flags;
|
|
if (!f) return undefined;
|
|
if (typeof f.get === 'function') return f.get(name);
|
|
return f[name];
|
|
} catch (_) { return undefined; }
|
|
}
|
|
function surfaceOfName(name) {
|
|
if (!name) return 'unknown';
|
|
if (name === 'VR_uid0' || /^VR_uid\d+$/.test(name)) return 'main';
|
|
if (/^VRKeyboard_uid/.test(name)) return 'keyboard';
|
|
if (/^VRNotificationToasts_uid/.test(name)) return 'notifications';
|
|
if (name.startsWith(SURFACE_PREFIX)) {
|
|
const rest = name.slice(SURFACE_PREFIX.length);
|
|
const m = rest.match(/^(.*?)\.\d+/);
|
|
return m ? m[1] : rest.replace(/_uid\d+$/, '');
|
|
}
|
|
return name.replace(/_uid\d+$/, '');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- %{Token} selectors, resolved lazily
|
|
// A selector list with %{Token} classes resolves through P1's class index (__LGS_INDEX). A part whose
|
|
// token does not resolve is left out. The index can be incomplete for minutes (after a Steam page
|
|
// crash it held 3 of 550 modules, review R1 F6), so an incomplete result is never kept: the getter
|
|
// resolves again on use, at most once a second, until every part resolves.
|
|
let INDEX_OVERRIDE = null; // test hook only (rt.input.test.index): stands in for an incomplete index
|
|
function resolveParts(list) {
|
|
const parts = String(list || '').split(/,(?![^(]*\))/).map((s) => s.trim()).filter(Boolean);
|
|
const out = [];
|
|
let full = true;
|
|
for (const p of parts) {
|
|
if (p.indexOf('%{') < 0) { out.push(p); continue; }
|
|
let bad = false;
|
|
const s = p.replace(/%\{([^}]+)\}/g, (_, tok) => {
|
|
try {
|
|
const idx = INDEX_OVERRIDE || H.__LGS_INDEX;
|
|
const r = idx && typeof idx.selector === 'function' ? idx.selector(tok) : null;
|
|
if (r && r.sel) return r.sel;
|
|
} catch (_) { /* unresolved */ }
|
|
bad = true;
|
|
return '';
|
|
});
|
|
if (bad) full = false; else out.push(s);
|
|
}
|
|
return { sel: out.join(', '), full };
|
|
}
|
|
function lazySel(list) {
|
|
let cache = '';
|
|
let full = false;
|
|
let at = -1e9;
|
|
let warned = false;
|
|
const get = () => {
|
|
if (full) return cache;
|
|
const t = H.performance.now();
|
|
if (t - at < 1000) return cache;
|
|
at = t;
|
|
const r = resolveParts(list);
|
|
cache = r.sel;
|
|
full = r.full;
|
|
if (!full && !warned) { warned = true; log('warn', 'unresolved %{Token} in selector; retrying on use', String(list)); }
|
|
return cache;
|
|
};
|
|
get.source = String(list);
|
|
get.resolved = () => { get(); return full; };
|
|
return get;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- webpack (one guarded pass)
|
|
// Steam's webpack require, taken once per page load. webpack also keeps every pushed chunk record
|
|
// in the array, so ours is spliced out again at once (G-REMOVE: no global mutation survives).
|
|
let REQ = null;
|
|
function webpackRequire() {
|
|
if (REQ) return REQ;
|
|
const ch = H.webpackChunksteamui;
|
|
if (!ch || typeof ch.push !== 'function') return null;
|
|
const sym = Symbol('lgs-p3-input');
|
|
let req = null;
|
|
try { ch.push([[sym], {}, (r) => { req = r; }]); } catch (_) { req = null; }
|
|
// Ours, and any record an earlier P3 build (before review R1 F4) left behind.
|
|
try {
|
|
for (let i = ch.length - 1; i >= 0; i--) {
|
|
const c = ch[i];
|
|
const id = c && Array.isArray(c[0]) ? c[0][0] : null;
|
|
if (id === sym || (typeof id === 'symbol' && id.description === 'lgs-p3-input')) ch.splice(i, 1);
|
|
}
|
|
} catch (_) { /* array gone */ }
|
|
if (req && req.m) REQ = req;
|
|
return REQ;
|
|
}
|
|
// Ids are hints only; a module is used only if its source passes the needle test.
|
|
function findSteamModules() {
|
|
const req = webpackRequire();
|
|
const out = { reaction: null, bus: null, PN: null };
|
|
if (!req) return out;
|
|
const src = (id) => { try { return Function.prototype.toString.call(req.m[id]); } catch (_) { return ''; } };
|
|
const FIND = {
|
|
mobx: ['89193', (s) => s.includes('[MobX]')],
|
|
sound: ['67758', (s) => s.length < 4000 && s.includes('RegisterCallbackOnPlaySound') && s.includes('"BasicNav"')],
|
|
};
|
|
const found = {};
|
|
const todo = new Set(Object.keys(FIND));
|
|
for (const k of [...todo]) if (req.m[FIND[k][0]] && FIND[k][1](src(FIND[k][0]))) { found[k] = FIND[k][0]; todo.delete(k); }
|
|
if (todo.size) {
|
|
for (const id of Object.keys(req.m)) {
|
|
if (!todo.size) break;
|
|
const s = src(id);
|
|
for (const k of [...todo]) if (FIND[k][1](s)) { found[k] = id; todo.delete(k); }
|
|
}
|
|
}
|
|
try {
|
|
if (found.mobx) {
|
|
out.reaction = Object.values(req(found.mobx)).find((f) => typeof f === 'function'
|
|
&& String(f).includes('"Reaction"') && String(f).includes('compareStructural')) || null;
|
|
}
|
|
} catch (_) { out.reaction = null; }
|
|
try {
|
|
if (found.sound) {
|
|
for (const v of Object.values(req(found.sound))) {
|
|
if (v && typeof v.PlayNavSound === 'function') out.bus = v;
|
|
else if (v && typeof v.BasicNav === 'number') out.PN = v;
|
|
}
|
|
}
|
|
} catch (_) { /* fallbacks below */ }
|
|
out.ids = found;
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- window hub (shared by P3's modules)
|
|
// One g_PopupManager created/destroyed callback pair for the whole package. Modules register
|
|
// per-document hooks with hub.onDoc(fn(win, doc, surface) -> cleanup); a hook runs for every
|
|
// live Steam popup document now and later, and its cleanup runs when the window closes, its
|
|
// document is replaced, the hook is removed or the package is removed.
|
|
function makeHub(PM, scope) {
|
|
// P1's window registry when the runtime has one (one popup callback pair for every module).
|
|
const RW = scope && scope.windows;
|
|
if (RW && typeof RW.track === 'function' && typeof RW.list === 'function' && typeof RW.find === 'function') {
|
|
return {
|
|
p1: true,
|
|
scan() {},
|
|
ensure() {},
|
|
drop() {},
|
|
clear() {},
|
|
windows() { return RW.list().map((e) => e.win); },
|
|
surfaceOf(w) { const e = RW.find(w); return e ? e.kind : surfaceOfName(w && w.name); },
|
|
docOf(w) { const e = RW.find(w); return e ? e.doc : null; },
|
|
main() {
|
|
const e = typeof RW.main === 'function' ? RW.main() : null;
|
|
if (e) return e.win;
|
|
try { return H.SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance.BrowserWindow || null; } catch (_) { return null; }
|
|
},
|
|
onDoc(fn) { return RW.track((e) => fn(e.win, e.doc, e.kind)); },
|
|
};
|
|
}
|
|
const wins = new Map(); // Window -> { doc, surface, cleanups: Map(hookId -> fn) }
|
|
const hooks = new Map(); // hookId -> fn
|
|
const popupOf = new Map(); // Window -> popup name
|
|
let nextId = 1;
|
|
function liveWindows() {
|
|
const out = [];
|
|
try {
|
|
for (const p of PM.GetPopups()) {
|
|
try { if (p.window && p.window.document && p.window.document.documentElement) { out.push(p.window); popupOf.set(p.window, p.m_strName); } } catch (_) { /* closing */ }
|
|
}
|
|
} catch (_) { /* none */ }
|
|
return out;
|
|
}
|
|
function runHook(rec, w, id, fn) {
|
|
try {
|
|
const c = fn(w, rec.doc, rec.surface);
|
|
rec.cleanups.set(id, typeof c === 'function' ? c : null);
|
|
} catch (e) { rec.cleanups.set(id, null); log('error', 'doc hook failed', String(e && e.stack || e)); }
|
|
}
|
|
function dropWin(w) {
|
|
const rec = wins.get(w);
|
|
if (!rec) return;
|
|
for (const c of rec.cleanups.values()) { if (c) { try { c(); } catch (_) { /* gone */ } } }
|
|
wins.delete(w);
|
|
}
|
|
function ensure(w) {
|
|
let d;
|
|
try { d = w.document; } catch (_) { dropWin(w); return; }
|
|
if (!d || !d.documentElement) return;
|
|
const rec = wins.get(w);
|
|
if (rec && rec.doc === d) return;
|
|
if (rec) dropWin(w);
|
|
const nrec = { doc: d, surface: surfaceOfName(popupOf.get(w) || w.name), cleanups: new Map() };
|
|
wins.set(w, nrec);
|
|
for (const [id, fn] of hooks) runHook(nrec, w, id, fn);
|
|
}
|
|
function scan() {
|
|
const seen = new Set(liveWindows());
|
|
for (const w of seen) ensure(w);
|
|
for (const w of [...wins.keys()]) if (!seen.has(w)) dropWin(w);
|
|
}
|
|
return {
|
|
scan,
|
|
ensure,
|
|
drop: dropWin,
|
|
windows() { return [...wins.keys()]; },
|
|
surfaceOf(w) { const r = wins.get(w); return r ? r.surface : surfaceOfName(w && w.name); },
|
|
docOf(w) { const r = wins.get(w); return r ? r.doc : null; },
|
|
main() {
|
|
try { const w = H.SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance.BrowserWindow; if (w) return w; } catch (_) { /* fall back */ }
|
|
for (const [w, r] of wins) if (r.surface === 'main') return w;
|
|
return null;
|
|
},
|
|
onDoc(fn) {
|
|
const id = nextId++;
|
|
hooks.set(id, fn);
|
|
for (const [w, rec] of wins) runHook(rec, w, id, fn);
|
|
return () => {
|
|
hooks.delete(id);
|
|
for (const rec of wins.values()) {
|
|
const c = rec.cleanups.get(id);
|
|
rec.cleanups.delete(id);
|
|
if (c) { try { c(); } catch (_) { /* gone */ } }
|
|
}
|
|
};
|
|
},
|
|
clear() { for (const w of [...wins.keys()]) dropWin(w); hooks.clear(); },
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sounds
|
|
const PN_FALLBACK = ['LaunchGame', 'FriendMessage', 'ChatMention', 'ChatMessage', 'ToastMessage', 'ToastAchievement',
|
|
'ToastMisc', 'ToastMiscShort', 'FriendOnline', 'FriendInGame', 'VolSound', 'ShowModal', 'HideModal', 'IntoGameDetail',
|
|
'OutOfGameDetail', 'PagedNavigation', 'ToggleOn', 'ToggleOff', 'SliderUp', 'SliderDown', 'ChangeTabs', 'DefaultOk',
|
|
'OpenSideMenu', 'CloseSideMenu', 'BasicNav', 'FailedNav', 'Typing', 'TimerExpired', 'Screenshot'];
|
|
// Our events -> Steam's ENavSound for the same event (IM §6.4, VP §4.4).
|
|
const SOUND_EVENT = {
|
|
activate: 'DefaultOk', tab: 'ChangeTabs', segment: 'ChangeTabs', page: 'PagedNavigation',
|
|
toggleOn: 'ToggleOn', toggleOff: 'ToggleOff', sliderUp: 'SliderUp', sliderDown: 'SliderDown',
|
|
sheetIn: 'ShowModal', sheetOut: 'HideModal', menuIn: 'OpenSideMenu', menuOut: 'CloseSideMenu',
|
|
nav: 'BasicNav', fail: 'FailedNav',
|
|
};
|
|
const SOUND_REFUSED = new Set(['hover', 'enter', 'leave', 'dwell']); // visionOS plays nothing on hover
|
|
|
|
// ---------------------------------------------------------------- haptics (SteamVR overlay effects)
|
|
const HAPTIC = { enter: 1, hover: 1, tick: 1, leave: 2, snap: 3, detent: 3, slide: 4, sliding: 4, edge: 5, thud: 5 };
|
|
|
|
RT.define({
|
|
name: 'input',
|
|
deps: [],
|
|
flag: 'wp.p3',
|
|
install(rt) {
|
|
R = rt || RT;
|
|
const FNC = H.FocusNavController;
|
|
const PM = H.g_PopupManager;
|
|
if (!FNC || !FNC.NavigationSource || !PM) throw new Error('input: FocusNavController / g_PopupManager not found');
|
|
const VRI = H.vrGamepadInput || null;
|
|
const steam = findSteamModules();
|
|
const hub = makeHub(PM, R);
|
|
const listeners = new Set();
|
|
const undo = [];
|
|
const st = {
|
|
cur: null, stub: null, stubTimer: 0, lastPulse: -1e9, steam, hub, listeners, undo,
|
|
};
|
|
live = st;
|
|
// Fail closed (runtime.md §1 rule 2): remove() works from here on, whatever step throws below.
|
|
// Each Steam subscription registers its undo the moment it is made, here and with P1's tracked
|
|
// rt.cleanup (both run once), so a partial install leaves nothing behind.
|
|
function addUndo(fn) {
|
|
let done = false;
|
|
const once = () => { if (done) return; done = true; fn(); };
|
|
undo.push(once);
|
|
try { if (R && typeof R.cleanup === 'function' && R !== PUB()) R.cleanup(once); } catch (_) { /* untracked */ }
|
|
return once;
|
|
}
|
|
st.remove = () => {
|
|
H.clearTimeout(st.stubTimer);
|
|
for (const u of undo.splice(0).reverse()) { try { u(); } catch (_) { /* already gone */ } }
|
|
listeners.clear();
|
|
try { hub.clear(); } catch (_) { /* gone */ }
|
|
// Belt and braces: strip our marks from every live popup, hooked or not.
|
|
try { for (const p of PM.GetPopups()) { try { unpaintDoc(p.window.document); } catch (_) { /* gone */ } } } catch (_) { /* none */ }
|
|
};
|
|
|
|
function computeLive() {
|
|
let s = 0;
|
|
try { s = FNC.NavigationSource.Value.eActivationSourceType | 0; } catch (_) { /* keep 0 */ }
|
|
let vrPad = null;
|
|
try { vrPad = VRI ? !!VRI.IsInGamepadNav : null; } catch (_) { vrPad = null; }
|
|
const mode = (s === 1 || s === 2) ? 'pad' : (s === 0 ? (vrPad === false ? 'laser' : 'pad') : 'laser');
|
|
return { mode, source: SOURCE[s] || String(s), vrMode: vrPad === null ? 'none' : (vrPad ? 'gamepad' : 'laser') };
|
|
}
|
|
function effective(l) {
|
|
if (!st.stub) return l;
|
|
return { mode: st.stub.mode || l.mode, vrMode: st.stub.vrMode || l.vrMode, source: l.source };
|
|
}
|
|
function paintDoc(doc) {
|
|
try {
|
|
const de = doc.documentElement;
|
|
const c = st.cur;
|
|
de.classList.toggle('lgs-input-laser', c.mode === 'laser');
|
|
de.classList.toggle('lgs-input-pad', c.mode === 'pad');
|
|
if (c.vrMode === 'gamepad' || c.vrMode === 'laser') de.setAttribute('data-lgs-vr-mode', c.vrMode);
|
|
else de.removeAttribute('data-lgs-vr-mode');
|
|
} catch (_) { /* not ready */ }
|
|
}
|
|
function unpaintDoc(doc) {
|
|
try {
|
|
const de = doc.documentElement;
|
|
de.classList.remove('lgs-input-laser', 'lgs-input-pad');
|
|
de.removeAttribute('data-lgs-vr-mode');
|
|
} catch (_) { /* gone */ }
|
|
}
|
|
function hapticAvailable() {
|
|
try { return typeof hub.main().SteamClient.OpenVR.TriggerOverlayHapticEffect === 'function'; } catch (_) { return false; }
|
|
}
|
|
function stateObj() {
|
|
let sounds = true;
|
|
try { sounds = H.settingsStore.clientSettings.enable_ui_sounds !== false; } catch (_) { /* default on */ }
|
|
const l = computeLive();
|
|
return {
|
|
mode: st.cur.mode, vrMode: st.cur.vrMode, source: st.cur.source,
|
|
stub: st.stub ? { mode: st.stub.mode, vrMode: st.stub.vrMode, until: st.stub.until } : null,
|
|
live: l,
|
|
windows: hub.windows().length,
|
|
subscriptions: {
|
|
navigationSource: true,
|
|
vrOverlayFocus: !!(VRI && typeof VRI.RegisterForNavigationTypeChange === 'function'),
|
|
mobxReaction: !!(VRI && steam.reaction),
|
|
},
|
|
sound: { bus: !!steam.bus, enum: !!steam.PN, uiSoundsEnabled: sounds },
|
|
haptics: { available: hapticAvailable(), armed: flag('haptics') === true },
|
|
};
|
|
}
|
|
function update(reason) {
|
|
if (live !== st) return;
|
|
const before = st.cur;
|
|
st.cur = effective(computeLive());
|
|
hub.scan();
|
|
for (const w of hub.windows()) { const d = hub.docOf(w); if (d) paintDoc(d); }
|
|
if (!before || st.cur.mode !== before.mode || st.cur.vrMode !== before.vrMode) {
|
|
if (!before) return;
|
|
const s = stateObj();
|
|
for (const fn of [...listeners]) {
|
|
try { fn(s, before, reason); } catch (err) { log('error', 'onChange listener failed', String(err && err.stack || err)); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Paint every document the hub hooks (new popups included).
|
|
st.cur = effective(computeLive());
|
|
addUndo(hub.onDoc((w, doc) => { if (st.cur) paintDoc(doc); return () => unpaintDoc(doc); }));
|
|
|
|
// ------------------------------------------------ sounds
|
|
function soundId(name) {
|
|
if (typeof name === 'number') return name;
|
|
const n = SOUND_EVENT[name] || name;
|
|
if (steam.PN && typeof steam.PN[n] === 'number') return steam.PN[n];
|
|
const i = PN_FALLBACK.indexOf(n);
|
|
return i >= 0 ? i : undefined;
|
|
}
|
|
function sound(event, opts) {
|
|
if (live !== st) return null;
|
|
if (typeof event === 'string' && SOUND_REFUSED.has(event)) return null;
|
|
const e = soundId(event);
|
|
if (e === undefined) { log('error', 'unknown sound ' + event); return null; }
|
|
const immediate = !!(opts && opts.immediate);
|
|
try {
|
|
if (steam.bus) steam.bus.PlayNavSound(e, immediate);
|
|
else H.SteamUIStore.m_GamepadUIAudioStore.PlayNavSound(e, immediate);
|
|
} catch (err) { log('error', 'PlayNavSound failed', String(err)); return null; }
|
|
return e;
|
|
}
|
|
|
|
// ------------------------------------------------ haptics (S17: disarmed unless flag haptics === true)
|
|
function haptic(kind, target) {
|
|
const e = typeof kind === 'number' ? kind : HAPTIC[kind];
|
|
let w = null;
|
|
try { w = target && target.ownerDocument ? target.ownerDocument.defaultView : target; } catch (_) { w = null; }
|
|
if (!w) w = hub.main();
|
|
let fn = null;
|
|
try { fn = w.SteamClient.OpenVR.TriggerOverlayHapticEffect; } catch (_) { fn = null; }
|
|
const armed = flag('haptics') === true;
|
|
const isPanel = !!(target && target.classList && target.classList.contains('Panel'));
|
|
const plan = {
|
|
api: 'SteamClient.OpenVR.TriggerOverlayHapticEffect', effect: e || null,
|
|
available: typeof fn === 'function', armed, mode: st.cur ? st.cur.mode : null, fired: false,
|
|
};
|
|
if (isPanel) plan.refused = 'steam-panel';
|
|
const t = H.performance.now();
|
|
if (!e || !plan.available || !armed || isPanel || !st.cur || st.cur.mode !== 'laser' || t - st.lastPulse < 80) return plan;
|
|
st.lastPulse = t;
|
|
try { fn.call(w.SteamClient.OpenVR, e, 0); plan.fired = true; } catch (err) { plan.error = String(err); }
|
|
return plan;
|
|
}
|
|
|
|
// ------------------------------------------------ stub (test hook: our classes only)
|
|
function stub(mode, opts) {
|
|
if (live !== st) return () => {};
|
|
H.clearTimeout(st.stubTimer);
|
|
if (mode === null || mode === undefined || mode === false) {
|
|
if (st.stub) { st.stub = null; update('stub'); }
|
|
return () => {};
|
|
}
|
|
if (mode !== 'pad' && mode !== 'laser') throw new Error('input.stub: mode must be "pad", "laser" or null');
|
|
const o = opts || {};
|
|
const vrMode = o.vrMode === 'gamepad' || o.vrMode === 'laser' ? o.vrMode : null;
|
|
const ttl = typeof o.ttlMs === 'number' && o.ttlMs > 0 ? o.ttlMs : STUB_TTL_MS;
|
|
const token = { mode, vrMode, until: Date.now() + ttl };
|
|
st.stub = token;
|
|
st.stubTimer = H.setTimeout(() => { if (st.stub === token) { st.stub = null; update('stub'); } }, ttl);
|
|
update('stub');
|
|
return () => { if (st.stub === token) { H.clearTimeout(st.stubTimer); st.stub = null; update('stub'); } };
|
|
}
|
|
|
|
// ------------------------------------------------ sticky marks (P3-internal)
|
|
// Steam renders className through React: a re-render (e.g. the one that adds .gpfocus right
|
|
// after vgp_onfocus) overwrites the class attribute and drops our classes. A mark is re-added
|
|
// by a MutationObserver callback (a microtask, so before the frame is painted) until it is
|
|
// released. One observer per document; it only watches the elements we marked.
|
|
// Weak by design (review R1 F1): the registry never keeps an element alive. Marked elements are
|
|
// held through a WeakMap (element -> classes) and a WeakRef set used only to sweep on removal,
|
|
// so a page Steam unmounts while one of its nodes is marked is still collected.
|
|
const WR = typeof H.WeakRef === 'function' ? H.WeakRef : null;
|
|
const marks = (() => {
|
|
const perDoc = new Map(); // doc -> { mo, want: WeakMap(el -> {cls: Set, ref}), refs: Set(WeakRef | el) }
|
|
const deref = (ref) => (WR && ref instanceof WR ? ref.deref() : ref);
|
|
function recFor(doc) {
|
|
let r = perDoc.get(doc);
|
|
if (r) return r;
|
|
const want = new WeakMap();
|
|
const view = doc.defaultView || H;
|
|
const MO = view.MutationObserver || H.MutationObserver;
|
|
const mo = new MO((muts) => {
|
|
for (const m of muts) {
|
|
const w = want.get(m.target);
|
|
if (!w) continue;
|
|
for (const c of w.cls) if (!m.target.classList.contains(c)) m.target.classList.add(c);
|
|
}
|
|
});
|
|
r = { mo, want, refs: new Set() };
|
|
perDoc.set(doc, r);
|
|
return r;
|
|
}
|
|
function dropDoc(doc, r) { try { r.mo.disconnect(); } catch (_) { /* gone */ } perDoc.delete(doc); }
|
|
// Forget collected elements and closed documents (cheap: a handful of marks are live at a time).
|
|
function prune() {
|
|
for (const [doc, r] of perDoc) {
|
|
for (const ref of r.refs) { const el = deref(ref); if (!el) r.refs.delete(ref); }
|
|
if (!r.refs.size || !doc.defaultView) dropDoc(doc, r);
|
|
}
|
|
}
|
|
function add(el, cls) {
|
|
if (!el || el.nodeType !== 1) return;
|
|
const r = recFor(el.ownerDocument);
|
|
let w = r.want.get(el);
|
|
if (!w) {
|
|
w = { cls: new Set(), ref: WR ? new WR(el) : el };
|
|
r.want.set(el, w);
|
|
r.refs.add(w.ref);
|
|
r.mo.observe(el, { attributes: true, attributeFilter: ['class'] });
|
|
if (r.refs.size > 32) prune();
|
|
}
|
|
w.cls.add(cls);
|
|
el.classList.add(cls);
|
|
}
|
|
function remove(el, cls) {
|
|
if (!el || el.nodeType !== 1) return;
|
|
const doc = el.ownerDocument;
|
|
const r = perDoc.get(doc);
|
|
const w = r && r.want.get(el);
|
|
if (w) {
|
|
w.cls.delete(cls);
|
|
if (!w.cls.size) {
|
|
r.want.delete(el);
|
|
r.refs.delete(w.ref);
|
|
if (!r.refs.size) dropDoc(doc, r); // disconnect also drops stale observations
|
|
}
|
|
}
|
|
try { el.classList.remove(cls); } catch (_) { /* gone */ }
|
|
}
|
|
function has(el, cls) {
|
|
const r = el && perDoc.get(el.ownerDocument);
|
|
const w = r && r.want.get(el);
|
|
return !!(w && w.cls.has(cls));
|
|
}
|
|
// Release every mark whose class passes pred (a module's own sweep on its removal).
|
|
function sweep(pred) {
|
|
for (const [doc, r] of [...perDoc]) {
|
|
for (const ref of [...r.refs]) {
|
|
const el = deref(ref);
|
|
const w = el && r.want.get(el);
|
|
if (!w) { r.refs.delete(ref); continue; }
|
|
for (const c of [...w.cls]) if (pred(c)) remove(el, c);
|
|
}
|
|
if (perDoc.get(doc) === r && !r.refs.size) dropDoc(doc, r);
|
|
}
|
|
}
|
|
function clear() {
|
|
for (const r of perDoc.values()) {
|
|
try { r.mo.disconnect(); } catch (_) { /* gone */ }
|
|
for (const ref of r.refs) {
|
|
const el = deref(ref);
|
|
const w = el && r.want.get(el);
|
|
if (w) { try { el.classList.remove(...w.cls); } catch (_) { /* gone */ } }
|
|
}
|
|
}
|
|
perDoc.clear();
|
|
}
|
|
function count() { prune(); let n = 0; for (const r of perDoc.values()) n += r.refs.size; return n; }
|
|
return { add, remove, has, sweep, clear, count, weak: !!WR };
|
|
})();
|
|
addUndo(() => marks.clear());
|
|
|
|
const api = {
|
|
get mode() { return st.cur.mode; },
|
|
get vrMode() { return st.cur.vrMode; },
|
|
get source() { return st.cur.source; },
|
|
state: stateObj,
|
|
onChange(fn) {
|
|
if (typeof fn !== 'function') throw new Error('input.onChange: function expected');
|
|
listeners.add(fn);
|
|
return () => { listeners.delete(fn); };
|
|
},
|
|
stub,
|
|
windows() { return hub.windows(); },
|
|
surfaceOf(w) { return hub.surfaceOf(w); },
|
|
sound,
|
|
soundId,
|
|
haptic,
|
|
// P3-internal: the per-document hook hub, sticky class marks and lazy %{Token} selectors
|
|
// shared by attention, states and tooltip.
|
|
hub,
|
|
marks,
|
|
lazySel,
|
|
// Test hook (review R1 F6): make lazy selectors see a stand-in class index, e.g. one that has not
|
|
// indexed a token yet; null restores P1's. Only P3's own resolution is affected, never the theme's.
|
|
test: {
|
|
index(fake) { INDEX_OVERRIDE = fake && typeof fake.selector === 'function' ? fake : null; return !!INDEX_OVERRIDE; },
|
|
},
|
|
};
|
|
addUndo(() => { INDEX_OVERRIDE = null; });
|
|
|
|
// ------------------------------------------------ subscriptions (each undo registered at once)
|
|
const s1 = FNC.NavigationSource.Subscribe(() => update('source'));
|
|
addUndo(() => s1.Unsubscribe());
|
|
if (VRI && typeof VRI.RegisterForNavigationTypeChange === 'function') {
|
|
const s2 = VRI.RegisterForNavigationTypeChange(() => update('vr-overlay-focus'));
|
|
addUndo(() => s2.Unregister());
|
|
}
|
|
if (VRI && steam.reaction) {
|
|
const dispose = steam.reaction(() => { try { return VRI.IsInGamepadNav; } catch (_) { return null; } }, () => update('vr-mode'));
|
|
addUndo(dispose);
|
|
} else if (VRI) {
|
|
const t = H.setInterval(() => update('poll'), 2000); // last resort when MobX is not found
|
|
addUndo(() => H.clearInterval(t));
|
|
}
|
|
if (!hub.p1) {
|
|
const pending = new Set();
|
|
addUndo(() => { for (const t of pending) H.clearTimeout(t); pending.clear(); });
|
|
const pc = PM.AddPopupCreatedCallback((p) => {
|
|
for (const ms of [0, 250, 1000]) {
|
|
const t = H.setTimeout(() => {
|
|
pending.delete(t);
|
|
if (live !== st) return;
|
|
try { if (p.window) hub.scan(); for (const w of hub.windows()) { const d = hub.docOf(w); if (d) paintDoc(d); } } catch (_) { /* closed */ }
|
|
}, ms);
|
|
pending.add(t);
|
|
}
|
|
});
|
|
addUndo(() => pc.Unregister());
|
|
const pd = PM.AddPopupDestroyedCallback((p) => {
|
|
let w = null;
|
|
try { w = p.window; } catch (_) { /* gone */ }
|
|
if (w) hub.drop(w);
|
|
});
|
|
addUndo(() => pd.Unregister());
|
|
}
|
|
|
|
hub.scan();
|
|
for (const w of hub.windows()) { const d = hub.docOf(w); if (d) paintDoc(d); }
|
|
// PLAN §1.4: one accessor, __LGS_RT.input, on the public runtime object, so every module's scoped
|
|
// rt (Object.create(public)) and the lab (__LGS_RT.input.stub) see it. Through P1's rt.expose
|
|
// (tracked: deleted when this module is removed); plain assignment only without it.
|
|
function expose(name, value) {
|
|
const P = PUB();
|
|
if (R && R !== P && typeof R.expose === 'function') {
|
|
try { addUndo(R.expose(name, value)); return; } catch (err) { log('warn', 'rt.expose(' + name + ') refused; assigning', String(err)); }
|
|
}
|
|
P[name] = value;
|
|
addUndo(() => { if (P[name] === value) delete P[name]; });
|
|
}
|
|
expose('input', api);
|
|
expose('sound', sound);
|
|
expose('haptic', haptic);
|
|
return api;
|
|
},
|
|
remove() {
|
|
const st = live;
|
|
live = null;
|
|
if (st && st.remove) st.remove();
|
|
const P = PUB();
|
|
if (P && st && P.input && P.input.hub === st.hub) { delete P.input; delete P.sound; delete P.haptic; }
|
|
R = null;
|
|
},
|
|
});
|
|
})();
|