mirror of
https://github.com/MichaelScottsman/LiquidAss-Frame.git
synced 2026-10-18 07:01:01 +02:00
Install paths and internal identifiers keep the glass-shell name so existing installs carry over. Steam's program row is matched by the new name, with the old one still accepted.
1455 lines
78 KiB
JavaScript
1455 lines
78 KiB
JavaScript
// Glass Shell runtime module "react" (P2): Steam React framework (tier T3).
|
|
// Contract: docs/phase2/contracts/react.md. Mechanisms: docs/phase2/capabilities/steam-react.md (SR).
|
|
//
|
|
// What it gives other modules (const react = rt.use('react')):
|
|
// - Steam's own React, jsx runtime, router and components, found by source text (SR §3.2);
|
|
// - routes.add(path, Component) under /library/lgs/... and routes.override(path, fn(steamChildren))
|
|
// by patching the main window's route switch fiber (SR §3.4, §3.5), self-healing on navigation;
|
|
// - patch.byProps(id, predicate, wrap) for memo / observer / forwardRef / class components found by
|
|
// the shape of their props (SR §3.2);
|
|
// - ui helpers (Page, ErrorBoundary, menu, modal, confirm, loc), data helpers, and
|
|
// - actions (launchNonSteam, primary, desktopWindow, navigate) that are LOGGED, never run, in test mode.
|
|
//
|
|
// Fail closed: the first call that needs Steam's modules runs ready(); a missing required finder or route
|
|
// switch throws before anything is patched. install() itself is cheap and patches nothing. remove()
|
|
// restores every fiber, holder and listener and reports patchedLeft. Nothing persists: everything lives
|
|
// in SharedJSContext's heap.
|
|
(() => {
|
|
'use strict';
|
|
const RT = window.__LGS_RT || window.__LGS_RT_TEST;
|
|
if (!RT || typeof RT.define !== 'function') return;
|
|
|
|
const VERSION = 3; // contract react.md v3 (§11)
|
|
const PREFIX = '/library/lgs/';
|
|
const REQUIRED = ['React', 'jsx', 'RoutePaths', 'Focusable', 'DialogButton', 'GamepadPage'];
|
|
const MARK = '__lgsP2'; // set on every function this module installs
|
|
// Steam's library-grid launch source (the value the grid's tile menu passes; module 94601 `Zw7` = 1000
|
|
// in build 11094443). It only tags the launch for Steam's own statistics.
|
|
const LAUNCH_SOURCE_LIBRARY = 1000;
|
|
const CLIENT = 'mostavailable'; // what the library grid's tile menu passes (SR, 43394 callers)
|
|
const ACTION_LOG_MAX = 200;
|
|
const WALK_MAX = 400000;
|
|
|
|
let rt = null; // P1's scoped runtime for this module
|
|
let S = null; // state of the current install
|
|
let aliased = null; // the api we exposed as RT.react
|
|
let jsxMod = null, reactMod = null; // Steam's jsx runtime and React exports (patchedLeft checks)
|
|
|
|
// ------------------------------------------------------------ small utilities
|
|
// Our trampolines (patch.byProps, the route switch) keep the function they replace as __lgsOrig: source
|
|
// checks read through them, so a predicate that reads the source still matches a patched component
|
|
// (a second layer, review R1 m5).
|
|
function unwrapFn(f) {
|
|
for (let i = 0; i < 8 && typeof f === 'function' && typeof f.__lgsOrig === 'function'; i++) f = f.__lgsOrig;
|
|
return f;
|
|
}
|
|
function fnSrc(v) {
|
|
try {
|
|
if (v == null) return '';
|
|
const f = unwrapFn(typeof v === 'function' ? v : (v.render || (v.type && (v.type.render || v.type))));
|
|
if (typeof f !== 'function') return '';
|
|
let s = Function.prototype.toString.call(f);
|
|
if (f.prototype && typeof f.prototype.render === 'function') s += '\n' + Function.prototype.toString.call(unwrapFn(f.prototype.render));
|
|
return s;
|
|
} catch (_) { return ''; }
|
|
}
|
|
function pick(ex, test) {
|
|
if (!ex || (typeof ex !== 'object' && typeof ex !== 'function')) return null;
|
|
for (const k of Object.keys(ex)) {
|
|
let v; try { v = ex[k]; } catch (_) { continue; }
|
|
try { if (v && test(v, fnSrc(v), k)) return v; } catch (_) { /* next */ }
|
|
}
|
|
return null;
|
|
}
|
|
const has = (s, ...n) => n.every((x) => s.includes(x));
|
|
const util = { fnSrc, pick, has };
|
|
const errMsg = (e) => String((e && e.message) || e);
|
|
function logE(msg, data) { try { rt.error(msg, data); } catch (_) { /* no runtime log */ } }
|
|
function logI(msg, data) { try { rt.log(msg, data); } catch (_) { /* no runtime log */ } }
|
|
|
|
// ------------------------------------------------------------ finders (SR §3.2)
|
|
// name -> [needles every candidate module's SOURCE must contain, pick(exports, util)].
|
|
// Needles are literal strings Steam keeps across builds; never webpack ids or minified names.
|
|
const FINDERS = {
|
|
React: [['react.production'], (ex) => (ex.createElement && ex.useState && ex.version ? ex : null)],
|
|
jsx: [['react-jsx-runtime.production'], (ex) => (ex.jsx && ex.jsxs ? ex : null)],
|
|
ReactDOM: [['react-dom.production'], (ex) => (ex.createPortal && ex.flushSync ? ex : null)],
|
|
Router: [['computeRootMatch', 'Router-History'], (ex) => {
|
|
const proto = (v) => (v && v.prototype && typeof v.prototype.render === 'function' ? Function.prototype.toString.call(v.prototype.render) : '');
|
|
const Route = pick(ex, (v) => has(proto(v), 'computedMatch', '.component'));
|
|
const Switch = pick(ex, (v) => has(proto(v), 'Children.forEach', 'computedMatch'));
|
|
const matchPath = pick(ex, (v, s) => typeof v === 'function' && !proto(v) && has(s, 'isExact', 'sensitive'));
|
|
return Route && Switch && matchPath ? { Route, Switch, matchPath } : null;
|
|
}],
|
|
RoutePaths: [['"/zoo"', 'GamepadUI'], (ex) => pick(ex, (v) => typeof v === 'object' && v.GamepadUI && v.GamepadUI.Zoo && v.Library && typeof v.Library.Home === 'function')],
|
|
Focusable: [['"flow-children":', 'focusWithinClassName', '"Panel"'], (ex) => pick(ex, (v, s) => typeof v === 'function' && has(s, '"flow-children"', 'focusWithinClassName'))],
|
|
DialogButton: [['"DialogButton","_DialogLayout","Secondary"'], (ex) => pick(ex, (v, s) => has(s, '"DialogButton","_DialogLayout","Secondary"'))],
|
|
DialogButtonPrimary: [['"DialogButton","_DialogLayout","Primary"'], (ex) => pick(ex, (v, s) => has(s, '"DialogButton","_DialogLayout","Primary"'))],
|
|
Field: [['spacingBetweenLabelAndChild', '"keep-inline"'], (ex) => pick(ex, (v, s) => has(s, 'spacingBetweenLabelAndChild', '"keep-inline"', 'childrenLayout'))],
|
|
ToggleField: [['"ToggleField"'], (ex) => pick(ex, (v, s) => v.render && has(s, '"ToggleField"'))],
|
|
SliderField: [['"SliderField"'], (ex) => pick(ex, (v, s) => typeof v === 'function' && s.length < 400 && has(s, '"SliderField"'))],
|
|
DropdownField: [['"DropDownField"'], (ex) => pick(ex, (v, s) => typeof v === 'function' && s.length < 400 && has(s, '"DropDownField"'))],
|
|
GamepadPage: [['useHeaderOpacitiesForGamepadPage', 'padForHeader'], (ex) => pick(ex, (v, s) => v.render && has(s, 'padForHeader', 'scrollable', 'headerVisibility'))],
|
|
ScrollPanel: [['scrollPaddingLeft', 'ScrollBoth'], (ex) => pick(ex, (v, s) => v.render && has(s, 'scrollPaddingTop', 'scrollDirection'))],
|
|
ScrollPanelGroup: [['scrollPaddingLeft', 'ScrollBoth', 'scrollStepPercent'], (ex) => pick(ex, (v, s) => v.render && has(s, 'scrollStepPercent', 'onGamepadDirection'))],
|
|
ConfirmModal: [['bAlertDialog', 'strMiddleButtonText', 'bProgressDialog'], (ex) => pick(ex, (v, s) => typeof v === 'function' && has(s, 'bAlertDialog', 'strMiddleButtonText', 'closeModal'))],
|
|
showModal: [['bHideMainWindowForPopouts', 'bForcePopOut'], (ex) => pick(ex, (v, s) => typeof v === 'function' && s.length < 200 && has(s, 'bHideMainWindowForPopouts'))],
|
|
showContextMenu: [['GetContextMenuManagerFromWindow', 'CreateContextMenuInstance'], (ex) => pick(ex, (v, s) => typeof v === 'function' && has(s, 'CreateContextMenuInstance', '.Show()'))],
|
|
Menu: [['contextMenuCheckMark', 'bInteractableItem'], (ex) => {
|
|
const MenuItem = pick(ex, (v, s) => has(s, 'bInteractableItem', 'OnOKButton'));
|
|
const Menu = pick(ex, (v, s) => typeof v === 'function' && s.length < 160 && has(s, 'labelId:', 'useId()'));
|
|
const MenuSeparator = pick(ex, (v, s) => typeof v === 'function' && has(s, 'ContextMenuSeparator', '"separator"'));
|
|
return MenuItem && Menu ? { Menu, MenuItem, MenuSeparator } : null;
|
|
}],
|
|
useNonSteamApps: [['ScanForInstalledNonSteamApps', 'useEffect'], (ex) => pick(ex, (v, s) => typeof v === 'function' && has(s, 'ScanForInstalledNonSteamApps'))],
|
|
LaunchFilters: [['"vrurlhandler"', '"Install Chromium"'], (ex) => {
|
|
const arrs = Object.keys(ex).map((k) => { try { return ex[k]; } catch (_) { return null; } }).filter(Array.isArray);
|
|
const always = arrs.find((a) => a.includes('vrurlhandler') && !a.includes('konsole'));
|
|
const devOnly = arrs.find((a) => a.includes('konsole'));
|
|
const allow = arrs.find((a) => a.includes('Install Chromium'));
|
|
const useDevMode = pick(ex, (v, s) => typeof v === 'function' && s.length < 200 && has(s, '"developer_mode_enabled"'));
|
|
return always && devOnly && allow ? { always, devOnly, allow, useDevMode } : null;
|
|
}],
|
|
// The library tile menu (module 43394 in 11094443): GetPrimaryActionMenuItem runs
|
|
// runAction(primaryAction(instance, overview, client), overview, client, launchSource, window)().
|
|
AppActions: [['GetPrimaryActionMenuItem', 'ContextMenuAction'], (ex) => {
|
|
const primaryAction = pick(ex, (v, s) => typeof v === 'function' && has(s, 'BIsAppBlocked()', 'display_status') && !s.includes('Local-only'));
|
|
const runAction = pick(ex, (v, s) => typeof v === 'function' && has(s, 'Local-only app action for non-local client data', 'CancelLaunch'));
|
|
return primaryAction && runAction ? { primaryAction, runAction } : null;
|
|
}],
|
|
// "#GameAction_<action>" label (module 18488): label(action, count)
|
|
ActionLabel: [['"#GameActionPlural_"', '"#GameAction_"'], (ex) => pick(ex, (v, s) => typeof v === 'function' && has(s, '"#GameActionPlural_"', '"#GameAction_"'))],
|
|
// The bar's CSS module (12031 in 11094443): its hashed class names, so the statusPill target does
|
|
// not depend on the theme's class index.
|
|
BarClasses: [['QuickAccessButton:"'], (ex) => (ex && typeof ex.QuickAccessButton === 'string' ? ex : null)],
|
|
// The VR gamepad UI message service (module 92102): p.SteamVR.DashboardDesktopWindowClicked
|
|
VRMessages: [['"VRGamepadUIMessages"', 'm_SteamVR_ClientMethods'], (ex) => pick(ex, (v) => typeof v === 'object' && v.SteamVR && typeof v.SteamVR.DashboardDesktopWindowClicked === 'function')],
|
|
};
|
|
|
|
function newState() {
|
|
return {
|
|
req: null, src: null, srcTimer: null, factories: 0, M: null, where: {}, counts: {}, ready: false, error: null, scanMs: null,
|
|
scan: null, attempts: 0, retryAt: 0, noSwitch: false,
|
|
broken: new Set(), RouteType: null, ui: null,
|
|
switchPatches: [], routes: new Map(), overrides: new Map(), unlisten: null, winOff: null, lateOffs: [],
|
|
targets: new Map(), handles: new Map(), touched: new Set(), typeMap: new Map(), jsxHook: null,
|
|
menus: new Set(), modals: new Set(),
|
|
actionLog: [], actionSubs: new Set(), forceTest: false,
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------ webpack and source scan
|
|
function getReq() {
|
|
if (S.req) return S.req;
|
|
const chunk = window.webpackChunksteamui;
|
|
if (!chunk || typeof chunk.push !== 'function') throw transientError('lgs-react: webpackChunksteamui not found (Steam UI not loaded yet?)');
|
|
const sym = Symbol('lgs-react');
|
|
let req = null;
|
|
chunk.push([[sym], {}, (r) => { req = r; }]);
|
|
// webpack also stores the pushed entry in the array; take ours out again.
|
|
for (let i = chunk.length - 1; i >= 0; i--) { const c = chunk[i]; if (c && c[0] && c[0][0] === sym) { chunk.splice(i, 1); break; } }
|
|
if (!req || !req.m) throw transientError('lgs-react: webpack require not available');
|
|
S.req = req;
|
|
return req;
|
|
}
|
|
function sources() {
|
|
if (S.src) return S.src;
|
|
const req = getReq();
|
|
const out = [];
|
|
for (const id of Object.keys(req.m)) {
|
|
let s; try { s = Function.prototype.toString.call(req.m[id]); } catch (_) { continue; }
|
|
out.push([id, s]);
|
|
}
|
|
S.src = out;
|
|
S.factories = out.length;
|
|
return out;
|
|
}
|
|
// One pass over the cached sources; require only modules whose source has every needle, smallest
|
|
// first (a module nobody loaded yet would run its top-level code on require).
|
|
// Each finder is searched by ONE anchor needle (its longest: string search skips ahead by the pattern's
|
|
// length), source-major so each source is scanned while it is hot; only the anchor's modules are then
|
|
// checked for the other needles. Measured on the Frame (build 11094443, a busy device): 143-165 ms
|
|
// against 466-494 ms for one alternation regex of every needle, which V8 runs alternative by
|
|
// alternative over 21 MB of two-byte source.
|
|
function findIn(spec) {
|
|
const src = sources();
|
|
const req = getReq();
|
|
const cand = {}, size = {};
|
|
const anchorOf = {};
|
|
for (const k in spec) {
|
|
cand[k] = [];
|
|
anchorOf[k] = spec[k][0].reduce((x, y) => (y.length > x.length ? y : x));
|
|
}
|
|
const anchors = [...new Set(Object.values(anchorOf))];
|
|
const byAnchor = new Map(anchors.map((x) => [x, []]));
|
|
for (let i = 0; i < src.length; i++) {
|
|
const s = src[i][1];
|
|
for (const x of anchors) if (s.includes(x)) byAnchor.get(x).push(i);
|
|
}
|
|
for (const k in spec) {
|
|
const nd = spec[k][0];
|
|
for (const i of byAnchor.get(anchorOf[k])) {
|
|
const [id, s] = src[i];
|
|
if (nd.every((n) => s.includes(n))) { cand[k].push(id); size[id] = s.length; }
|
|
}
|
|
}
|
|
const mods = {}, where = {}, counts = {};
|
|
for (const k in spec) {
|
|
cand[k].sort((a, b) => size[a] - size[b]);
|
|
counts[k] = cand[k].length;
|
|
for (const id of cand[k]) {
|
|
let ex; try { ex = req(id); } catch (_) { continue; }
|
|
let v = null; try { v = spec[k][1](ex, util); } catch (_) { v = null; }
|
|
if (v) { mods[k] = v; where[k] = id; break; }
|
|
}
|
|
}
|
|
return { mods, where, counts };
|
|
}
|
|
|
|
// ------------------------------------------------------------ Steam glue
|
|
const inst = () => window.SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance;
|
|
const mainWin = () => { const i = inst(); return i.BrowserWindow || i.m_BrowserWindow; };
|
|
const route = () => inst().m_history.location.pathname;
|
|
const nav = {
|
|
inst, win: mainWin, route,
|
|
location: () => inst().m_history.location,
|
|
go(path, replace) { inst().Navigate(path, !!replace); return path; },
|
|
back() { inst().NavigateBack(); },
|
|
focusRoot() { inst().FocusApplicationRoot(); },
|
|
};
|
|
|
|
// ------------------------------------------------------------ fibers
|
|
function fiberOf(el) {
|
|
if (!el) return null;
|
|
for (const k of Object.keys(el)) if (k.startsWith('__reactFiber$')) return el[k];
|
|
return null;
|
|
}
|
|
function hostRoot() {
|
|
const doc = mainWin().document;
|
|
const tw = doc.createTreeWalker(doc.body, 1 /* SHOW_ELEMENT */);
|
|
for (let n = tw.nextNode(), i = 0; n && i < 5000; n = tw.nextNode(), i++) {
|
|
let f = fiberOf(n);
|
|
if (!f) continue;
|
|
while (f.return) f = f.return;
|
|
// A DOM node's fiber pointer can be the alternate; walk the committed tree (FiberRoot.current).
|
|
if (f.tag === 3 && f.stateNode && f.stateNode.current) f = f.stateNode.current;
|
|
return f;
|
|
}
|
|
throw new Error('lgs-react: no React fiber in the main window');
|
|
}
|
|
// Depth-first over the mounted tree (main window and every popup: popups are portals of SharedJSContext's
|
|
// single root). visit(f) returning true stops the walk.
|
|
function walk(visit, from) {
|
|
const stack = [from || hostRoot()];
|
|
let n = 0;
|
|
while (stack.length && n++ < WALK_MAX) {
|
|
const f = stack.pop();
|
|
if (visit(f) === true) return n;
|
|
if (f.sibling) stack.push(f.sibling);
|
|
if (f.child) stack.push(f.child);
|
|
}
|
|
return n;
|
|
}
|
|
function findFibers(pred, max) {
|
|
const out = [];
|
|
walk((f) => {
|
|
const p = f.memoizedProps;
|
|
if (p && typeof p === 'object') { let ok = false; try { ok = pred(p, f); } catch (_) { ok = false; } if (ok) out.push(f); }
|
|
return max && out.length >= max;
|
|
});
|
|
return out;
|
|
}
|
|
// The first DOM node a fiber renders (depth-first through its children).
|
|
function firstHost(f) {
|
|
for (let c = f && f.child, i = 0; c && i < 200; c = c.child, i++) if (c.tag === 5) return c.stateNode;
|
|
return null;
|
|
}
|
|
function closestFiber(el, pred) {
|
|
for (let f = fiberOf(el); f; f = f.return) {
|
|
const p = f.memoizedProps;
|
|
if (f.tag !== 5 && f.tag !== 6 && p && typeof p === 'object') { let ok = false; try { ok = pred(p, f); } catch (_) { ok = false; } if (ok) return f; }
|
|
}
|
|
return null;
|
|
}
|
|
const fiber = {
|
|
of: fiberOf,
|
|
firstHost,
|
|
root: () => hostRoot(),
|
|
walk: (visit) => walk(visit),
|
|
findAll: (pred, opts) => findFibers(pred, opts && opts.max),
|
|
closest: closestFiber,
|
|
props(el) { const f = closestFiber(el, () => true); return f ? f.memoizedProps : null; },
|
|
};
|
|
|
|
// The main route switch: a function component whose children prop is the list of top-level <Route>
|
|
// elements (it contains both /settings and /zoo). SR §3.3.
|
|
function routeSwitchFibers(P) {
|
|
const settings = P.Settings.Root(), zoo = P.GamepadUI.Zoo.Root();
|
|
const out = [];
|
|
walk((f) => {
|
|
const ch = f.memoizedProps && typeof f.memoizedProps === 'object' ? f.memoizedProps.children : null;
|
|
if (f.tag === 0 && Array.isArray(ch)
|
|
&& ch.some((c) => c && c.props && c.props.path === settings)
|
|
&& ch.some((c) => c && c.props && c.props.path === zoo)) out.push(f);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ------------------------------------------------------------ ready(): the one scan, fail closed
|
|
// Kill switch: the flag `react` set to an off value turns every T3 feature off (callers fail to
|
|
// install and fall back to T1). Unset means on. Not sticky: it is read on every ready().
|
|
function killed() {
|
|
try {
|
|
if (!rt || !rt.flags || typeof rt.flags.get !== 'function') return false;
|
|
const v = rt.flags.get('react');
|
|
return v !== undefined && !rt.flags.enabled('react');
|
|
} catch (_) { return false; }
|
|
}
|
|
// A failure that can clear by itself (Steam's UI still mounting, as on the re-injection after a
|
|
// SharedJSContext restart): the route switch or the main window's fiber is not there yet, or webpack is
|
|
// not loaded. ready() retries those after a backoff (1, 2, 4, 8, 16, then every 30 s) instead of keeping
|
|
// T3 off until the next lgs on. A missing required finder stays sticky (review R1 m7).
|
|
function transientError(msg) { const e = new Error(msg); e.transient = true; return e; }
|
|
const RETRY_BASE_MS = 1000, RETRY_MAX_MS = 30000;
|
|
function ready() {
|
|
if (!S) throw new Error('lgs-react: module not installed');
|
|
if (killed()) throw new Error('lgs-react: flag react is off (T3 off)');
|
|
if (S.ready) return true;
|
|
if (S.error && (!S.error.transient || Date.now() < S.retryAt)) throw S.error;
|
|
const t0 = performance.now();
|
|
try {
|
|
// The finders' result is kept across transient retries (no second scan).
|
|
const r = S.scan || findIn(FINDERS);
|
|
for (const k of S.broken) { delete r.mods[k]; delete r.where[k]; }
|
|
const missing = REQUIRED.filter((k) => !r.mods[k]);
|
|
if (missing.length) throw new Error('lgs-react: cannot find ' + missing.join(', ') + ' (fail closed; T3 off)');
|
|
S.scan = r;
|
|
let fibers;
|
|
try { fibers = S.noSwitch ? [] : routeSwitchFibers(r.mods.RoutePaths); } catch (e) { throw transientError(errMsg(e) + ' (fail closed; T3 off until a retry finds it)'); }
|
|
if (!fibers.length) throw transientError('lgs-react: main route switch not found (fail closed; T3 off until a retry finds it)');
|
|
const zoo = r.mods.RoutePaths.GamepadUI.Zoo.Root();
|
|
const sib = fibers[0].memoizedProps.children.find((c) => c && c.props && c.props.path === zoo);
|
|
S.RouteType = sib ? sib.type : (r.mods.Router && r.mods.Router.Route);
|
|
if (!S.RouteType) throw new Error('lgs-react: no <Route> element type (fail closed; T3 off)');
|
|
S.M = r.mods; S.where = r.where; S.counts = r.counts;
|
|
jsxMod = r.mods.jsx; reactMod = r.mods.React;
|
|
S.ui = makeUI(r.mods);
|
|
S.ready = true;
|
|
S.error = null; S.attempts = 0; S.retryAt = 0; S.scan = null;
|
|
S.scanMs = Math.round(performance.now() - t0);
|
|
logI(`ready in ${S.scanMs} ms (${S.factories} factories)`);
|
|
dropSourcesLater();
|
|
return true;
|
|
} catch (e) {
|
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
S.scanMs = Math.round(performance.now() - t0);
|
|
S.src = null;
|
|
if (err.transient) {
|
|
S.attempts++;
|
|
err.retryAfterMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * Math.pow(2, S.attempts - 1));
|
|
err.attempt = S.attempts;
|
|
S.retryAt = Date.now() + err.retryAfterMs;
|
|
logE(`ready() failed (transient, attempt ${S.attempts}; retried by the next ready() after ${err.retryAfterMs} ms)`, err.message);
|
|
} else {
|
|
S.scan = null;
|
|
logE('ready() failed', err.message);
|
|
}
|
|
S.error = err;
|
|
throw err;
|
|
}
|
|
}
|
|
const need = () => { ready(); return S.M; };
|
|
// The source cache serves callers' own finders (find()) during their install; it is dropped 60 s
|
|
// after the last use (a later find() scans again).
|
|
const SRC_KEEP_MS = 60000;
|
|
function dropSourcesLater() {
|
|
if (!S) return;
|
|
if (S.srcTimer) { try { S.srcTimer(); } catch (_) { /* cleared */ } S.srcTimer = null; }
|
|
const st = S;
|
|
try {
|
|
if (rt && typeof rt.setTimeout === 'function') st.srcTimer = rt.setTimeout(() => { st.src = null; st.srcTimer = null; }, SRC_KEEP_MS);
|
|
} catch (_) { st.srcTimer = null; }
|
|
}
|
|
|
|
// ------------------------------------------------------------ route matching
|
|
function compile(path) {
|
|
const keys = [];
|
|
const re = path.replace(/[.+*?^${}()[\]\\]/g, '\\$&').replace(/\/:([A-Za-z0-9_]+)/g, (_, k) => { keys.push(k); return '/([^/]+)'; });
|
|
return { re: new RegExp('^' + re + '(?:/(?=.)|/?$)', 'i'), reExact: new RegExp('^' + re + '/?$', 'i'), keys };
|
|
}
|
|
function match(pathname, path, exact) {
|
|
if (S && S.M && S.M.Router && S.M.Router.matchPath) {
|
|
try { return S.M.Router.matchPath(pathname, { path, exact: !!exact }); } catch (_) { /* fall through */ }
|
|
}
|
|
const c = compile(path);
|
|
const m = (exact ? c.reExact : c.re).exec(pathname);
|
|
if (!m) return null;
|
|
const params = {};
|
|
c.keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]); });
|
|
return { path, url: m[0].replace(/\/$/, '') || '/', isExact: pathname === m[0] || pathname === m[0] + '/', params };
|
|
}
|
|
|
|
// ------------------------------------------------------------ the route switch patch (SR §3.4, §3.5)
|
|
function switchRender(orig, self, props, second) {
|
|
const st = S;
|
|
if (!st || !st.ready || (!st.routes.size && !st.overrides.size) || !props || typeof props !== 'object') return orig.call(self, props, second);
|
|
let children;
|
|
try { children = buildChildren(st, props.children); } catch (e) { logE('route switch: our children failed, rendering Steam\'s', String((e && e.stack) || e).slice(0, 700)); return orig.call(self, props, second); }
|
|
return orig.call(self, Object.assign({}, props, { children }), second);
|
|
}
|
|
function buildChildren(st, ch) {
|
|
const { React } = st.M;
|
|
const { jsx } = st.M.jsx;
|
|
const loc = nav.location();
|
|
let list = [].concat(ch);
|
|
if (st.overrides.size) {
|
|
list = list.map((c) => {
|
|
const p = c && c.props && c.props.path;
|
|
const o = typeof p === 'string' ? st.overrides.get(p) : null;
|
|
if (!o) return c;
|
|
const orig = c.props.children;
|
|
const host = (steam, routeProps) => jsx(st.ui.ErrorBoundary, {
|
|
name: 'override ' + p, fallback: steam === undefined ? null : steam,
|
|
children: jsx(OverrideHost, { o, steam, loc, routeProps }),
|
|
});
|
|
// Steam's route wrappers (Pc, Jh) call a function child with the route props (achievements);
|
|
// keep that form so Steam's own children get them too.
|
|
if (typeof orig === 'function') return React.cloneElement(c, { children: (rp) => host(orig(rp), rp) });
|
|
return React.cloneElement(c, { children: host(orig, null) });
|
|
});
|
|
}
|
|
if (st.routes.size) {
|
|
const ours = [...st.routes.values()].sort((a, b) => b.path.length - a.path.length).map((r) => jsx(st.RouteType, {
|
|
path: r.path, exact: !!r.opts.exact,
|
|
children: jsx(st.ui.ErrorBoundary, { name: 'route ' + r.path, children: jsx(RouteHost, { r, loc }) }),
|
|
}, 'lgs-route:' + r.path));
|
|
list = ours.concat(list);
|
|
}
|
|
return list;
|
|
}
|
|
function OverrideHost(props) {
|
|
const { o, steam, loc, routeProps } = props;
|
|
const m = (routeProps && routeProps.match) || match(loc.pathname, o.path, false);
|
|
const out = o.fn(steam === undefined ? null : steam, { match: m, location: loc });
|
|
return out === undefined ? null : out;
|
|
}
|
|
function RouteHost(props) {
|
|
const { r, loc } = props;
|
|
return S.M.jsx.jsx(r.Component, { match: match(loc.pathname, r.path, r.opts.exact), location: loc });
|
|
}
|
|
function patchSwitch() {
|
|
let n = 0;
|
|
for (const f of routeSwitchFibers(S.M.RoutePaths)) {
|
|
if (f.type && f.type[MARK]) continue;
|
|
const orig = f.type;
|
|
const patched = function LgsRouteSwitch(props, second) { return switchRender(orig, this, props, second); };
|
|
patched[MARK] = 'switch';
|
|
patched.__lgsOrig = orig;
|
|
f.type = patched;
|
|
if (f.alternate) f.alternate.type = patched;
|
|
S.switchPatches.push({ fiber: f, alt: f.alternate, orig, patched });
|
|
n++;
|
|
}
|
|
return n;
|
|
}
|
|
function unpatchSwitch() {
|
|
let n = 0;
|
|
for (const p of S.switchPatches) {
|
|
for (const f of [p.fiber, p.alt, p.fiber.alternate]) if (f && f.type === p.patched) { f.type = p.orig; n++; }
|
|
}
|
|
S.switchPatches = [];
|
|
return n;
|
|
}
|
|
function switchChildren() {
|
|
const f = routeSwitchFibers(S.M.RoutePaths)[0];
|
|
return f ? [].concat(f.memoizedProps.children) : [];
|
|
}
|
|
function onOurPath(pathname) {
|
|
for (const r of S.routes.values()) if (match(pathname, r.path, r.opts.exact)) return true;
|
|
for (const o of S.overrides.values()) if (match(pathname, o.path, false)) return true;
|
|
return false;
|
|
}
|
|
|
|
// One history listener and one window listener while anything is registered: re-patch the switch
|
|
// before the router renders into our paths (SR §3.4 self-healing), re-apply byProps patches on
|
|
// bare function components that remounted, and attach optional patches still waiting for their target
|
|
// (a pending handle counts as a registration by itself: review R1 M2).
|
|
const pendingAny = () => { for (const h of S.handles.values()) if (h._pending) return true; return false; };
|
|
function syncWatchers() {
|
|
const any = S.routes.size || S.overrides.size || [...S.targets.values()].some((T) => T.needsRefresh) || pendingAny();
|
|
if (any && !S.unlisten) {
|
|
S.unlisten = inst().m_history.listen((loc) => {
|
|
try { if ((S.routes.size || S.overrides.size) && onOurPath(loc.pathname)) patchSwitch(); } catch (e) { logE('re-patch failed', errMsg(e)); }
|
|
refreshTargets('navigation');
|
|
scheduleLateAttach('navigation');
|
|
});
|
|
// New popup windows, and pooled popups shown again (P1: "+" list, bar menus), may mount patched
|
|
// components afresh.
|
|
const offs = [];
|
|
const onWin = (why) => () => { refreshTargets(why); scheduleLateAttach(why); };
|
|
try { if (rt.windows && typeof rt.windows.onAdd === 'function') offs.push(rt.windows.onAdd(onWin('window'))); } catch (_) { /* older runtime */ }
|
|
try { if (rt.windows && typeof rt.windows.onShow === 'function') offs.push(rt.windows.onShow(onWin('window shown'))); } catch (_) { /* older runtime */ }
|
|
S.winOff = offs.length ? () => { for (const off of offs) { try { off(); } catch (_) { /* gone */ } } } : null;
|
|
} else if (!any && S.unlisten) {
|
|
try { S.unlisten(); } catch (_) { /* gone */ }
|
|
S.unlisten = null;
|
|
if (S.winOff) { try { S.winOff(); } catch (_) { /* gone */ } S.winOff = null; }
|
|
}
|
|
if (!pendingAny()) cancelLateAttach();
|
|
if (!S.routes.size && !S.overrides.size && S.switchPatches.length) unpatchSwitch();
|
|
}
|
|
// The history event comes before React commits the new route, so a target that mounts with that route is
|
|
// not there yet when the listener runs. While an optional patch is pending, it is looked for again a few
|
|
// times after each navigation (or window shown, or its registration): a bounded series of scoped timers
|
|
// (P1's rt.setTimeout, cleared on removal), restarted by the next event and stopped once nothing is pending.
|
|
const LATE_ATTACH_MS = [120, 400, 1000, 2500];
|
|
function cancelLateAttach() {
|
|
if (!S || !S.lateOffs.length) return;
|
|
for (const off of S.lateOffs) { try { off(); } catch (_) { /* fired */ } }
|
|
S.lateOffs = [];
|
|
}
|
|
function scheduleLateAttach(why) {
|
|
if (!S || !S.ready || !pendingAny()) return;
|
|
cancelLateAttach();
|
|
const st = S;
|
|
for (const ms of LATE_ATTACH_MS) {
|
|
let off = null;
|
|
const fire = () => {
|
|
const i = st.lateOffs.indexOf(off);
|
|
if (i >= 0) st.lateOffs.splice(i, 1); // a fired timer is no longer scheduled (status().lateTimers)
|
|
if (S !== st) return;
|
|
attachPending(why + ' +' + ms + ' ms');
|
|
if (!pendingAny()) cancelLateAttach();
|
|
};
|
|
try { off = rt && typeof rt.setTimeout === 'function' ? rt.setTimeout(fire, ms) : null; } catch (_) { off = null; }
|
|
if (typeof off === 'function') st.lateOffs.push(off);
|
|
}
|
|
}
|
|
function attachPending(why) {
|
|
if (!S || !S.ready) return 0;
|
|
let n = 0;
|
|
for (const h of [...S.handles.values()]) {
|
|
if (!h._pending) continue;
|
|
try { if (h._attach(why)) n++; } catch (e) { logE(`patch ${h.id}: late attach failed (${why})`, errMsg(e)); }
|
|
}
|
|
return n;
|
|
}
|
|
function rerenderIfOn(test) {
|
|
let cur; try { cur = route(); } catch (_) { return; }
|
|
if (test(cur)) { try { inst().Navigate(cur, true); } catch (e) { logE('re-render navigation failed', errMsg(e)); } }
|
|
}
|
|
|
|
const routes = {
|
|
add(path, Component, opts) {
|
|
need();
|
|
opts = Object.assign({}, opts);
|
|
if (typeof path !== 'string' || !path.startsWith(PREFIX) || path.length <= PREFIX.length) throw new Error(`lgs-react: routes.add: ${path} must be under ${PREFIX} (SR §3.4)`);
|
|
if (typeof Component !== 'function' && !(Component && typeof Component === 'object')) throw new Error('lgs-react: routes.add: Component must be a component');
|
|
if (S.routes.has(path)) throw new Error(`lgs-react: routes.add: ${path} is already added`);
|
|
if (!S.switchPatches.length && !patchSwitch()) throw new Error('lgs-react: route switch not found; nothing added');
|
|
const r = { path, Component, opts, owner: opts.owner || null };
|
|
S.routes.set(path, r);
|
|
syncWatchers();
|
|
rerenderIfOn((cur) => !!match(cur, path, opts.exact));
|
|
const st = S;
|
|
return {
|
|
path,
|
|
remove() {
|
|
if (S !== st || !S.routes.has(path) || S.routes.get(path) !== r) return false;
|
|
let on = false; try { on = !!match(route(), path, opts.exact); } catch (_) { /* no window */ }
|
|
S.routes.delete(path);
|
|
if (on) { try { inst().Navigate('/library/home', true); } catch (_) { /* gone */ } }
|
|
syncWatchers();
|
|
return true;
|
|
},
|
|
};
|
|
},
|
|
override(path, fn, opts) {
|
|
need();
|
|
opts = Object.assign({}, opts);
|
|
if (typeof path !== 'string' || !path) throw new Error('lgs-react: routes.override: path must be a string');
|
|
if (typeof fn !== 'function') throw new Error('lgs-react: routes.override: fn must be a function');
|
|
if (S.overrides.has(path)) throw new Error(`lgs-react: routes.override: ${path} is already overridden`);
|
|
if (!switchChildren().some((c) => c && c.props && c.props.path === path)) throw new Error(`lgs-react: routes.override: Steam has no top-level route ${path}`);
|
|
if (!S.switchPatches.length && !patchSwitch()) throw new Error('lgs-react: route switch not found; nothing overridden');
|
|
const o = { path, fn, opts, owner: opts.owner || null };
|
|
S.overrides.set(path, o);
|
|
syncWatchers();
|
|
rerenderIfOn((cur) => !!match(cur, path, false));
|
|
const st = S;
|
|
return {
|
|
path,
|
|
remove() {
|
|
if (S !== st || S.overrides.get(path) !== o) return false;
|
|
S.overrides.delete(path);
|
|
syncWatchers();
|
|
rerenderIfOn((cur) => !!match(cur, path, false));
|
|
return true;
|
|
},
|
|
};
|
|
},
|
|
list() { return { added: S ? [...S.routes.keys()] : [], overridden: S ? [...S.overrides.keys()] : [] }; },
|
|
get paths() { return need().RoutePaths; },
|
|
};
|
|
|
|
// ------------------------------------------------------------ patches by props shape (SR §3.2)
|
|
// Our trampolines keep __lgsOrig for life (an inert one, with no layers left, has an empty MARK).
|
|
const baseFn = (f) => (f && f.__lgsOrig ? f.__lgsOrig : f);
|
|
// What to patch for a fiber: a shared holder (memo .type, forwardRef .render, class prototype.render)
|
|
// and/or the live fibers' own type.
|
|
function describe(f) {
|
|
const t = f.type;
|
|
switch (f.tag) {
|
|
case 0: {
|
|
if (typeof t !== 'function') return null;
|
|
const p = f.return;
|
|
if (p && p.tag === 14 && p.type && baseFn(p.type.type) === baseFn(t)) return { kind: 'memo', holder: p.type, key: 'type', orig: baseFn(t), fiberType: true };
|
|
return { kind: 'fn', holder: null, key: null, orig: baseFn(t), fiberType: true };
|
|
}
|
|
case 15: {
|
|
if (typeof t !== 'function') return null;
|
|
const h = f.elementType && typeof f.elementType === 'object' ? f.elementType : null;
|
|
return { kind: 'simplememo', holder: h, key: h ? 'type' : null, orig: baseFn(t), fiberType: true };
|
|
}
|
|
case 14: {
|
|
const inner = t && t.type;
|
|
if (typeof inner === 'function') return { kind: 'memo', holder: t, key: 'type', orig: baseFn(inner), fiberType: true };
|
|
if (inner && typeof inner.render === 'function') return { kind: 'fwd', holder: inner, key: 'render', orig: baseFn(inner.render), fiberType: false };
|
|
return null;
|
|
}
|
|
case 11:
|
|
if (t && typeof t.render === 'function') return { kind: 'fwd', holder: t, key: 'render', orig: baseFn(t.render), fiberType: false };
|
|
return null;
|
|
case 1:
|
|
if (typeof t === 'function' && t.prototype && typeof t.prototype.render === 'function') return { kind: 'class', holder: t.prototype, key: 'render', orig: baseFn(t.prototype.render), fiberType: false };
|
|
return null;
|
|
default: return null;
|
|
}
|
|
}
|
|
const PATCH_TAGS = new Set([0, 1, 11, 14, 15]);
|
|
function matchTargets(pred, tags) {
|
|
const found = new Map(); // orig -> desc
|
|
walk((f) => {
|
|
if (!(tags || PATCH_TAGS).has(f.tag)) return;
|
|
const p = f.memoizedProps;
|
|
if (!p || typeof p !== 'object') return;
|
|
let ok = false; try { ok = !!pred(p, f); } catch (_) { ok = false; }
|
|
if (!ok) return;
|
|
const d = describe(f);
|
|
if (d && !found.has(d.orig)) found.set(d.orig, d);
|
|
});
|
|
return [...found.values()];
|
|
}
|
|
function swapLiveFibers(T) {
|
|
let n = 0;
|
|
const fix = (f) => {
|
|
if (!f || (f.tag !== 0 && f.tag !== 15)) return false;
|
|
let changed = false;
|
|
if (typeof f.type === 'function' && baseFn(f.type) === T.orig && f.type !== T.installed) { f.type = T.installed; changed = true; }
|
|
// A bare function mounted while patched has our trampoline as elementType (jsx substitution);
|
|
// give it the original back on removal so Steam's next element reuses the fiber.
|
|
if (!T.layers.length && T.tramp && f.elementType === T.tramp) { f.elementType = T.orig; changed = true; }
|
|
return changed;
|
|
};
|
|
walk((f) => { if (fix(f)) n++; fix(f.alternate); });
|
|
T.live = n;
|
|
return n;
|
|
}
|
|
// One stable trampoline per target for its whole life, so the type React sees never changes while
|
|
// layers come and go (no remounts); it calls the current composition of the layers.
|
|
function installTarget(T) {
|
|
let fn = T.orig;
|
|
for (const L of T.layers) {
|
|
const w = L.wrap(fn);
|
|
if (typeof w !== 'function') throw new Error(`lgs-react: patch ${L.id}: wrap() must return a function`);
|
|
fn = w;
|
|
}
|
|
T.composed = fn;
|
|
if (!T.tramp) {
|
|
const tramp = function LgsPatched() { return T.composed.apply(this, arguments); };
|
|
tramp.__lgsOrig = T.orig;
|
|
if (T.orig.displayName) tramp.displayName = T.orig.displayName;
|
|
T.tramp = tramp;
|
|
}
|
|
T.tramp[MARK] = T.layers.map((L) => L.id).join(',');
|
|
T.installed = T.layers.length ? T.tramp : T.orig;
|
|
if (T.holder) { T.holder[T.key] = T.installed; S.touched.add(T); }
|
|
if (T.kind === 'fn') {
|
|
if (T.layers.length) S.typeMap.set(T.orig, T.tramp); else S.typeMap.delete(T.orig);
|
|
syncJsxHook();
|
|
}
|
|
if (T.fiberType) swapLiveFibers(T);
|
|
}
|
|
// A bare function component has no shared holder, and a remount would bring its original type back.
|
|
// While one is patched, Steam's element factories (jsx, jsxs, createElement) substitute our trampoline
|
|
// for it: one Map lookup per element, nothing else. Removed when the last such patch goes.
|
|
function syncJsxHook() {
|
|
const want = S.typeMap.size > 0;
|
|
if (want && !S.jsxHook) {
|
|
const J = S.M.jsx, R = S.M.React, map = S.typeMap;
|
|
const oj = J.jsx, ojs = J.jsxs, oce = R.createElement;
|
|
const sub = (t) => (typeof t === 'function' ? (map.get(t) || t) : t);
|
|
const nj = function (t, p, k) { return oj(sub(t), p, k); };
|
|
const njs = function (t, p, k) { return ojs(sub(t), p, k); };
|
|
const nce = function (t) { if (typeof t === 'function' && map.has(t)) { const a = Array.prototype.slice.call(arguments); a[0] = map.get(t); return oce.apply(this, a); } return oce.apply(this, arguments); };
|
|
for (const f of [nj, njs, nce]) f[MARK] = 'jsx';
|
|
J.jsx = nj; J.jsxs = njs; R.createElement = nce;
|
|
S.jsxHook = { J, R, oj, ojs, oce, nj, njs, nce };
|
|
} else if (!want && S.jsxHook) {
|
|
unhookJsx();
|
|
}
|
|
}
|
|
function unhookJsx() {
|
|
const h = S && S.jsxHook;
|
|
if (!h) return 0;
|
|
let n = 0;
|
|
if (h.J.jsx === h.nj) { h.J.jsx = h.oj; n++; }
|
|
if (h.J.jsxs === h.njs) { h.J.jsxs = h.ojs; n++; }
|
|
if (h.R.createElement === h.nce) { h.R.createElement = h.oce; n++; }
|
|
S.jsxHook = null;
|
|
return n;
|
|
}
|
|
function refreshTargets(why) {
|
|
if (!S || !S.ready) return;
|
|
for (const T of S.targets.values()) {
|
|
if (T.needsRefresh && T.layers.length) { try { swapLiveFibers(T); } catch (e) { logE('patch refresh failed (' + why + ')', errMsg(e)); } }
|
|
}
|
|
attachPending(why);
|
|
}
|
|
|
|
const patch = {
|
|
byProps(id, predicate, wrap, opts) {
|
|
need();
|
|
opts = Object.assign({ max: 1 }, opts);
|
|
if (typeof id !== 'string' || !id) throw new Error('lgs-react: patch.byProps: id must be a string');
|
|
if (S.handles.has(id)) throw new Error(`lgs-react: patch.byProps: ${id} already exists`);
|
|
if (typeof predicate !== 'function') throw new Error(`lgs-react: patch.byProps ${id}: predicate must be a function (is it one of patch.targets that is still null?)`);
|
|
if (typeof wrap !== 'function') throw new Error(`lgs-react: patch.byProps ${id}: wrap must be a function`);
|
|
const tags = opts.tags ? new Set(opts.tags) : null;
|
|
const layer = { id, wrap };
|
|
const h = {
|
|
id, count: 0, live: 0, kinds: [], _pending: false, _targets: [],
|
|
_attach(late) {
|
|
const found = matchTargets(predicate, tags);
|
|
if (found.length > opts.max) throw new Error(`lgs-react: patch.byProps ${id}: ${found.length} components match (max ${opts.max}); tighten the predicate`);
|
|
if (!found.length) {
|
|
if (opts.optional) {
|
|
h._pending = true;
|
|
syncWatchers();
|
|
// The target may be mounting right now (registered during a navigation): look again shortly.
|
|
if (!late) scheduleLateAttach('registration');
|
|
return 0;
|
|
}
|
|
throw new Error(`lgs-react: patch.byProps ${id}: nothing matches (fail closed)`);
|
|
}
|
|
// Validate every wrap before touching anything.
|
|
for (const d of found) { const w = wrap(d.orig); if (typeof w !== 'function') throw new Error(`lgs-react: patch.byProps ${id}: wrap() must return a function`); }
|
|
for (const d of found) {
|
|
let T = S.targets.get(d.orig);
|
|
if (!T) { T = Object.assign({ layers: [], installed: d.orig, live: 0 }, d); T.needsRefresh = !T.holder && T.fiberType; S.targets.set(d.orig, T); }
|
|
T.layers.push(layer);
|
|
installTarget(T);
|
|
h._targets.push(T);
|
|
}
|
|
h._pending = false;
|
|
h.count = h._targets.length;
|
|
h.kinds = h._targets.map((T) => T.kind);
|
|
h.live = h._targets.reduce((a, T) => a + (T.live || 0), 0);
|
|
if (late) { h.attachedLate = String(late); logI(`patch ${id}: attached late (${late})`, { count: h.count }); }
|
|
syncWatchers();
|
|
return h.count;
|
|
},
|
|
refresh() { refreshTargets('manual'); h.live = h._targets.reduce((a, T) => a + (T.live || 0), 0); return h.live; },
|
|
remove() {
|
|
if (!S || S.handles.get(id) !== h) return false;
|
|
for (const T of h._targets) {
|
|
T.layers = T.layers.filter((L) => L !== layer);
|
|
try { installTarget(T); } catch (e) { logE(`patch ${id}: restore failed`, errMsg(e)); }
|
|
if (!T.layers.length) S.targets.delete(T.orig);
|
|
}
|
|
h._targets = [];
|
|
h.count = 0;
|
|
S.handles.delete(id);
|
|
syncWatchers();
|
|
return true;
|
|
},
|
|
};
|
|
S.handles.set(id, h);
|
|
try { h._attach(false); } catch (e) { S.handles.delete(id); throw e; }
|
|
return h;
|
|
},
|
|
// Named predicates for known targets (contract §5.2). null = not mapped yet.
|
|
targets: {
|
|
plusButton: (p) => { const k = Object.keys(p); return k.length === 1 && k[0] === 'allowLaunchProgram'; },
|
|
pagedSettings: (p) => {
|
|
if (!Array.isArray(p.pages)) return false;
|
|
let sys = null; try { sys = S.M.RoutePaths.Settings.System(); } catch (_) { return false; }
|
|
return p.pages.some((pg) => pg && pg.route === sys);
|
|
},
|
|
// The bar's status pill (Quick Access button; CC3): a function component with no props whose first
|
|
// DOM node is %{QuickAccessButton}. It renders Steam's bar popup button ({refBarPopupHandle,
|
|
// popupContents, onPopupVisibilityChange, tooltip, ...}); a wrap may change that element's props.
|
|
statusPill: (p, f) => {
|
|
if (!f || f.tag !== 0 || Object.keys(p).length !== 0) return false;
|
|
const el = firstHost(f);
|
|
if (!el || !el.classList) return false;
|
|
const cls = S && S.M && S.M.BarClasses ? S.M.BarClasses.QuickAccessButton : null;
|
|
if (cls) return el.classList.contains(cls);
|
|
let sel = null; try { sel = rt.sel('%{QuickAccessButton}'); } catch (_) { return false; }
|
|
return !!el.matches(sel);
|
|
},
|
|
// The dashboard bar (the glass tuner, rt 33-tune.js): the memo with no props that renders
|
|
// %{PopupBody>DashboardBar} around the Home circle (Bookend) and the main %{BarSurface}; its source
|
|
// names the bar's tab lists (probed on build 11094443: one match).
|
|
dashboardBar: (p, f) => !!f && (f.tag === 15 || f.tag === 0) && Object.keys(p).length === 0
|
|
&& fnSrc(f.type).includes('rgBarTabsForMainSurface'),
|
|
// The game page's action row (GP §4.12): the forwardRef that renders %{AppButtons} (Play, Steam
|
|
// Input, the gear) as a Focusable row inside its output. Its parent PlaySection has the same props
|
|
// plus onNav; the row's own source names its CSS module class ActionRow.
|
|
appButtons: (p, f) => !!f && f.tag === 11 && 'overview' in p && 'details' in p && 'onGameInfoToggle' in p
|
|
&& 'bShowingLaunchDetails' in p && !('onNav' in p) && fnSrc(f.type).includes('.ActionRow'),
|
|
},
|
|
list() { return S ? [...S.handles.values()].map((h) => ({ id: h.id, count: h.count, live: h.live, kinds: h.kinds, pending: h._pending, late: h.attachedLate || null })) : []; },
|
|
// Force one render of every live instance a handle patched, so the patch shows at once.
|
|
// class: forceUpdate(); mobx observer: the observer's own path (new stateVersion + onStoreChange,
|
|
// what mobx does when an observable it reads changes). A plain function component cannot be forced
|
|
// from outside; it is counted as skipped and shows the patch at its next own render.
|
|
rerender(h) {
|
|
need();
|
|
if (!h || !Array.isArray(h._targets)) throw new Error('lgs-react: patch.rerender: not a patch handle');
|
|
const out = { forced: 0, skipped: 0, how: {} };
|
|
const mine = (f, T) => {
|
|
switch (T.kind) {
|
|
case 'fwd': return f.tag === 11 && f.type === T.holder;
|
|
case 'class': return f.tag === 1 && f.type && f.type.prototype === T.holder;
|
|
default: return (f.tag === 0 || f.tag === 15) && typeof f.type === 'function' && baseFn(f.type) === T.orig;
|
|
}
|
|
};
|
|
const fibers = [];
|
|
walk((f) => { for (const T of h._targets) if (mine(f, T)) { fibers.push(f); break; } });
|
|
for (const f of fibers) {
|
|
const how = forceFiber(f);
|
|
if (how) { out.forced++; out.how[how] = (out.how[how] || 0) + 1; } else out.skipped++;
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
function forceFiber(f) {
|
|
try {
|
|
if (f.tag === 1 && f.stateNode && typeof f.stateNode.forceUpdate === 'function') { f.stateNode.forceUpdate(); return 'class'; }
|
|
for (let hk = f.memoizedState, i = 0; hk && i < 80; hk = hk.next, i++) {
|
|
const s = hk.memoizedState;
|
|
const adm = s && typeof s === 'object' ? s.current : null;
|
|
if (adm && typeof adm === 'object' && typeof adm.onStoreChange === 'function' && 'stateVersion' in adm && adm.reaction) {
|
|
adm.stateVersion = Symbol('lgs-rerender');
|
|
adm.onStoreChange();
|
|
return 'observer';
|
|
}
|
|
}
|
|
} catch (e) { logE('rerender failed', errMsg(e)); }
|
|
return null;
|
|
}
|
|
|
|
// ------------------------------------------------------------ localization (PLAN §1.15)
|
|
// Steam's LocalizeString returns the raw string ("%1$s mins played all time") and ignores extra
|
|
// arguments; loc() fills %1$s … %9$s from args itself (REQ C2a->P2 #11). A placeholder without an
|
|
// argument is left as it is.
|
|
function loc(token, ...args) {
|
|
try {
|
|
const LM = window.LocalizationManager;
|
|
if (!LM || typeof token !== 'string') return null;
|
|
let s = LM.LocalizeString(token);
|
|
if (typeof s !== 'string' || !s || s === token) return null;
|
|
if (args.length) s = s.replace(/%(\d)\$s/g, (m, n) => (args[n - 1] != null ? String(args[n - 1]) : m));
|
|
return s;
|
|
} catch (_) { return null; }
|
|
}
|
|
function lang() {
|
|
try {
|
|
const LM = window.LocalizationManager;
|
|
const l = LM.GetPreferredLocales ? LM.GetPreferredLocales() : null;
|
|
return String((Array.isArray(l) ? l[0] : l) || 'english');
|
|
} catch (_) { return 'english'; }
|
|
}
|
|
const isEnglish = () => /^en/i.test(lang());
|
|
// Steam's string for token, else the English text only when the UI language is English, else null.
|
|
const text = (token, english) => (token && loc(token)) || (isEnglish() ? (english || null) : null);
|
|
|
|
// ------------------------------------------------------------ UI helpers
|
|
function makeUI(M) {
|
|
const React = M.React;
|
|
const { jsx, jsxs } = M.jsx;
|
|
class ErrorBoundary extends React.Component {
|
|
constructor(p) { super(p); this.state = { err: null }; }
|
|
static getDerivedStateFromError(err) { return { err }; }
|
|
componentDidCatch(err) {
|
|
logE('render error in ' + (this.props.name || 'view'), String((err && err.stack) || err).slice(0, 600));
|
|
if (typeof this.props.onError === 'function') { try { this.props.onError(err); } catch (_) { /* caller's handler */ } }
|
|
}
|
|
render() {
|
|
if (!this.state.err) return this.props.children === undefined ? null : this.props.children;
|
|
if (this.props.fallback !== undefined) return this.props.fallback;
|
|
return jsx(FailPage, { name: this.props.name, err: this.state.err });
|
|
}
|
|
}
|
|
// Our error page: Steam's header and footer stay; Back is focused (SR §7). The root spans the page
|
|
// (width 100%, 48 px side insets) so the title and the capsule sit on the window's centre line on any
|
|
// route (VP P-30, P-36; review R1 M3). Type, colour and sizes come from P4's tokens, with the token
|
|
// values as fallbacks for the theme-off state: title = Title 2, Back = a 60 px capsule in Headline.
|
|
const FAIL_ROOT = {
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '32px',
|
|
width: '100%', height: '100%', boxSizing: 'border-box', padding: '0 48px', textAlign: 'center',
|
|
color: 'var(--lgs-text-1, rgb(255 255 255 / .96))',
|
|
};
|
|
const FAIL_TITLE = { font: 'var(--lgs-type-title2, 700 30px/1.25 "Motiva Sans", sans-serif)', letterSpacing: 'var(--lgs-track-title, -.01em)', maxWidth: '100%' };
|
|
const FAIL_BACK = {
|
|
width: 'auto', minWidth: '240px', minHeight: 'var(--lgs-btn, 60px)', padding: '0 48px',
|
|
borderRadius: 'calc(var(--lgs-btn, 60px) / 2)', font: 'var(--lgs-type-headline, 600 24px/1.30 "Motiva Sans", sans-serif)',
|
|
};
|
|
function FailPage() {
|
|
const back = () => nav.back();
|
|
const title = text(null, 'This view could not be shown');
|
|
return jsx(M.GamepadPage, { scrollable: false, children: jsxs(M.Focusable, {
|
|
className: 'lgs-react-fail', onCancel: back, onCancelActionDescription: text('#Button_Back', 'Back'),
|
|
style: FAIL_ROOT,
|
|
children: [
|
|
title ? jsx('div', { className: 'lgs-react-fail-title', style: FAIL_TITLE, children: title }, 't') : null,
|
|
// A visionOS capsule sized to its label (60 tall, at least 240 wide), not a full-width bar.
|
|
jsx(M.DialogButton, { className: 'lgs-react-fail-back', autoFocus: true, onClick: back, style: FAIL_BACK, children: text('#Button_Back', 'Back') }, 'b'),
|
|
] }) });
|
|
}
|
|
function Page(props) {
|
|
const { onCancel, className, pageProps, rootProps, children, name } = props;
|
|
const cancel = onCancel || (() => nav.back());
|
|
return jsx(ErrorBoundary, { name: name || 'page', children: jsx(M.GamepadPage, Object.assign({ scrollable: false }, pageProps, {
|
|
children: jsx(M.Focusable, Object.assign({}, rootProps, {
|
|
className, onCancel: cancel, onCancelActionDescription: text('#Button_Back', 'Back'), children,
|
|
})),
|
|
})) });
|
|
}
|
|
function Style(props) { return jsx('style', { children: props.css }); }
|
|
return { ErrorBoundary, FailPage, Page, Style };
|
|
}
|
|
// ------------------------------------------------------------ focus back after a modal (D-P2-7, RX-3)
|
|
const NULL_TREE = /null-tree/i;
|
|
const RESTORE_MS = [50, 200, 500, 1000];
|
|
function React_isElement(M, x) { try { return !!M.React.isValidElement(x); } catch (_) { return false; } }
|
|
function activeNavTree() {
|
|
try {
|
|
const F = window.FocusNavController;
|
|
if (typeof F.GetActiveNavTree === 'function') return F.GetActiveNavTree() || null;
|
|
const c = F.GetActiveContext();
|
|
return (c && c.m_LastActiveNavTree) || null;
|
|
} catch (_) { return null; }
|
|
}
|
|
const treeLive = (t) => { try { const el = t && t.m_Root && t.m_Root.m_element; return !!(el && el.isConnected); } catch (_) { return false; } };
|
|
const treeHasNode = (t) => { try { return !!(typeof t.GetLastFocusedNode === 'function' ? t.GetLastFocusedNode() : t.m_lastFocusNode); } catch (_) { return true; } };
|
|
// The nav tree that has focus when a modal opens, and how to give focus back to it when the modal is gone.
|
|
function rememberFocus() {
|
|
const tree = activeNavTree();
|
|
if (!tree || NULL_TREE.test(String(tree.id || '')) || typeof tree.TakeFocus !== 'function') return null;
|
|
let r0 = null; try { r0 = route(); } catch (_) { return null; }
|
|
const st = S;
|
|
let started = false;
|
|
return {
|
|
restore() {
|
|
if (started) return; // once per modal
|
|
started = true;
|
|
// The modal's own tree, still active (and its DOM still connected) while Steam closes it.
|
|
const closing = activeNavTree();
|
|
let i = 0, acted = 0;
|
|
const attempt = () => {
|
|
if (S !== st || !st) return;
|
|
try {
|
|
if (route() !== r0 || !treeLive(tree)) return; // the modal navigated away, or the page is gone
|
|
const cur = activeNavTree();
|
|
const lost = !cur || NULL_TREE.test(String(cur.id || '')) || (cur === tree && !treeHasNode(tree));
|
|
if (lost) {
|
|
// TakeFocus() alone does nothing while the context's active tree is vr-null-tree: activate the
|
|
// page's tree first (Steam's own Activate: OnGamepadNavigationTreeActivated), then focus its last
|
|
// node. Checked again at the next attempt; at most two tries.
|
|
if (acted < 2) {
|
|
acted++;
|
|
if (typeof tree.Activate === 'function') tree.Activate();
|
|
tree.TakeFocus();
|
|
logI('modal closed: focus given back to ' + String(tree.id || 'the page') + ' (try ' + acted + ')');
|
|
}
|
|
} else if (cur === tree) {
|
|
return; // focus is back (Steam's or ours)
|
|
} else if (cur !== closing && treeLive(cur)) {
|
|
return; // focus is somewhere else that is live (another modal)
|
|
}
|
|
} catch (e) { logE('modal focus restore failed', errMsg(e)); return; }
|
|
if (i < RESTORE_MS.length) schedule();
|
|
};
|
|
const schedule = () => {
|
|
const ms = RESTORE_MS[i++] - (i > 1 ? RESTORE_MS[i - 2] : 0);
|
|
try { if (rt && typeof rt.setTimeout === 'function') rt.setTimeout(attempt, ms); } catch (_) { /* removed */ }
|
|
};
|
|
schedule();
|
|
},
|
|
};
|
|
}
|
|
// Wraps the modal's content: forwards every prop Steam passes to the caller's element, with Steam's
|
|
// closeModal wrapped so the focus series starts when the modal closes (Steam keeps a closed modal mounted
|
|
// for a while, so its unmount is too late: seen 25 s at 13:20). Steam's own modals (ConfirmModal's OK and
|
|
// Cancel, B through onCancel) close through this prop.
|
|
function ModalFocusHost(props) {
|
|
const { inner, back, ...rest } = props;
|
|
const R = S && S.M ? S.M.React : null;
|
|
if (!R) return inner;
|
|
if (typeof rest.closeModal === 'function') {
|
|
const close = rest.closeModal;
|
|
rest.closeModal = function lgsCloseModal() {
|
|
try { back.restore(); } catch (_) { /* module gone */ }
|
|
return close.apply(this, arguments);
|
|
};
|
|
}
|
|
return R.cloneElement(inner, rest);
|
|
}
|
|
function trackMenu(m) {
|
|
if (!m) return m;
|
|
S.menus.add(m);
|
|
try { if (typeof m.SetOnHideCallback === 'function') { const st = S; m.SetOnHideCallback(() => { if (st.menus) st.menus.delete(m); }); } } catch (_) { /* older client */ }
|
|
return m;
|
|
}
|
|
const ui = {
|
|
get Page() { need(); return S.ui.Page; },
|
|
get ErrorBoundary() { need(); return S.ui.ErrorBoundary; },
|
|
get Style() { need(); return S.ui.Style; },
|
|
style(css) { need(); return S.M.jsx.jsx(S.ui.Style, { css }); },
|
|
loc, lang, text,
|
|
menu(content, anchor, opts) {
|
|
const M = need();
|
|
opts = opts || {};
|
|
if (!M.showContextMenu || !M.Menu) throw new Error('lgs-react: Steam context menu components not found');
|
|
const { jsx } = M.jsx;
|
|
const { Menu, MenuItem, MenuSeparator } = M.Menu;
|
|
let el = content;
|
|
if (Array.isArray(content)) {
|
|
el = jsx(Menu, { label: opts.label, children: content.map((it, i) => {
|
|
if (!it) return null;
|
|
if (it === 'separator' || it.separator) return MenuSeparator ? jsx(MenuSeparator, {}, 'sep' + i) : null;
|
|
const p = { onSelected: it.onSelected, disabled: !!it.disabled, children: it.label };
|
|
if (it.tone) p.tone = it.tone;
|
|
if (it.className) p.className = it.className;
|
|
return jsx(MenuItem, p, it.key || 'item' + i);
|
|
}) });
|
|
}
|
|
return trackMenu(M.showContextMenu(el, anchor || mainWin().document.body, opts.position || {}));
|
|
},
|
|
modal(element, opts) {
|
|
const M = need();
|
|
opts = opts || {};
|
|
if (!M.showModal) throw new Error('lgs-react: Steam showModal not found');
|
|
const st = S;
|
|
const win = opts.window || mainWin();
|
|
let el = element;
|
|
// Focus goes back to the control that had it (D-P2-7): Steam clears the page tree's last focus node
|
|
// when the modal opens and, in VR, parks focus in `vr-null-tree` when it closes, where
|
|
// FocusApplicationRoot() reactivates the page tree with no node. When our modal's content unmounts and
|
|
// focus is still lost on the same route, the page tree's own TakeFocus() restores it (live: the same
|
|
// poster; RX-3). opts.restoreFocus: false opts out.
|
|
if (opts.restoreFocus !== false && React_isElement(M, element)) {
|
|
const back = rememberFocus();
|
|
if (back) el = M.jsx.jsx(ModalFocusHost, { inner: element, back });
|
|
}
|
|
return Promise.resolve(M.showModal(el, win, opts.options)).then((m) => { if (m && st.modals) st.modals.add(m); return m; });
|
|
},
|
|
confirm(o) {
|
|
const M = need();
|
|
if (!M.ConfirmModal) throw new Error('lgs-react: Steam ConfirmModal not found');
|
|
o = o || {};
|
|
const p = { strTitle: o.title, strDescription: o.description, onOK: o.onOK, onCancel: o.onCancel };
|
|
if (o.okText) p.strOKButtonText = o.okText;
|
|
if (o.cancelText) p.strCancelButtonText = o.cancelText;
|
|
if (o.middleText) { p.strMiddleButtonText = o.middleText; p.onMiddleButton = o.onMiddle; }
|
|
if (o.alert) p.bAlertDialog = true;
|
|
return ui.modal(M.jsx.jsx(M.ConfirmModal, p), o);
|
|
},
|
|
};
|
|
|
|
// ------------------------------------------------------------ data (read-only, SR §3.7)
|
|
function overview(x) {
|
|
if (x && typeof x === 'object') return x;
|
|
try { return window.appStore.GetAppOverviewByAppID(Number(x)) || null; } catch (_) { return null; }
|
|
}
|
|
function isLiquidGlass(a) {
|
|
return !!a && ((typeof a.strExePath === 'string' && /\/glass-shell\/device\/lgs$/.test(a.strExePath)) || (a.strAppName === 'LiquidAss' || a.strAppName === 'Liquid Glass'));
|
|
}
|
|
function filterPrograms(list, devMode, opts) {
|
|
if (!Array.isArray(list)) return [];
|
|
const F = S && S.M && S.M.LaunchFilters;
|
|
const ex = F ? [...F.always, ...(devMode ? [] : F.devOnly)] : ['steam', 'vrurlhandler'];
|
|
const allow = F ? F.allow : [];
|
|
const out = [];
|
|
for (const a of list) {
|
|
if (!a) continue;
|
|
const keep = allow.includes(a.strAppName) || !ex.some((x) => a.strExePath === x || String(a.strExePath || '').endsWith('/' + x));
|
|
if (!keep) continue;
|
|
const lg = isLiquidGlass(a);
|
|
if (lg && !(opts && opts.includeLiquidGlass)) continue;
|
|
out.push({
|
|
key: a.strCmdline, name: a.strAppName, exePath: a.strExePath, cmdline: a.strCmdline, args: a.strArguments,
|
|
iconUrl: a.strIconDataBase64 ? 'data:image/png;base64,' + a.strIconDataBase64 : null,
|
|
isApplication: !!a.bIsApplication, isLiquidGlass: lg, raw: a,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
const data = {
|
|
stores: () => ({ collectionStore: window.collectionStore, appStore: window.appStore, urlStore: window.urlStore }),
|
|
app: (appid) => overview(appid),
|
|
collection(id) { try { return window.collectionStore.GetCollection(id) || null; } catch (_) { return null; } },
|
|
installedGames(opts) {
|
|
opts = opts || {};
|
|
const cs = window.collectionStore;
|
|
const apps = (cs && cs.localGamesCollection ? cs.localGamesCollection.allApps : []).slice();
|
|
if (opts.sort === 'name') apps.sort((a, b) => String(a.display_name).localeCompare(String(b.display_name)));
|
|
else apps.sort((a, b) => (b.rt_last_time_played || 0) - (a.rt_last_time_played || 0));
|
|
return (opts.limit ? apps.slice(0, opts.limit) : apps).map((ov) => ({ appid: ov.appid, name: ov.display_name, overview: ov }));
|
|
},
|
|
recentGames(limit) {
|
|
const c = window.collectionStore && window.collectionStore.recentAppsCollection;
|
|
const apps = c ? (c.visibleApps || c.allApps || []).slice() : [];
|
|
return (limit ? apps.slice(0, limit) : apps).map((ov) => ({ appid: ov.appid, name: ov.display_name, overview: ov }));
|
|
},
|
|
art(x) {
|
|
const ov = overview(x);
|
|
const out = { portrait: [], hero: null, logo: null, header: null, icon: null, custom: { hero: [], logo: [], landscape: [], portrait: [] } };
|
|
if (!ov) return out;
|
|
const as = window.appStore, us = window.urlStore;
|
|
try { out.portrait.push(...[].concat(as.GetCachedVerticalCapsuleURL(ov) || [])); } catch (_) { /* older client */ }
|
|
try { const u = as.GetVerticalCapsuleURLForApp(ov); if (u) out.portrait.push(u); } catch (_) { /* older client */ }
|
|
out.portrait = out.portrait.filter(Boolean);
|
|
const b = (file) => { try { return us.BuildCachedLibraryAssetURL(ov.appid, file, ov.local_cache_version) || null; } catch (_) { return null; } };
|
|
out.hero = b('library_hero.jpg'); out.logo = b('logo.png'); out.header = b('header.jpg');
|
|
try { out.icon = as.GetIconURLForApp(ov) || null; } catch (_) { /* none */ }
|
|
const c = (fn) => { try { const v = as[fn] && as[fn](ov); return Array.isArray(v) ? v.filter(Boolean) : (v ? [v] : []); } catch (_) { return []; } };
|
|
out.custom = { hero: c('GetCustomHeroImageURLs'), logo: c('GetCustomLogoImageURLs'), landscape: c('GetCustomLandcapeImageURLs'), portrait: c('GetCustomVerticalCapsuleImageURLs') };
|
|
return out;
|
|
},
|
|
// Hooks: call only inside a component rendered by a route, override or patch of this module.
|
|
useDevMode() {
|
|
const M = need();
|
|
const v = M.LaunchFilters && M.LaunchFilters.useDevMode ? M.LaunchFilters.useDevMode() : [true];
|
|
return !!(Array.isArray(v) ? v[0] : v);
|
|
},
|
|
useNonSteamApps(opts) {
|
|
const M = need();
|
|
opts = opts || {};
|
|
const dev = data.useDevMode();
|
|
const raw = M.useNonSteamApps ? M.useNonSteamApps(true, opts.enabled !== false, opts.refresh || 0) : undefined;
|
|
const lg = !!opts.includeLiquidGlass;
|
|
return M.React.useMemo(() => (Array.isArray(raw) ? filterPrograms(raw, dev, { includeLiquidGlass: lg }) : null), [raw, dev, lg]);
|
|
},
|
|
filterPrograms: (list, devMode, opts) => filterPrograms(list, devMode, opts),
|
|
isLiquidGlass,
|
|
};
|
|
|
|
// ------------------------------------------------------------ actions (HA §14, PLAN §2.3 P2, RX-7)
|
|
function eventWindow(ev) {
|
|
try {
|
|
const t = ev && (ev.currentTarget || (ev.nativeEvent && ev.nativeEvent.currentTarget));
|
|
return (t && t.ownerDocument && t.ownerDocument.defaultView) || null;
|
|
} catch (_) { return null; }
|
|
}
|
|
// A synthetic (lab) mouse or pointer event puts the action in test mode by itself. One untrusted click is
|
|
// NOT a lab click: Steam's own controls turn the gamepad's A into HTMLElement.click(), so a DialogButton's
|
|
// onClick and a MenuItem's onSelected get an untrusted PointerEvent 'click' with pointerType '' (pointerId
|
|
// -1). That is the user's A press, the gamepad path, and must reach the action (review R1 M1). Lab clicks
|
|
// are MouseEvents (P10's L.click, the selftest) or pointer events with a pointerType; a locked lab step
|
|
// stays in test mode anyway through P10's action logger (reason 1), whatever its clicks look like.
|
|
function steamProgrammaticClick(ne) {
|
|
return ne.type === 'click' && ne.pointerType === '' && (ne.pointerId === undefined || ne.pointerId === -1 || ne.pointerId === 0);
|
|
}
|
|
function untrustedPointer(ev) {
|
|
const ne = ev && (ev.nativeEvent || ev);
|
|
if (!ne || typeof ne !== 'object' || typeof ne.isTrusted !== 'boolean' || ne.isTrusted) return false;
|
|
if (!/^(click|dblclick|auxclick|contextmenu|mouse|pointer)/.test(String(ne.type || ''))) return false;
|
|
return !steamProgrammaticClick(ne);
|
|
}
|
|
function testReasons(ev) {
|
|
const r = [];
|
|
let loggerOn = null;
|
|
try { const t = rt.test && rt.test.actions; loggerOn = t && typeof t.enabled === 'function' ? !!t.enabled() : null; } catch (_) { loggerOn = null; }
|
|
if (loggerOn === null) r.push('test state unreadable (fail closed)');
|
|
else if (loggerOn) r.push('runtime action logger on');
|
|
if (S && S.forceTest) r.push('actions.test(true)');
|
|
let live; try { live = rt.flags.get('actionsLive'); } catch (_) { live = undefined; }
|
|
if (live !== true) r.push('flag actionsLive is not true');
|
|
if (untrustedPointer(ev)) r.push('synthetic pointer event');
|
|
return r;
|
|
}
|
|
function recordAction(entry) {
|
|
S.actionLog.push(entry);
|
|
if (S.actionLog.length > ACTION_LOG_MAX) S.actionLog.splice(0, S.actionLog.length - ACTION_LOG_MAX);
|
|
try { const t = rt.test && rt.test.actions; if (t && typeof t.record === 'function' && (entry.mode !== 'executed')) t.record({ fn: entry.fn, arg: entry.arg, mode: entry.mode, reason: entry.reason, detail: entry.detail }); } catch (_) { /* no runtime */ }
|
|
for (const cb of S.actionSubs) { try { cb(entry); } catch (_) { /* subscriber gone */ } }
|
|
}
|
|
// plan() -> {run: fn | null, refuse: reason, detail}
|
|
function act(fn, arg, ev, plan) {
|
|
if (!S) throw new Error('lgs-react: module not installed');
|
|
const reasons = testReasons(ev);
|
|
let p;
|
|
try { p = plan() || {}; } catch (e) { p = { run: null, refuse: 'Steam handler lookup failed: ' + errMsg(e) }; }
|
|
const entry = { t: Date.now(), fn, arg, mode: 'executed', reason: null, detail: p.detail || null };
|
|
if (typeof p.run !== 'function') { entry.mode = 'refused'; entry.reason = p.refuse || 'Steam handler not found'; }
|
|
else if (reasons.length) { entry.mode = 'logged'; entry.reason = reasons.join('; '); }
|
|
if (entry.mode === 'executed') {
|
|
try { const r = p.run(); if (r && typeof r.catch === 'function') r.catch((e) => logE(`action ${fn} failed`, errMsg(e))); } catch (e) { entry.mode = 'error'; entry.reason = errMsg(e); }
|
|
}
|
|
recordAction(entry);
|
|
return { fn, arg, mode: entry.mode, reason: entry.reason, detail: entry.detail };
|
|
}
|
|
function primaryInfo(appid) {
|
|
const M = need();
|
|
const ov = overview(appid);
|
|
if (!ov) return { appid, action: null, label: null, reason: 'no such app' };
|
|
if (!M.AppActions) return { appid, action: null, label: null, reason: 'Steam app actions not found' };
|
|
const action = M.AppActions.primaryAction(inst(), ov, CLIENT);
|
|
let label = null;
|
|
if (action) { try { label = M.ActionLabel ? M.ActionLabel(action, 1) : null; } catch (_) { label = null; } }
|
|
return { appid: ov.appid, name: ov.display_name, action: action || null, label, overview: ov };
|
|
}
|
|
const actions = {
|
|
launchNonSteam(cmdline, ev) {
|
|
return act('launchNonSteam', cmdline, ev, () => {
|
|
if (typeof cmdline !== 'string' || !cmdline) return { refuse: 'cmdline must be a non-empty string' };
|
|
const A = window.SteamClient && window.SteamClient.Apps;
|
|
if (!A || typeof A.LaunchNonSteamApp !== 'function') return { refuse: 'SteamClient.Apps.LaunchNonSteamApp not found' };
|
|
return { run: () => A.LaunchNonSteamApp(cmdline) };
|
|
});
|
|
},
|
|
primary(appid, ev) {
|
|
return act('primary', appid, ev, () => {
|
|
const i = primaryInfo(appid);
|
|
if (!i.action) return { refuse: i.reason || 'no primary action for this app', detail: { action: null } };
|
|
const M = S.M;
|
|
const ov = i.overview;
|
|
return {
|
|
detail: { action: i.action, label: i.label, name: i.name },
|
|
// Steam's tile menu passes the window of the clicked element (event.currentTarget's
|
|
// ownerDocument.defaultView); without an event, the main window.
|
|
run: () => { const f = M.AppActions.runAction(i.action, ov, CLIENT, LAUNCH_SOURCE_LIBRARY, eventWindow(ev) || mainWin()); if (typeof f === 'function') f(); },
|
|
};
|
|
});
|
|
},
|
|
desktopWindow(windowId, ev) {
|
|
return act('desktopWindow', windowId, ev, () => {
|
|
const M = need();
|
|
const p = M.VRMessages;
|
|
if (!p || !p.SteamVR || typeof p.SteamVR.DashboardDesktopWindowClicked !== 'function') return { refuse: 'DashboardDesktopWindowClicked not found' };
|
|
if (windowId == null) return { refuse: 'windowId missing' };
|
|
return { run: () => p.SteamVR.DashboardDesktopWindowClicked({ window_id: windowId }) };
|
|
});
|
|
},
|
|
navigate(path, opts, ev) {
|
|
if (opts && typeof opts === 'object' && (opts.nativeEvent || typeof opts.isTrusted === 'boolean')) { ev = opts; opts = {}; }
|
|
const replace = !!(opts && opts.replace);
|
|
return act('navigate', path, ev, () => {
|
|
if (typeof path !== 'string' || !path.startsWith('/')) return { refuse: 'path must start with /' };
|
|
return { detail: { replace }, run: () => inst().Navigate(path, replace) };
|
|
});
|
|
},
|
|
primaryInfo(appid) { const i = primaryInfo(appid); return { appid: i.appid, name: i.name || null, action: i.action, label: i.label, reason: i.reason || null }; },
|
|
mode(ev) { const r = testReasons(ev); return { mode: r.length ? 'test' : 'live', reasons: r }; },
|
|
get log() { return S ? S.actionLog.slice() : []; },
|
|
onLog(cb) { S.actionSubs.add(cb); const st = S; return () => { st.actionSubs.delete(cb); }; },
|
|
test(on) { S.forceTest = !!on; return actions.mode(); },
|
|
// The Steam function each action calls (identity checks, HA AT-4).
|
|
handler(name) {
|
|
switch (name) {
|
|
case 'launchNonSteam': { const A = window.SteamClient && window.SteamClient.Apps; return A ? A.LaunchNonSteamApp : null; }
|
|
case 'primary': { const M = need(); return M.AppActions ? { primaryAction: M.AppActions.primaryAction, runAction: M.AppActions.runAction, launchSource: LAUNCH_SOURCE_LIBRARY, client: CLIENT } : null; }
|
|
case 'desktopWindow': { const M = need(); return M.VRMessages && M.VRMessages.SteamVR ? M.VRMessages.SteamVR.DashboardDesktopWindowClicked : null; }
|
|
case 'navigate': return inst().Navigate;
|
|
default: return null;
|
|
}
|
|
},
|
|
};
|
|
|
|
// ------------------------------------------------------------ status, removal
|
|
function countPatchedLeft(touched) {
|
|
let n = 0;
|
|
const isOurs = (t) => !!t && ((typeof t === 'function' && t[MARK]) || (typeof t === 'object' && ((t.type && t.type[MARK]) || (t.render && t.render[MARK]))));
|
|
try {
|
|
walk((f) => {
|
|
if (isOurs(f.type) || isOurs(f.elementType)) n++;
|
|
if (f.alternate && (isOurs(f.alternate.type) || isOurs(f.alternate.elementType))) n++;
|
|
});
|
|
} catch (_) { /* no window */ }
|
|
for (const T of touched || []) { try { if (T.holder && T.holder[T.key] && T.holder[T.key][MARK]) n++; } catch (_) { /* gone */ } }
|
|
// Steam's element factories (the jsx substitution for bare function components).
|
|
try {
|
|
const J = jsxMod, R = reactMod;
|
|
if (J && ((J.jsx && J.jsx[MARK]) || (J.jsxs && J.jsxs[MARK]))) n++;
|
|
if (R && R.createElement && R.createElement[MARK]) n++;
|
|
} catch (_) { /* gone */ }
|
|
return n;
|
|
}
|
|
function status() {
|
|
const st = S;
|
|
const out = { version: VERSION, installed: !!st, ready: !!(st && st.ready), error: st && st.error ? st.error.message : null,
|
|
scanMs: st ? st.scanMs : null, factories: st ? st.factories : 0, finders: {}, switchFibers: null, patchedLive: null,
|
|
routes: [], overrides: [], patches: [], actions: null };
|
|
if (!st) return out;
|
|
for (const k in FINDERS) out.finders[k] = { module: st.where[k] || null, candidates: st.counts[k] == null ? null : st.counts[k], required: REQUIRED.includes(k), found: !!(st.M && st.M[k]) };
|
|
if (st.ready) {
|
|
try { const live = routeSwitchFibers(st.M.RoutePaths); out.switchFibers = live.length; out.patchedLive = live.filter((f) => f.type && f.type[MARK]).length; } catch (e) { out.switchError = errMsg(e); }
|
|
}
|
|
out.routes = [...st.routes.keys()];
|
|
out.overrides = [...st.overrides.keys()];
|
|
out.patches = patch.list();
|
|
// Our functions live anywhere (fibers, holders, Steam's element factories); 0 when nothing is registered.
|
|
out.patchedLeft = st.ready ? countPatchedLeft([...st.touched]) : 0;
|
|
out.jsxHooked = !!st.jsxHook;
|
|
// The history and window listeners (installed while anything is registered, pending patches included)
|
|
// and the late-attach timers still scheduled.
|
|
out.watching = !!st.unlisten;
|
|
out.lateTimers = st.lateOffs.length;
|
|
if (st.error) { out.errorTransient = !!st.error.transient; out.retryInMs = st.error.transient ? Math.max(0, st.retryAt - Date.now()) : null; out.attempts = st.attempts; }
|
|
try { out.actions = Object.assign(actions.mode(), { logged: st.actionLog.length }); } catch (_) { /* no runtime */ }
|
|
return out;
|
|
}
|
|
function removeAll() {
|
|
const st = S;
|
|
if (!st) return { patchedLeft: 0, restored: 0 };
|
|
const rep = { restored: 0, routeBefore: null, routeAfter: null };
|
|
for (const m of st.menus) { try { m.Hide(); } catch (_) { /* closed */ } }
|
|
for (const m of st.modals) { try { m.Close(); } catch (_) { /* closed */ } }
|
|
st.menus.clear(); st.modals.clear();
|
|
let cur = null; try { cur = route(); } catch (_) { /* no window */ }
|
|
rep.routeBefore = cur;
|
|
let onOurs = false, onOverride = false;
|
|
if (cur && st.ready) {
|
|
for (const r of st.routes.values()) if (match(cur, r.path, r.opts.exact)) onOurs = true;
|
|
for (const o of st.overrides.values()) if (match(cur, o.path, false)) onOverride = true;
|
|
}
|
|
st.routes.clear(); st.overrides.clear();
|
|
const touched = [...st.touched];
|
|
for (const T of st.targets.values()) { T.layers = []; try { installTarget(T); rep.restored++; } catch (e) { logE('restore failed', errMsg(e)); } }
|
|
st.targets.clear(); st.handles.clear();
|
|
rep.restored += unhookJsx();
|
|
rep.restored += unpatchSwitch();
|
|
if (st.unlisten) { try { st.unlisten(); } catch (_) { /* gone */ } st.unlisten = null; }
|
|
if (st.winOff) { try { st.winOff(); } catch (_) { /* gone */ } st.winOff = null; }
|
|
cancelLateAttach();
|
|
if (onOurs) { try { inst().Navigate('/library/home', true); } catch (_) { /* gone */ } }
|
|
else if (onOverride) { try { inst().Navigate(cur, true); } catch (_) { /* gone */ } }
|
|
rep.patchedLeft = st.ready ? countPatchedLeft(touched) : 0;
|
|
try { rep.routeAfter = route(); } catch (_) { /* gone */ }
|
|
st.actionSubs.clear();
|
|
st.src = null; st.req = null; st.M = null; st.ui = null;
|
|
S = null;
|
|
return rep;
|
|
}
|
|
|
|
// ------------------------------------------------------------ the API
|
|
function makeApi() {
|
|
const api = {
|
|
version: VERSION,
|
|
get isReady() { return !!(S && S.ready); },
|
|
ready,
|
|
status,
|
|
// Tests only: forget a failed scan (and any broken-finder hook) so ready() can run again.
|
|
reset() {
|
|
if (!S) return false;
|
|
if (S.routes.size || S.overrides.size || S.handles.size || S.switchPatches.length) throw new Error('lgs-react: reset() with live patches; remove them first');
|
|
const keep = { actionLog: S.actionLog, actionSubs: S.actionSubs };
|
|
S = Object.assign(newState(), keep);
|
|
return true;
|
|
},
|
|
find(spec) { need(); const r = findIn(spec); dropSourcesLater(); return r; },
|
|
util,
|
|
fiber,
|
|
routes,
|
|
patch,
|
|
ui,
|
|
data,
|
|
actions,
|
|
nav,
|
|
get React() { return need().React; },
|
|
get jsx() { return need().jsx.jsx; },
|
|
get jsxs() { return need().jsx.jsxs; },
|
|
get Fragment() { return need().jsx.Fragment || need().React.Fragment; },
|
|
get ReactDOM() { return need().ReactDOM || null; },
|
|
get router() { return need().Router || null; },
|
|
get Routes() { return need().RoutePaths; },
|
|
get M() { return need(); },
|
|
get c() {
|
|
const M = need();
|
|
const m = M.Menu || {};
|
|
return { Focusable: M.Focusable, DialogButton: M.DialogButton, DialogButtonPrimary: M.DialogButtonPrimary || null, Field: M.Field || null,
|
|
ToggleField: M.ToggleField || null, SliderField: M.SliderField || null, DropdownField: M.DropdownField || null, GamepadPage: M.GamepadPage,
|
|
ScrollPanel: M.ScrollPanel || null, ScrollPanelGroup: M.ScrollPanelGroup || null, Menu: m.Menu || null, MenuItem: m.MenuItem || null,
|
|
MenuSeparator: m.MenuSeparator || null, ConfirmModal: M.ConfirmModal || null, showModal: M.showModal || null, showContextMenu: M.showContextMenu || null };
|
|
},
|
|
// Test hooks (RX-5): the next ready() treats these finders as missing.
|
|
test: {
|
|
breakFinder(name) { if (!S) return false; if (name) S.broken.add(name); else S.broken.clear(); return [...S.broken]; },
|
|
// RX-RETRY: while on, ready() sees no route switch, as while Steam's UI is still mounting (a
|
|
// transient failure, retried after the backoff).
|
|
noSwitch(on) { if (!S) return false; S.noSwitch = !!on; return S.noSwitch; },
|
|
countPatchedLeft: () => countPatchedLeft(S ? [...S.touched] : []),
|
|
// The module's own removal (what lgs off runs), then a fresh, unscanned state as after lgs on.
|
|
// Every handle anyone holds is dead afterwards: tests only, with no other T3 module enabled.
|
|
cycle() {
|
|
const keep = S ? { actionLog: S.actionLog } : {};
|
|
const rep = removeAll();
|
|
S = Object.assign(newState(), keep);
|
|
return rep;
|
|
},
|
|
// Simulates React remounting the route switch (the patch is lost): the original type goes back
|
|
// on the live switch fiber; self-healing must re-patch it on the next navigation into our paths.
|
|
dropSwitchPatch() {
|
|
if (!S || !S.ready) return 0;
|
|
let n = 0;
|
|
for (const p of S.switchPatches) for (const f of [p.fiber, p.fiber.alternate]) if (f && f.type === p.patched) { f.type = p.orig; n++; }
|
|
S.switchPatches = [];
|
|
return n;
|
|
},
|
|
},
|
|
};
|
|
return api;
|
|
}
|
|
|
|
RT.define({
|
|
name: 'react',
|
|
deps: [],
|
|
// The kill switch (contract §1): P1's BUILTIN_FLAGS has react: true; an off value removes this module,
|
|
// so every module that depends on it is blocked and its area falls back to T1.
|
|
flag: 'react',
|
|
install(scope) {
|
|
rt = scope || RT;
|
|
S = newState();
|
|
const api = makeApi();
|
|
// `rt.react` (contract §0): an alias of rt.use('react') on the runtime object, so every module's
|
|
// scoped rt (Object.create of it) and lab steps (__LGS_RT.react) see it. Removed with the module.
|
|
// P1's rt.expose does this (tracked, deleted on removal); the getter is the fallback for a runtime
|
|
// without it.
|
|
aliased = null;
|
|
let exposed = false;
|
|
try { if (typeof rt.expose === 'function') { rt.expose('react', api); exposed = true; } } catch (e) { logE('rt.expose(react) failed', errMsg(e)); }
|
|
if (!exposed) {
|
|
try {
|
|
if (!Object.prototype.hasOwnProperty.call(RT, 'react')) {
|
|
Object.defineProperty(RT, 'react', { configurable: true, enumerable: false, get: () => api });
|
|
aliased = api;
|
|
}
|
|
} catch (_) { aliased = null; }
|
|
}
|
|
return api;
|
|
},
|
|
remove() {
|
|
if (aliased) {
|
|
try { const d = Object.getOwnPropertyDescriptor(RT, 'react'); if (d && d.get && d.get() === aliased) delete RT.react; } catch (_) { /* runtime gone */ }
|
|
aliased = null;
|
|
}
|
|
const rep = removeAll();
|
|
rt = null;
|
|
return { patchedLeft: rep.patchedLeft || 0, restored: rep.restored || 0, routeBefore: rep.routeBefore || null, routeAfter: rep.routeAfter || null };
|
|
},
|
|
});
|
|
})();
|