diff --git a/README.md b/README.md index 0632b49..298da78 100644 --- a/README.md +++ b/README.md @@ -557,29 +557,21 @@ own, and with no other window open the dashboard always shows it. **Fix:** a UI patch of SteamVR's dashboard (turns on the [SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable)) gives the Steam -window an X. Clicking it: +window an X that hides Steam: it docks the window back if it was in the +world, theater or on a hand, then shows the most recently active other +dashboard window, or **just the dashboard bar** if there is none. -- docks the window back into the dashboard if it was in the world, theater - or on a hand; -- if it was active, switches to the most recently active other window with a - dashboard tab; -- with none left, shows **just the dashboard bar** ("bar only"). +Steam stays hidden until you bring it back (Steam tab, a Steam menu pick, +SteamVR asking for it): closing the active window, or a theater window, +then goes to the previous window or the bar instead of Steam. This +survives dashboard reopens and patch-service restarts; a SteamVR restart +starts with Steam. Debugging: `window.__sfuiSteamClose.plan()` / +`homePlan()` and `window.__sfuiSteamCloseState` in the `systemui` page. -Bar-only survives closing/reopening the dashboard and patch-service restarts, -and ends as soon as any window becomes active; after a SteamVR restart the -dashboard starts with Steam again. - -Debugging: `window.__sfuiSteamClose.plan()` in the `systemui` page tells -what a click would do; state is `window.__sfuiSteamCloseState`. - -**Limitations:** - -- If Steam doesn't send its automatic show request after a dashboard open, - the first Steam menu pick in bar-only is swallowed once. -- Steam shown via SteamVR's `SwitchToDashboardOverlay` path ends bar-only. -- No effect with a VRLink remote dashboard. -- Turning the option off while bar-only leaves no active window until the - next tab click or dashboard open. +**Limitations:** SteamVR's rarer "go home" paths (Now Playing after a game +quits, message overlays) still show Steam; no effect with a VRLink remote +dashboard; turning the option off while bar-only leaves no active window +until the next tab click or dashboard open. **Caveats:** found by signature (`steam-close-button` in `signatures.json`); on mismatch the dashboard stays stock. Tested with SteamVR build 11008059. diff --git a/modules/lib/signatures.json b/modules/lib/signatures.json index 30851ee..8c80863 100644 --- a/modules/lib/signatures.json +++ b/modules/lib/signatures.json @@ -212,7 +212,7 @@ "checkOnly": true, "module": { "includes": ["e[e.Alive=2]=\"Alive\""] }, "expects": ["this.m_eState=", "this.closing=", "this.docking=", "this.tab=", "get isActiveDashboardFrame()", - "this.props="] + "this.props=", "get associatedSummonOverlayKeys()"] }, "frameStore": { "checkOnly": true, @@ -223,19 +223,29 @@ "checkOnly": true, "module": { "includes": ["window.DashboardStore="] }, "expects": ["_setActiveFrame(e,t){", "\"_setActiveFrame\"", "get mainSteamFrame()", "m_unMainSteamFrameID", - "get activeFrameID()", "get activeFrame()", "get visibilityState()", "get isFullyVisible()"] + "get activeFrameID()", "get activeFrame()", "get isFullyVisible()"] }, "dashboard": { "checkOnly": true, "module": { "includes": ["window.Dashboard=this", "autoSwitchOverlayIfNeeded(){"] }, - "expects": ["onShowOverlayRequestFromSteam(e){", - "SetImplementation(\"ShowOverlay\",(e=>{this.onShowOverlayRequestFromSteam(e)}))", - "this.autoSwitchOverlayIfNeeded()", "switchToFrameInternal(e,t,r){", "\"hideDashboard\"", - "visibleInDashboardBar"] + "expects": ["this.autoSwitchOverlayIfNeeded()", "switchToFrameInternal(e,t,r){", "\"hideDashboard\"", + "visibleInDashboardBar", "this.m_mailbox=", + "k_sDashboardOverlayDestroyedMessage=\"dashboard_overlay_destroyed\"", + "k_sDashboardOverlayDestroyedMessage,this.onDashboardOverlayDestroyed)", + "onDashboardOverlayDestroyed(e){", "&&this.switchToHomeOverlay()}", + "RegisterHandler(\"show_dashboard_requested\",(e=>this.showDashboardOverlay(", + "RegisterHandler(\"switch_dashboard_overlay_requested\",(e=>this.showDashboardOverlay(", + "overlayKey:null==e?void 0:e.overlay_key,sReason:null==e?void 0:e.reason"] + }, + "mailbox": { + "checkOnly": true, + "module": { "includes": ["RegisterHandler(e,t){this.m_oHandlers[e]=t}"] }, + "expects": ["this.m_oHandlers[t.type](t)"] }, "docking": { "checkOnly": true, - "module": { "includes": ["get dockLocation()", "SetDockLocation(e"], "excludes": ["get associatedSummonOverlayKeys()"] } + "module": { "includes": ["get dockLocation()", "SetDockLocation(e"], "excludes": ["get associatedSummonOverlayKeys()"] }, + "expects": ["sReason:\"SetDockLocation\""] }, "overlayKeys": { "checkOnly": true, diff --git a/modules/steam-close-button.nix b/modules/steam-close-button.nix index b8d9054..ed7d6b1 100644 --- a/modules/steam-close-button.nix +++ b/modules/steam-close-button.nix @@ -9,10 +9,9 @@ in { imports = [ ./steam-ui-patches.nix ./steamvr-debugger.nix ]; options.steamFrame.dashboard.steamCloseButton.enable = lib.mkEnableOption '' - a Close (X) button on the dashboard's Steam window: docks it back if it - was in the world, then switches to the last active other window, or - leaves only the dashboard bar (until a window is activated, e.g. via the - Steam tab). Dashboard patch; off removes it (next switch)''; + a Close (X) button on the dashboard's Steam window. It hides Steam + (previous window or just the bar) until shown explicitly, e.g. via the + Steam tab. Dashboard patch; off removes it (next switch)''; config = lib.mkIf cfg.enable { steamFrame.uiPatches.patches = [ { diff --git a/modules/steam-close-button/patch.js b/modules/steam-close-button/patch.js index fd5a714..fa9021c 100644 --- a/modules/steam-close-button/patch.js +++ b/modules/steam-close-button/patch.js @@ -1,51 +1,40 @@ -// steam-close-button: a Close (X) button on the SteamVR dashboard's main -// "Steam" window (overlay valve.steam.gamepadui.main). -// Target: SteamVR dashboard (vrwebhelper, DevTools 127.0.0.1:8087, title -// "systemui"). mkPatch patch (see lib/default.nix); no options. +// steam-close-button: a Close (X) button on the SteamVR dashboard's main Steam +// window (overlay valve.steam.gamepadui.main). Target: "systemui" on +// 127.0.0.1:8087; mkPatch patch, no options. // -// The button: a frame's `closing` component shows X when a close method is -// possible; method 0 is "componentProps.onCloseRequested exists", with -// componentProps = {...defaultComponentProps, ...frame.props.componentProps.closing}. -// The main frame passes no closing props, so this instance's -// defaultComponentProps is replaced by a copy adding onCloseRequested -// (original as non-enumerable __sfuiOrig) and frame.props is re-assigned (in a -// MobX action) so the computed re-evaluates. Later React props updates keep it. +// Button: the frame's `closing` component shows X when +// componentProps.onCloseRequested exists; this instance's defaultComponentProps +// gets one (original kept as __sfuiOrig) and frame.props is re-assigned so the +// MobX computed re-evaluates. // -// Clicking X: the frame can't be destroyed, so it is put out of view: -// * undocked (world/theater/hand) -> docked back into the dashboard; -// * if it was active -> switch to the most recently active other frame that -// is alive, docked and has a dashboard-bar tab (as a tab click does); -// * with none -> "bar only": dashboard open with no active frame. -// Stock SteamVR has that state but never keeps it, so while bar-only is on -// and no frame is active: -// - an instance override of Dashboard.autoSwitchOverlayIfNeeded returns -// early (its autorun, and reopening the dashboard, would switch back); -// - an instance override of Dashboard.onShowOverlayRequestFromSteam drops -// the FIRST ShowOverlay(main) from Steam after each dashboard open, within -// STEAM_SHOW_CAP_MS (Steam sends it by itself 50 ms .. 1.7 s after open). -// Trade-off: if Steam doesn't send it, the user's first explicit Steam-menu -// pick in that session is swallowed once. -// Steam tab clicks and SteamVR's SwitchToDashboardOverlay aren't filtered. -// Bar-only ends as soon as any frame becomes active. +// X hides Steam: docks the window back if undocked, then switches to the most +// recently active other alive, docked frame with a bar tab, else "bar only" +// (no active frame). Without the Dashboard component it hides the dashboard. +// Steam stays hidden until it is the active frame again, which only explicit +// requests do (tab click, Steam menu pick, SwitchToDashboardOverlay). While +// hidden, stock fallbacks to Steam go to that same target instead: +// - Dashboard.autoSwitchOverlayIfNeeded (instance override): no active frame +// (active window closed, dashboard opened) -> target; bar-only stays; +// - mailbox handler "dashboard_overlay_destroyed" (stock: active overlay gone +// -> switchToHomeOverlay -> Steam) -> target; +// - mailbox show/switch requests for Steam with reason "SetDockLocation" (echo +// of X docking Steam) are dropped, "theater frame destroyed" (vrserver) are +// passed on without the key. +// Rarer stock "go home" paths (Now Playing, message overlay) still show Steam. // -// State (bar-only flag, open time, pending request, history, log) lives in -// window.__sfuiSteamCloseState (schema 1), taken over by upgrades. Teardown -// never switches frames and keeps the state, so a re-injection continues -// bar-only; a dashboard reload starts fresh. -// -// Without the Dashboard component, X hides the dashboard. RequestClose's -// spinner (it expects the frame to go away) is reset right after. A MobX -// reaction tracks active-frame history and re-applies when the main frame -// changes. window.__sfuiSteamClose: plan() (what a click would do), -// wouldIgnoreSteamShow(), barOnly, state, teardown(). Result: "patched" / -// "unchanged", plus " (bar-only, N ignored Steam requests)" when relevant. +// Contract: state in window.__sfuiSteamCloseState (schema 1: steamHidden, +// barOnly, history, redirected, redirects; older versions' fields are left +// for a rollback) survives re-injection; teardown restores all overrides and +// never switches frames. +// window.__sfuiSteamClose: plan(), homePlan(), steamHidden, barOnly, state. ((find, sigs, opts) => { const NAME = 'steam-close-button'; - const VERSION = 5; - const MAIN_KEY = 'valve.steam.gamepadui.main'; // anchored by sigs.overlayKeys - const FRAME_ALIVE = 2; // frame m_eState Alive (sigs.frame) - const STEAM_SHOW_CAP_MS = 10000; + const VERSION = 7; + const MAIN_KEY = 'valve.steam.gamepadui.main'; // sigs.overlayKeys + const FRAME_ALIVE = 2; // sigs.frame const REASON = 'sfui steam-close-button'; + const DOCK_ECHO = 'SetDockLocation'; // sigs.docking + const THEATER_GONE = 'theater frame destroyed'; // sent by vrserver const prev = window.__sfuiSteamClose; if (prev?.version === VERSION) return prev.apply(); @@ -66,21 +55,19 @@ prev?.teardown?.(); // other VERSION; never switches frames - // Page state. To keep bar-only across dashboard reloads, load/save it here - // (e.g. localStorage; shownAt is performance.now() of this page). - const loadState = () => { + const S = (() => { const s = window.__sfuiSteamCloseState; - if (s?.schema === 1) return s; - return (window.__sfuiSteamCloseState = { - schema: 1, barOnly: false, shownAt: -Infinity, steamShowPending: false, - history: [], ignored: 0, suppressed: [], - }); - }; - const S = loadState(); - if (S.barOnly && DS.activeFrame != null) S.barOnly = false; // a frame became active meanwhile + if (s?.schema !== 1) return (window.__sfuiSteamCloseState = + { schema: 1, steamHidden: false, barOnly: false, history: [], redirected: 0, redirects: [] }); + s.steamHidden ??= !!s.barOnly; // version <= 5: bar-only implied hidden + s.barOnly = !!s.barOnly && DS.activeFrame == null; + s.history ??= []; s.redirected ??= 0; s.redirects ??= []; + return s; + })(); + const mainActive = () => DS.activeFrameID != null && DS.activeFrameID === DS.m_unMainSteamFrameID; + if (mainActive()) S.steamHidden = false; - // Recently active frame IDs, most recent last. - const note = (id) => { + const note = (id) => { // recently active frames, most recent last if (id == null) return; const h = S.history, i = h.indexOf(id); if (i >= 0) h.splice(i, 1); @@ -89,16 +76,17 @@ }; note(DS.activeFrameID); - const fallbackFrame = (main) => { + const fallbackFrame = (except) => { + const main = DS.mainSteamFrame; for (let i = S.history.length - 1; i >= 0; i--) { const f = FS.GetFrame(S.history[i]); - if (f && f.frameID !== main.frameID && f.m_eState === FRAME_ALIVE && + if (f && f !== main && f !== except && f.m_eState === FRAME_ALIVE && f.tab?.visibleInDashboardBar && f.docking?.dockLocation === DOCK_DASHBOARD) return f; } return null; }; - // What a click on the main frame's X would do: { action, dock, to?, title? }. + // What a click on X would do: { action, dock, to?, title? }. const plan = (frame = DS.mainSteamFrame) => { if (!frame) return { action: 'none', reason: 'no main frame' }; const dock = !!frame.docking && frame.docking.dockLocation !== DOCK_DASHBOARD; @@ -108,84 +96,108 @@ if (window.Dashboard && DS._setActiveFrame) return { action: 'bar-only', dock }; return { action: DS.isFullyVisible ? 'hide' : 'none', dock }; }; + // Where a fallback away from `except` goes while hidden. + const homePlan = (except = DS.activeFrame) => { + const f = fallbackFrame(except); + return f ? { action: 'switch', to: f.frameID, title: f.title } : { action: 'bar-only' }; + }; - // ---- bar-only: overrides on the Dashboard instance -------------------------- - const holding = () => S.barOnly && DS.activeFrame == null; - const wouldIgnoreSteamShow = (key = MAIN_KEY) => holding() && key === MAIN_KEY && DS.visibilityState !== 0 && - S.steamShowPending && performance.now() - S.shownAt < STEAM_SHOW_CAP_MS; - const wrappers = { + const hiding = () => S.steamHidden && DS.mainSteamFrame != null && !mainActive(); + const barOnly = () => { + S.barOnly = true; + if (DS.activeFrameID != null) DS._setActiveFrame(undefined, undefined); + }; + const goHome = (except, why) => { + const p = homePlan(except); + S.redirected++; + S.redirects.push({ at: new Date().toISOString(), why, to: p.to ?? 'bar-only' }); + if (S.redirects.length > 10) S.redirects.shift(); + if (p.to != null && window.Dashboard?.switchToFrameInternal) window.Dashboard.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON); + else barOnly(); + }; + + // ---- overrides: a Dashboard instance method and its mailbox handlers ------ + const methodWrappers = { autoSwitchOverlayIfNeeded: (orig) => function autoSwitchOverlayIfNeeded(...args) { - if (holding()) return; + if (S.barOnly && DS.activeFrame == null) return; + if (hiding() && DS.activeFrame == null) return goHome(null, 'autoSwitchOverlayIfNeeded'); return orig.apply(this, args); }, - onShowOverlayRequestFromSteam: (orig) => function onShowOverlayRequestFromSteam(e, ...rest) { - if (e?.overlay_key === MAIN_KEY) { - const ignore = wouldIgnoreSteamShow(MAIN_KEY); - S.steamShowPending = false; - if (ignore) { - S.ignored++; - S.suppressed.push({ at: new Date().toISOString(), afterOpenMs: Math.round(performance.now() - S.shownAt), key: MAIN_KEY }); - if (S.suppressed.length > 10) S.suppressed.shift(); - return; - } - } + }; + const showRequest = (orig) => function showRequest(e, ...rest) { + if (hiding() && e?.overlay_key === MAIN_KEY) { + if (e.reason === DOCK_ECHO) return; + if (e.reason === THEATER_GONE) return orig.call(this, { ...e, overlay_key: undefined }, ...rest); + } + return orig.call(this, e, ...rest); + }; + const handlerWrappers = { + dashboard_overlay_destroyed: (orig) => function overlayDestroyed(e, ...rest) { + const a = DS.activeFrame; + if (hiding() && a?.associatedSummonOverlayKeys?.includes(e?.overlay_key ?? '')) return goHome(a, 'overlay destroyed'); return orig.call(this, e, ...rest); }, + show_dashboard_requested: showRequest, + switch_dashboard_overlay_requested: showRequest, }; - let guarded = null; // Dashboard instance carrying the overrides - // Installs the overrides (idempotent); true if autoSwitchOverlayIfNeeded, - // the one bar-only needs, is overridden. + const ours = (f) => f?.__sfuiPatch === NAME && f.__sfuiVersion === VERSION; + const wrapAll = (obj, wrappers) => { + for (const [name, make] of Object.entries(wrappers)) { + const cur = obj[name]; + if (typeof cur !== 'function' || ours(cur)) continue; + const own = Object.getOwnPropertyDescriptor(obj, name); + if (own && !own.writable && !own.configurable) continue; + const old = cur.__sfuiPatch === NAME; // left by another version + const orig = old ? cur.__sfuiOrig : cur; + obj[name] = Object.assign(make(orig), { __sfuiPatch: NAME, __sfuiVersion: VERSION, __sfuiOrig: orig, + __sfuiHadOwn: old ? cur.__sfuiHadOwn : !!own }); + } + }; + const DASH_NAMES = [...Object.keys(methodWrappers), 'onShowOverlayRequestFromSteam']; // + version <= 6 + const unwrapAll = (obj, names) => { + for (const name of names) { + const f = obj?.[name]; + if (f?.__sfuiPatch !== NAME) continue; + if (f.__sfuiHadOwn) obj[name] = f.__sfuiOrig; + else delete obj[name]; + } + }; + let guarded = null, guardedHandlers = null; + // Installs the overrides (idempotent); true if autoSwitchOverlayIfNeeded is ours. const guard = () => { const dash = window.Dashboard; if (!dash) return false; if (guarded && guarded !== dash) unguard(); // Dashboard remounted guarded = dash; - for (const [name, make] of Object.entries(wrappers)) { - const cur = dash[name]; - if (typeof cur !== 'function' || (cur.__sfuiPatch === NAME && cur.__sfuiVersion === VERSION)) continue; - const own = Object.getOwnPropertyDescriptor(dash, name); - if (own && !own.writable && !own.configurable) continue; - const ours = cur.__sfuiPatch === NAME; // left by another version - const orig = ours ? cur.__sfuiOrig : cur; - dash[name] = Object.assign(make(orig), { - __sfuiPatch: NAME, __sfuiVersion: VERSION, __sfuiOrig: orig, __sfuiHadOwn: ours ? cur.__sfuiHadOwn : !!own, - }); + unwrapAll(dash, DASH_NAMES.filter((n) => !(n in methodWrappers))); // left by an older version + wrapAll(dash, methodWrappers); + const h = dash.m_mailbox?.m_oHandlers; // registered once the mailbox is up + if (h && typeof h === 'object') { + guardedHandlers = h; + wrapAll(h, Object.fromEntries(Object.entries(handlerWrappers).filter(([t]) => typeof h[t] === 'function'))); } - return dash.autoSwitchOverlayIfNeeded?.__sfuiVersion === VERSION && dash.autoSwitchOverlayIfNeeded.__sfuiPatch === NAME; + return ours(dash.autoSwitchOverlayIfNeeded); }; const unguard = () => { - const d = guarded; - guarded = null; - for (const name of Object.keys(wrappers)) { - const f = d?.[name]; - if (f?.__sfuiPatch !== NAME) continue; - if (f.__sfuiHadOwn) d[name] = f.__sfuiOrig; - else delete d[name]; - } + unwrapAll(guarded, DASH_NAMES); + unwrapAll(guardedHandlers, Object.keys(handlerWrappers)); + guarded = guardedHandlers = null; }; const onClose = (frame) => { const p = plan(frame); - if (p.dock) frame.docking.SetDockLocation(DOCK_DASHBOARD); const dash = window.Dashboard; - switch (p.action) { - case 'switch': - dash.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON); - break; - case 'bar-only': - if (!guard()) { dash.hideDashboard?.(REASON, false); break; } - S.barOnly = true; - S.steamShowPending = false; - DS._setActiveFrame(undefined, undefined); - break; - case 'hide': - if (dash?.hideDashboard) dash.hideDashboard(REASON, false); - else window.VRHTML?.VRDashboardManager?.HideDashboard(REASON, false); - break; + if (p.action !== 'none' || p.dock) { guard(); S.steamHidden = true; } + if (p.dock) frame.docking.SetDockLocation(DOCK_DASHBOARD); + if (p.action === 'switch') dash.switchToFrameInternal(FS.GetFrame(p.to), undefined, REASON); + else if (p.action === 'bar-only' && guard()) barOnly(); + else if (p.action === 'bar-only' || p.action === 'hide') { + if (dash?.hideDashboard) dash.hideDashboard(REASON, false); + else window.VRHTML?.VRDashboardManager?.HideDashboard(REASON, false); } }; - // ---- the button: defaultComponentProps of the main frame's `closing` -------- + // ---- the button -------------------------------------------------------------- let closing = null; // the component we changed const nudge = (frame) => mx.runInAction(() => { if (frame?.props) frame.props = { ...frame.props }; }); const resetSpinner = (c) => mx.runInAction(() => { c.m_bShowSpinner = false; }); @@ -199,8 +211,8 @@ const orig = cur?.__sfuiPatch === NAME ? cur.__sfuiOrig : cur; const props = { ...orig, - onCloseRequested: () => { - try { onClose(frame); } finally { setTimeout(() => resetSpinner(c), 0); } // set after we return + onCloseRequested: () => { // RequestClose sets the spinner after we return + try { onClose(frame); } finally { setTimeout(() => resetSpinner(c), 0); } }, }; for (const [k, value] of [['__sfuiPatch', NAME], ['__sfuiVersion', VERSION], ['__sfuiOrig', orig]]) @@ -226,19 +238,15 @@ guard(); if (!f) return 'main Steam frame not found'; const r = patchFrame(f); - const notes = [S.barOnly && 'bar-only', S.ignored && `${S.ignored} ignored Steam request${S.ignored === 1 ? '' : 's'}`] - .filter(Boolean); + const notes = [S.steamHidden && 'Steam hidden', S.barOnly && 'bar-only', + S.redirected && `${S.redirected} redirect${S.redirected === 1 ? '' : 's'}`].filter(Boolean); return notes.length ? `${r} (${notes.join(', ')})` : r; }; const disposers = [ mx.reaction(() => DS.activeFrameID, note), - mx.reaction(() => DS.activeFrameID != null, (active) => { - if (active) { S.barOnly = false; S.steamShowPending = false; } - }), - mx.reaction(() => DS.visibilityState !== 0, (shown) => { - if (shown) { S.shownAt = performance.now(); S.steamShowPending = true; } - }), + mx.reaction(() => DS.activeFrameID != null, (active) => { if (active) S.barOnly = false; }), + mx.reaction(mainActive, (on) => { if (on) S.steamHidden = false; }), mx.reaction(() => DS.m_unMainSteamFrameID, () => { try { apply(); } catch (e) { console.error(NAME, e); } }), ]; const teardown = () => { @@ -248,7 +256,8 @@ if (window.__sfuiSteamClose === self) delete window.__sfuiSteamClose; }; const self = window.__sfuiSteamClose = { - version: VERSION, apply, teardown, plan, wouldIgnoreSteamShow, state: S, + version: VERSION, apply, teardown, plan, homePlan, state: S, + get steamHidden() { return S.steamHidden; }, get barOnly() { return S.barOnly; }, }; return apply().replace(/^unchanged/, 'patched'); diff --git a/modules/steam-close-button/unpatch.js b/modules/steam-close-button/unpatch.js index 3d8fb02..a1368f3 100644 --- a/modules/steam-close-button/unpatch.js +++ b/modules/steam-close-button/unpatch.js @@ -1,7 +1,8 @@ // Reverts steam-close-button/patch.js (the X disappears). Never switches -// frames and keeps window.__sfuiSteamCloseState, so a re-injection continues; -// if removed while bar-only, the dashboard stays without an active frame -// until the next tab click or open. Safe when not patched. +// frames and keeps window.__sfuiSteamCloseState (Steam hidden, bar-only), so +// a re-injection continues; restores the Dashboard methods and mailbox +// handlers it wrapped. If removed while bar-only, the dashboard stays without +// an active frame until the next tab click or open. Safe when not patched. (() => { const s = window.__sfuiSteamClose; if (!s) return 'not patched';