diff --git a/README.md b/README.md index 1083572..6a5072c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Valve Steam Frame: SteamOS on `aarch64-linux`, standalone home-manager on a non-NixOS system. They work around quirks of the Frame's two graphical sessions (portal config, keyboard layout, VR keyboard, "+" menu, VR -dashboard windows, clipboard, Firefox) +dashboard windows, a close button for Steam's VR window, clipboard, Firefox) declaratively, so every change can be reverted by activating an older home-manager generation. @@ -141,6 +141,7 @@ the two files below). dashboard = { windowMaxScale = 4.0; windowDistance.world.max = 10.0; + steamCloseButton.enable = true; }; firefox.enable = true; # Only relevant with Steam Developer Mode on (see hidden-apps below). @@ -165,7 +166,7 @@ home-manager switch --flake .#steamos ``` Individual modules are available as -`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,dashboard-windows,clipboard-sync,firefox}`; +`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,dashboard-windows,steam-close-button,clipboard-sync,firefox}`; `default` imports all of them. **Steam Developer Mode** (a Steam setting, not managed here) makes the "+" @@ -197,6 +198,7 @@ menu list every desktop entry, including terminals such as Konsole. | `steamFrame.launcherMenu.iconFallbacks` | list of str | `[ "utilities-terminal" "preferences-system" ]` | Icon names installed as hicolor copies of Breeze app icons, so the "+" menu shows them (Konsole, KDE System Settings). `[ ]`: none. Takes effect after a Steam restart. | | `steamFrame.dashboard.windowMaxScale` | null or number | `null` | Largest resize-handle scale of SteamVR dashboard windows, relative to their default size. `null`: stock (2). See [Dashboard windows](#dashboard-windows-dashboard). | | `steamFrame.dashboard.windowDistance.{world,theater,dashboard}.{min,max}` | null or number (m) | `null` | How close / far grabbed windows can be pulled in / pushed back. `null`: stock (world 0.25-5, theater 1-6, dashboard 0.3-4 m). | +| `steamFrame.dashboard.steamCloseButton.enable` | bool | `false` | Close (X) button on the dashboard's Steam window: switches to the previous window, or leaves just the dashboard bar. See [Steam close button](#steam-close-button-dashboardsteamclosebuttonenable). | | `steamFrame.steamvrDebugger.enable` | bool | `true` if a UI patch uses port 8087, else `false` | SteamVR dashboard DevTools on `127.0.0.1:8087` (`VRWebHelper/DebuggerEnabled`), needed by dashboard patches. See [SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable). | | `steamFrame.hiddenApps` | list of str | `[ ]` | Desktop entry ids (without `.desktop`) to hide from the "+" and KDE menus. | | `steamFrame.clipboardSync.enable` | bool | `true` | Clipboard bridge between the Steam session and the nested desktop. | @@ -614,13 +616,78 @@ options silently do nothing (the scale option still applies). The patch's state, including counters of rewritten nodes per kind, is `window.__sfuiDashboardWindows` in the `systemui` page (DevTools). +### Steam close button (`dashboard.steamCloseButton.enable`) + +**Problem:** every SteamVR dashboard window has a close (X) button except +Steam's own (the library, overlay `valve.steam.gamepadui.main`): it can't be +put away, and with no other window open the dashboard always shows it. + +**Fix:** a UI patch (see above) of SteamVR's dashboard page (`vrwebhelper`, +`127.0.0.1:8087`, title `systemui`; it turns on the +[SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable)) gives the Steam +window an X. The window can't really be closed, so clicking it: + +- docks the window back into the dashboard if it was placed in the world + (or theater / hand); +- if it was the dashboard's active window, switches to the most recently + active other window that is still open, in the dashboard and has a tab in + the dashboard bar (like a tab click); +- with none left, leaves the dashboard open with **just its bar** ("bar + only"), no window shown. + +Bar-only is kept when the dashboard is closed and reopened (SteamVR would +otherwise pick Steam again) and across restarts of the patch service +(`home-manager switch`, re-injection); it ends as soon as any window becomes +active: the Steam tab or another tab, a Steam menu pick, a launched app or a +new window. It does not survive a SteamVR restart (or a reload of the +dashboard page): the dashboard then starts with Steam as usual. + +```nix +steamFrame.dashboard.steamCloseButton.enable = true; +``` + +How: the X of a dashboard window is its `closing` component, shown when a +close method is possible; the patch adds an `onCloseRequested` to the Steam +window's default component props. For bar-only it clears the active frame +and overrides two methods of the dashboard instance while bar-only is on: +`autoSwitchOverlayIfNeeded` (stock: no active frame → show Steam) does +nothing, and `onShowOverlayRequestFromSteam` drops the first +`ShowOverlay("valve.steam.gamepadui.main")` Steam sends by itself after each +dashboard open (within 10 s). The patch's state (bar-only, recent windows, +ignored requests) is `window.__sfuiSteamCloseState` in the `systemui` page; +`window.__sfuiSteamClose.plan()` tells what a click would do right now, +without doing it. The journal (`journalctl --user -u steam-ui-patches`) +shows `bar-only` and the number of ignored Steam requests. + +**Limitations:** + +- If Steam ever doesn't send that automatic request after an open, the + first Steam menu pick (e.g. Library) in bar-only is swallowed once; a + second one works. +- Showing Steam through SteamVR's own `CVRSteamPrivate::SwitchToDashboardOverlay` + path (not through the dashboard page) isn't filtered and ends bar-only. +- With a VRLink remote dashboard, SteamVR ignores the local active-frame + change, so bar-only has no effect there. +- Turning the option off (next switch) removes the button but leaves a + dashboard that is bar-only at that moment without an active window until + the next tab click or dashboard open. + +**Caveats:** depends on SteamVR UI internals: MobX, the dock-location enum +and the `closing` component class are found by signature, and the names the +patch relies on (`DashboardStore._setActiveFrame`, `mainSteamFrame`, +`Dashboard.autoSwitchOverlayIfNeeded`, …) are checked offline +(`steam-close-button` in `modules/lib/signatures.json`, +`scripts/check-signatures.mjs`); if a lookup fails, the patch reports it and +leaves the dashboard stock. Tested with SteamVR build 11008059. + ### SteamVR debugger (`steamvrDebugger.enable`) Patches of the SteamVR dashboard need its DevTools port, which SteamVR only opens with the setting `VRWebHelper/DebuggerEnabled` (port `VRWebHelper/DebuggerPort`, default 8087). It is enabled automatically (`mkDefault`) as soon as a patch in `steamFrame.uiPatches.patches` uses port -8087 (e.g. [Dashboard windows](#dashboard-windows-dashboard)). +8087 (e.g. [Dashboard windows](#dashboard-windows-dashboard), +[Steam close button](#steam-close-button-dashboardsteamclosebuttonenable)). SteamVR rewrites `~/.config/openvr/config/steamvr.vrsettings` while it runs and on exit, so the file can't be managed by Home Manager, and edits while it diff --git a/flake.nix b/flake.nix index b4371c9..8e1d365 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,7 @@ launcher-menu = ./modules/launcher-menu.nix; steamvr-debugger = ./modules/steamvr-debugger.nix; dashboard-windows = ./modules/dashboard-windows.nix; + steam-close-button = ./modules/steam-close-button.nix; # Built with the consumer's pkgs; only the source comes from our input. # The key lets the module system deduplicate it when it is imported both # directly and through `default`. diff --git a/modules/lib/signatures.json b/modules/lib/signatures.json index 0c7d3f8..73e8b3d 100644 --- a/modules/lib/signatures.json +++ b/modules/lib/signatures.json @@ -156,6 +156,66 @@ "module": { "includes": ["\"grab-scale\"", "\"min-distance\"", "\"max-distance\""] } } } + }, + "steam-close-button": { + "bundle": "vrwebui-systemui", + "modules": { + "mobx": { + "module": { "includes": ["runInAction:()=>", "makeObservable:()=>", "reaction:()=>"] } + }, + "dock": { + "module": { "includes": ["e[e.Dashboard=1]=\"Dashboard\",e[e.LeftHand=2]=\"LeftHand\""] }, + "exports": { + "EDockLocation": { "type": "object", "props": { "Dashboard": 1, "LeftHand": 2, "RightHand": 3, "World": 4, "Theater": 5 } } + } + }, + "closing": { + "module": { "includes": ["this.componentName=\"closing\"", "closeMethodPriority"] }, + "exports": { + "Closing": { "type": "class", "includes": ["componentName=\"closing\"", "RequestClose()"] } + }, + "expects": ["defaultComponentProps=", "null!=this.componentProps.onCloseRequested", "get showCloseButton()", + "m_bShowSpinner=!0"] + }, + "frameComponent": { + "checkOnly": true, + "module": { "includes": ["this.defaultComponentProps", "FramePageComponent '"] }, + "expects": ["get componentProps()", ".componentProps)", "[this.componentName]"] + }, + "frame": { + "checkOnly": true, + "module": { "includes": ["e[e.Alive=2]=\"Alive\""] }, + "expects": ["this.m_eState=", "this.closing=", "this.docking=", "this.tab=", "get isActiveDashboardFrame()", + "this.props="] + }, + "frameStore": { + "checkOnly": true, + "module": { "includes": ["window.FrameStore="] }, + "expects": ["GetFrame(e){"] + }, + "dashboardStore": { + "checkOnly": true, + "module": { "includes": ["window.DashboardStore="] }, + "expects": ["_setActiveFrame(e,t){", "\"_setActiveFrame\"", "get mainSteamFrame()", "m_unMainSteamFrameID", + "get activeFrameID()", "get activeFrame()", "get visibilityState()", "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"] + }, + "docking": { + "checkOnly": true, + "module": { "includes": ["get dockLocation()", "SetDockLocation(e"], "excludes": ["get associatedSummonOverlayKeys()"] } + }, + "overlayKeys": { + "checkOnly": true, + "module": { "includes": ["\"valve.steam.gamepadui.main\"", "\"valve.steam.gamepadui.appoverlay\""] } + } + } } } } diff --git a/modules/steam-close-button.nix b/modules/steam-close-button.nix new file mode 100644 index 0000000..95f9875 --- /dev/null +++ b/modules/steam-close-button.nix @@ -0,0 +1,46 @@ +# Close (X) button on the SteamVR dashboard's main Steam window +# (steamFrame.dashboard.steamCloseButton.enable): it switches to the +# previously active dashboard window, or leaves just the dashboard bar when +# Steam was the last one. Runtime patch of SteamVR's dashboard page +# (vrwebhelper "systemui", DevTools 127.0.0.1:8087; see +# steam-close-button/patch.js for how it works), registered with +# steam-ui-patches.nix only when enabled; it turns on the SteamVR web helper +# debugger (steamvr-debugger.nix), which needs one SteamVR restart the first +# time. Depends on SteamVR UI internals, found by signature +# (lib/signatures.json, "steam-close-button"; scripts/check-signatures.mjs +# checks them offline). +{ config, lib, pkgs, ... }: +let + cfg = config.steamFrame.dashboard.steamCloseButton; + inherit (import ./lib { inherit pkgs; }) mkPatch; +in { + imports = [ ./steam-ui-patches.nix ./steamvr-debugger.nix ]; + + options.steamFrame.dashboard.steamCloseButton.enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Close (X) button on the SteamVR dashboard's main Steam window. It + docks the window back into the dashboard if it was placed in the world, + then switches to the most recently active other dashboard window, or, + with none, leaves the dashboard open with just its bar ("bar only"; + kept when the dashboard is closed and reopened, until a window is + activated, e.g. with the Steam tab). Runtime patch of the SteamVR + dashboard (steamFrame.uiPatches), applied immediately; turning it off + removes the button (next switch). + ''; + }; + + config = lib.mkIf cfg.enable { + steamFrame.uiPatches.patches = [ { + name = "steam-close-button"; + endpoint = "http://127.0.0.1:8087"; + target.title = "systemui"; + patch = mkPatch { + name = "steam-close-button"; + src = ./steam-close-button/patch.js; + }; + unpatch = ./steam-close-button/unpatch.js; + } ]; + }; +} diff --git a/modules/steam-close-button/patch.js b/modules/steam-close-button/patch.js new file mode 100644 index 0000000..4af7787 --- /dev/null +++ b/modules/steam-close-button/patch.js @@ -0,0 +1,277 @@ +// steam-close-button: a Close (X) button on the SteamVR dashboard's main +// "Steam" window (Steam's library, overlay valve.steam.gamepadui.main). +// Target: SteamVR's dashboard page (vrwebhelper, DevTools 127.0.0.1:8087, +// title "systemui"). +// +// This file is a function expression, called by the file lib/default.nix +// (mkPatch) generates: ()(find, sigs, opts), with find the finder +// library (lib/finders.js) and sigs this patch's module signatures +// (lib/signatures.json, "steam-close-button": MobX, the dock-location enum, +// the frame's `closing` component class, plus check-only anchors for the +// dashboard internals used below). No options. +// +// The button: every dashboard frame has a `closing` component; its X renders +// when closing.showCloseButton, i.e. when one of closeMethodPriority's methods +// is possible. Method 0 is "componentProps.onCloseRequested exists", and +// componentProps = {...defaultComponentProps, ...frame.props.componentProps.closing}. +// The main frame passes no closing props, so this patch replaces that +// instance's plain `defaultComponentProps` field with a copy that adds +// onCloseRequested (original kept as the non-enumerable __sfuiOrig), then +// re-assigns frame.props (a shallow copy, in a MobX action) so the computed +// componentProps re-evaluates and the button appears. React re-assigning +// frame.props later keeps it (the defaults are merged every time). +// +// Clicking X (RequestClose -> onCloseRequested); 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 the active dashboard frame -> the dashboard switches to the +// most recently active other frame that is alive, docked in the dashboard +// and has a dashboard-bar tab (the call a tab click makes); +// * with none -> "bar only": the dashboard stays open with no active frame, +// just the dashboard bar (a docked frame renders only while active). +// Stock SteamVR has that state (e.g. when a VRLink remote frame vanishes) +// but never keeps it, so while bar-only is on and no frame is active: +// - an instance override of Dashboard.autoSwitchOverlayIfNeeded returns +// early (its onDashboardTabsUpdated autorun, and reopening the dashboard +// with the Steam button, would otherwise switch right back to Steam); +// - an instance override of Dashboard.onShowOverlayRequestFromSteam drops +// the FIRST ShowOverlay("valve.steam.gamepadui.main") from Steam after each +// dashboard open, within STEAM_SHOW_CAP_MS (Steam's main VR window sends +// it by itself 50 ms .. 1.7 s after the open). Trade-off: should Steam not +// send it, the user's first explicit Steam-menu pick (e.g. Library) in +// that session is swallowed once; a second one works. +// A Steam tab click (DashboardTabClicked -> switchToFrameInternal) and +// SteamVR's own CVRSteamPrivate::SwitchToDashboardOverlay path aren't +// filtered. Bar-only ends as soon as any frame becomes active. +// +// State that must outlive this instance (bar-only flag, last open time, +// pending Steam request, frame history, suppression log) is kept in +// window.__sfuiSteamCloseState (schema 1, shared with the earlier personal +// version 4 of this patch, whose instance is handed over seamlessly). +// Teardown (unpatch.js, a newer VERSION) only removes overrides, reactions +// and markers: it never switches frames and leaves the state, so a +// re-injection (service restart, switch) continues bar-only. It is in-page +// only: a dashboard (vrwebhelper) reload starts fresh (see loadState). +// +// Without the Dashboard component, X hides the dashboard. RequestClose shows +// a spinner after onCloseRequested (it expects the frame to go away); it is +// reset right after. A MobX reaction keeps the history of active frames and +// re-applies when the main frame changes (the 15 s re-injection covers the +// rest). window.__sfuiSteamClose: plan() (what a click would do now, no side +// effects), wouldIgnoreSteamShow(), barOnly, state, teardown(). +// Results: "patched" / "unchanged", with " (bar-only, N ignored Steam +// requests)" while there is something to report, so the injector logs it. +((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 REASON = 'sfui steam-close-button'; + + const prev = window.__sfuiSteamClose; + if (prev?.version === VERSION) return prev.apply(); + + const DS = window.DashboardStore, FS = window.FrameStore; + if (!DS || !FS) return 'not patched: window.DashboardStore/FrameStore missing'; + let mods; + try { + mods = find.resolveAll(find.getWebpackRequire('webpackChunkvrwebui'), sigs); + } catch (e) { + return `signature not found, dashboard left unpatched: ${e.message}`; + } + const mx = mods.mobx.module; + if (typeof mx.runInAction !== 'function' || typeof mx.reaction !== 'function') + return 'signature not found, dashboard left unpatched: mobx.runInAction/reaction'; + const Closing = mods.closing.exports.Closing; + const DOCK_DASHBOARD = mods.dock.exports.EDockLocation.Dashboard; + + 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 = 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 + + // Recently active frame IDs, most recent last. + const note = (id) => { + if (id == null) return; + const h = S.history, i = h.indexOf(id); + if (i >= 0) h.splice(i, 1); + h.push(id); + if (h.length > 32) h.shift(); + }; + note(DS.activeFrameID); + + const fallbackFrame = (main) => { + 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 && + 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? }. + const plan = (frame = DS.mainSteamFrame) => { + if (!frame) return { action: 'none', reason: 'no main frame' }; + const dock = !!frame.docking && frame.docking.dockLocation !== DOCK_DASHBOARD; + if (!frame.isActiveDashboardFrame) return { action: dock ? 'dock' : 'none', dock }; + const other = fallbackFrame(frame); + if (other && window.Dashboard?.switchToFrameInternal) return { action: 'switch', dock, to: other.frameID, title: other.title }; + if (window.Dashboard && DS._setActiveFrame) return { action: 'bar-only', dock }; + return { action: DS.isFullyVisible ? 'hide' : 'none', dock }; + }; + + // ---- 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 = { + autoSwitchOverlayIfNeeded: (orig) => function autoSwitchOverlayIfNeeded(...args) { + if (holding()) return; + 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; + } + } + return orig.call(this, e, ...rest); + }, + }; + let guarded = null; // Dashboard instance carrying the overrides + // Installs the overrides (idempotent); true if autoSwitchOverlayIfNeeded, + // the one bar-only needs, is overridden. + 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, + }); + } + return dash.autoSwitchOverlayIfNeeded?.__sfuiVersion === VERSION && dash.autoSwitchOverlayIfNeeded.__sfuiPatch === NAME; + }; + 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]; + } + }; + + 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; + } + }; + + // ---- the button: defaultComponentProps of the main frame's `closing` -------- + 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; }); + + const patchFrame = (frame) => { + const c = frame.closing; + if (!(c instanceof Closing)) return 'not patched: main frame has no closing component'; + const cur = c.defaultComponentProps; + closing = c; + if (cur?.__sfuiPatch === NAME && cur.__sfuiVersion === VERSION) return 'unchanged'; + const orig = cur?.__sfuiPatch === NAME ? cur.__sfuiOrig : cur; + const props = { + ...orig, + onCloseRequested: () => { + try { onClose(frame); } finally { setTimeout(() => resetSpinner(c), 0); } // set after we return + }, + }; + for (const [k, value] of [['__sfuiPatch', NAME], ['__sfuiVersion', VERSION], ['__sfuiOrig', orig]]) + Object.defineProperty(props, k, { value, configurable: true }); // non-enumerable: not merged + c.defaultComponentProps = props; + nudge(frame); + return 'patched'; + }; + const unpatchFrame = () => { + const c = closing; + closing = null; + const cur = c?.defaultComponentProps; + if (cur?.__sfuiPatch !== NAME) return; + c.defaultComponentProps = cur.__sfuiOrig; + if (c.frame?.m_eState !== FRAME_ALIVE) return; + resetSpinner(c); + nudge(c.frame); + }; + + const apply = () => { + const f = DS.mainSteamFrame; + if (closing && closing.frame !== f) unpatchFrame(); // main frame recreated + 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); + 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.m_unMainSteamFrameID, () => { try { apply(); } catch (e) { console.error(NAME, e); } }), + ]; + const teardown = () => { + for (const d of disposers.splice(0)) d(); + unpatchFrame(); + unguard(); + if (window.__sfuiSteamClose === self) delete window.__sfuiSteamClose; + }; + const self = window.__sfuiSteamClose = { + version: VERSION, apply, teardown, plan, wouldIgnoreSteamShow, state: S, + 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 new file mode 100644 index 0000000..870e5de --- /dev/null +++ b/modules/steam-close-button/unpatch.js @@ -0,0 +1,13 @@ +// Reverts steam-close-button/patch.js: removes its overrides, reactions and +// markers (the X disappears). Teardown only: it never switches frames and +// leaves window.__sfuiSteamCloseState (bar-only flag etc.) in place, so a +// re-injection (service restart, switch) continues where it left off; if the +// patch is gone for good while bar-only, the dashboard just stays without an +// active frame until the next tab click or dashboard open. Safe when not +// patched. +(() => { + const s = window.__sfuiSteamClose; + if (!s) return 'not patched'; + s.teardown(); + return 'unpatched'; +})() diff --git a/template/home.nix b/template/home.nix index 661d62a..aae8aa2 100644 --- a/template/home.nix +++ b/template/home.nix @@ -33,6 +33,9 @@ # windowMaxScale = 4.0; # windowDistance.world.max = 10.0; # windowDistance.theater.max = 12.0; + # # X button on the Steam window: back to the previous window, or + # # just the dashboard bar: + # steamCloseButton.enable = true; # }; # firefox.enable = true; # launcher for the Firefox Flatpak # # Hidden from the "+" menu (with Steam Developer Mode on):