Files
lhns--steam-frame-nix/modules/launcher-menu/pinned-desktop/patch.js
T
Pierre Kisters e98563bf08 modules: consistency pass over patches and option docs
- pinned-desktop (VERSION 3): drop the stop() of a predecessor global that
  never existed in this repo; log errors as "sfui <NAME>:" through one
  guard helper, like the grid patch.
- steamCloseButton.enable is a mkEnableOption like the other feature
  switches; enable descriptions no longer end in a stray newline.
- launcher-menu.nix header lists show-all and no longer claims the patches
  use no webpack module; hiddenApps mentions showAllApps.
- Patch comments: frame-controls ring timing matches the code (half of
  longPressMs, at most 1 s), window-curvature names frame-controls as what
  moves its control into the bar, steam-close-button no longer refers to an
  unpublished version.
2026-09-27 23:28:05 +02:00

163 lines
7.3 KiB
JavaScript

// pinned-desktop: in the VR dashboard's "+" menu (section
// #VRDashboard_LaunchNonSteamApp), "Desktop" (the nested Plasma session) is
// pinned above or below the scrolling program list instead of scrolling with
// it.
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and opts { position: "top" } or
// { position: "bottom" } from launcher-menu.nix.
//
// DOM patch, evaluated in SharedJSContext (the bar popups share its realm):
// a timer attaches a MutationObserver to every dashboard bar popup document
// (g_PopupManager); whenever the menu renders, the stock Desktop item (found
// through its React fiber: list key == strExePath "steamos-nested-desktop")
// is hidden by CSS and a pinned block is inserted into the list container:
// "bottom" appends it after the scroll region (1px separator, then a clone of
// the item), "top" inserts it right before the scroll region, i.e. below the
// menu heading (clone, then separator). The clone keeps the item's classes,
// icon and CSS :hover; clicking it clicks the hidden stock item, so Steam's
// own handler runs (nav sound + SteamClient.Apps.LaunchNonSteamApp). The
// list container is a flex column with a max-height, so the scroll region
// shrinks to make room and the menu keeps its size.
// Anchors: React props and keys (the list key, the section's `header` prop)
// and the bar popups' window names ("valve.steam.gamepadui.barpopup...");
// scripts/check-signatures.mjs checks that Steam's UI still has them
// ("launcher-menu-pinned-desktop" in lib/signatures.json).
// unpatch.js (or a new VERSION/position) calls __sfuiPinnedDesktop.stop(),
// which removes the pinned blocks, CSS, markers, observers and the timer.
((find, sigs, opts) => {
const NAME = 'launcher-menu-pinned-desktop';
const VERSION = 3;
const POS = opts.position === 'top' ? 'top' : 'bottom';
const KEY = 'steamos-nested-desktop';
const HIDDEN = 'data-sfui-desktop-hidden';
const PIN = 'sfui-pinned-desktop';
const STYLE_ID = 'sfui-pinned-desktop-style';
const CSS = `
[${HIDDEN}] { display: none !important; }
.${PIN} { flex: 0 0 auto; }
.${PIN} > .${PIN}-sep { height: 1px; background: rgba(255, 255, 255, 0.1); }
.${PIN} > [role=button]::after { display: none !important; }`;
const prev = window.__sfuiPinnedDesktop;
if (prev?.version === VERSION && prev.position === POS) { prev.scan(); return 'unchanged'; }
prev?.stop?.();
if (!window.g_PopupManager) return 'g_PopupManager missing';
const docs = new Map(); // popup document -> MutationObserver
const guard = (f) => (...a) => { try { f(...a); } catch (e) { console.error(`sfui ${NAME}:`, e); } };
// The stock Desktop item: role=button element whose fiber ancestors include
// the list entry keyed by the program's exe path.
const isDesktop = (el) => {
const f = find.findFiberUp(el, (x) => x.key != null, 12);
return !!f && (f.key === KEY || String(f.key).endsWith('/' + KEY));
};
// The list container (host div of the section component, which has a `header` prop).
const listOf = (el) => {
const f = find.findFiberUp(el, (x) => x.memoizedProps && typeof x.memoizedProps === 'object' &&
'header' in x.memoizedProps && typeof x.type === 'function', 60);
let c = f?.child;
while (c && typeof c.type !== 'string') c = c.child;
return c?.stateNode ?? null;
};
// Child of the list container that holds the (scrolling) items.
const scrollerOf = (orig, list) => {
let el = orig;
while (el && el.parentElement !== list) el = el.parentElement;
return el;
};
const buildPin = (d, orig) => {
const w = d.defaultView;
const pin = d.createElement('div');
pin.className = PIN;
const panel = orig.parentElement; // scroll panel: copy its side padding
const cs = w.getComputedStyle(panel);
pin.style.paddingLeft = cs.paddingLeft;
pin.style.paddingRight = cs.paddingRight;
if (POS === 'top') pin.style.paddingTop = cs.paddingTop;
else pin.style.paddingBottom = cs.paddingBottom;
const margin = cs.getPropertyValue('--field-negative-horizontal-margin');
if (margin) pin.style.setProperty('--field-negative-horizontal-margin', margin);
const sep = d.createElement('div');
sep.className = `${PIN}-sep`;
const item = orig.cloneNode(true);
item.removeAttribute(HIDDEN);
item.classList.remove('gpfocus', 'gpfocuswithin');
item.addEventListener('click', (e) => {
// Not to React's root listener (the clone has no fiber); the stock item
// gets its own click instead.
e.stopPropagation();
e.preventDefault();
if (orig.isConnected) orig.click();
});
if (POS === 'top') pin.append(item, sep); else pin.append(sep, item);
pin.__sfuiOrig = orig;
pin.__sfuiLabel = orig.textContent;
return pin;
};
const update = (d) => {
const pins = [...d.getElementsByClassName(PIN)];
const hidden = d.querySelector(`[${HIDDEN}]`);
let orig = hidden?.isConnected && isDesktop(hidden) ? hidden : null;
if (!orig) {
for (const el of d.querySelectorAll('[role=button]'))
if (!el.closest('.' + PIN) && isDesktop(el)) { orig = el; break; }
}
for (const el of d.querySelectorAll(`[${HIDDEN}]`)) if (el !== orig) el.removeAttribute(HIDDEN);
const list = orig && listOf(orig);
const scroller = list && scrollerOf(orig, list);
const placed = (p) => POS === 'top' ? p.nextElementSibling === scroller : list.lastElementChild === p;
const ok = (p) => p.__sfuiOrig === orig && p.parentElement === list && placed(p) &&
p.__sfuiLabel === orig.textContent;
let keep = null;
for (const p of pins) if (!scroller || keep || !ok(p)) p.remove(); else keep = p;
if (!scroller) return;
if (!orig.hasAttribute(HIDDEN)) orig.setAttribute(HIDDEN, '');
if (!keep) {
const pin = buildPin(d, orig);
if (POS === 'top') list.insertBefore(pin, scroller); else list.appendChild(pin);
}
};
const attach = (d) => {
if (docs.has(d) || !d?.body) return;
if (!d.getElementById(STYLE_ID)) {
const s = d.createElement('style');
s.id = STYLE_ID; s.textContent = CSS;
(d.head ?? d.documentElement).appendChild(s);
}
const obs = new MutationObserver(guard(() => update(d)));
obs.observe(d.body, { childList: true, subtree: true, characterData: true });
docs.set(d, obs);
update(d);
};
const scan = () => {
for (const [d, obs] of docs) if (!d.defaultView || d.defaultView.closed) { obs.disconnect(); docs.delete(d); }
for (const p of g_PopupManager.GetPopups())
if (/barpopup/.test(p.m_strName ?? '')) guard(() => attach(p.window?.document))();
};
const timer = setInterval(scan, 1000);
const stop = () => {
clearInterval(timer);
for (const [d, obs] of docs) {
obs.disconnect();
try {
for (const p of [...d.getElementsByClassName(PIN)]) p.remove();
for (const el of d.querySelectorAll(`[${HIDDEN}]`)) el.removeAttribute(HIDDEN);
d.getElementById(STYLE_ID)?.remove();
} catch {}
}
docs.clear();
if (window.__sfuiPinnedDesktop === state) delete window.__sfuiPinnedDesktop;
};
const state = { version: VERSION, position: POS, scan, stop, docs };
window.__sfuiPinnedDesktop = state;
scan();
return `patched (${POS})`;
})