launcher-menu: closeOnLaunch and launchDebounce options

Stock, clicking a program in the VR dashboard's "+" menu only calls
SteamClient.Apps.LaunchNonSteamApp; the menu stays open until the
program's window appears, so users click again and launch it twice.

New launch/ patch (SharedJSContext) wraps LaunchNonSteamApp, whose only
caller is this menu: closeOnLaunch closes the menu through the "+" bar
button's own popup handle right after a launch; launchDebounce ignores
repeated launches of the same command line within N seconds (logged to
the steam-ui-patches journal). Both opt-in, like the other launcherMenu
options.
This commit is contained in:
Pierre Kisters committed 2026-09-27 19:45:09 +02:00
1 parent 24d6504f30
commit dd6f3602aa
5 files changed
+169 -5

No files matched your search

+40 -1
View File
@@ -7,7 +7,12 @@
# - pinned-desktop/: hides Desktop in the scrolling list and pins a copy above
# or below it, with a separator; clicking the copy clicks the hidden
# original. The position is passed by calling the patch's function.
# Both are reverted when turned off (next switch).
# - launch/: wraps SteamClient.Apps.LaunchNonSteamApp (only called by this
# menu) to close the menu right after a program is started, and/or to
# ignore repeated launches of the same program within a few seconds (stock,
# the menu stays open until the program's window appears, inviting double
# launches). Options are passed by calling the patch's function.
# All are reverted when turned off (next switch).
# Depends on Steam UI internals; tested with Steam client 1790377368.
{ config, lib, pkgs, ... }:
let
@@ -38,6 +43,29 @@ in {
patch of Steam's UI (steamFrame.uiPatches).
'';
};
closeOnLaunch = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Close the "+" menu as soon as a program in it is activated (pointer,
controller or the pinned Desktop entry). Stock, it stays open until the
program's window appears, so it looks as if the click did nothing and
programs get launched twice. Runtime patch of Steam's UI
(steamFrame.uiPatches); off by default like the other launcherMenu
options, so nothing is injected unless asked for.
'';
};
launchDebounce = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
example = 10;
description = ''
Seconds during which another launch of the same program (same command
line) from the "+" menu is ignored, counted from the last launch that
went through; ignored launches are logged (steam-ui-patches journal).
0 disables it. Runtime patch of Steam's UI (steamFrame.uiPatches).
'';
};
};
config.steamFrame.uiPatches.patches =
@@ -54,5 +82,16 @@ in {
(${builtins.readFile ./launcher-menu/pinned-desktop/patch.js})(${builtins.toJSON { position = cfg.pinDesktop; }})
'';
unpatch = ./launcher-menu/pinned-desktop/unpatch.js;
}
++ lib.optional (cfg.closeOnLaunch || cfg.launchDebounce > 0) {
name = "launcher-menu-launch";
target = sharedJSContext;
patch = pkgs.writeText "launcher-menu-launch.js" ''
(${builtins.readFile ./launcher-menu/launch/patch.js})(${builtins.toJSON {
inherit (cfg) closeOnLaunch;
debounceSeconds = cfg.launchDebounce;
}})
'';
unpatch = ./launcher-menu/launch/unpatch.js;
};
}
+89
View File
@@ -0,0 +1,89 @@
// launch: what happens when a program of the VR dashboard's "+" menu
// (#VRDashboard_LaunchNonSteamApp) is activated.
//
// This file is a function expression; launcher-menu.nix calls it with the
// options: (<this file>)({ closeOnLaunch: true, debounceSeconds: 10 }).
//
// Stock, an item's onActivate only calls
// SteamClient.Apps.LaunchNonSteamApp(strCmdline) (plus the nav sound); the
// "+" popup stays open until the new window takes over, so users click again
// and start the program twice. That call is the only use of
// LaunchNonSteamApp in Steam's UI and is looked up at call time, so this
// patch wraps it in SharedJSContext (the dashboard bar's realm):
// - debounceSeconds > 0: a launch of the same command line within that many
// seconds of the last accepted one is ignored (console.info, and reported
// in the steam-ui-patches journal on the next re-injection);
// - closeOnLaunch: after a launch (ignored or not), the "+" popup is closed
// through the bar button's own popup handle (closePopup(), what stock does
// after adding a desktop window). The handle is found in the dashboard bar
// document: the button's React fiber ancestors include the "+" component
// (prop allowLaunchProgram) and the bar button (prop refBarPopopHandle).
// Works for every activation path (pointer, controller, the pinned Desktop
// copy, which clicks the stock item). The original is kept as __sfuiOrig
// (unpatch.js restores it). Idempotent; bump VERSION when changing the
// wrapper.
((opts) => {
const VERSION = 1;
const NAME = 'launcher-menu-launch';
const CLOSE = opts.closeOnLaunch === true;
const DEBOUNCE_MS = Math.max(0, Number(opts.debounceSeconds) || 0) * 1000;
const Apps = window.SteamClient?.Apps;
const cur = Apps?.LaunchNonSteamApp;
if (typeof cur !== 'function') return 'SteamClient.Apps.LaunchNonSteamApp missing';
const st = cur.__sfuiPatch === NAME ? cur.__sfuiState : null;
if (st && cur.__sfuiVersion === VERSION && st.close === CLOSE && st.debounceMs === DEBOUNCE_MS) {
// Report ignored launches since the last evaluation (the injector logs
// results that differ from the previous one).
const msgs = st.log.splice(0);
return msgs.length ? msgs.join('; ') : 'unchanged';
}
const orig = cur.__sfuiPatch === NAME ? cur.__sfuiOrig : cur;
const fiberOf = (el) => { const k = Object.keys(el).find((k) => k.startsWith('__reactFiber$')); return k && el[k]; };
// Popup handles of the "+" bar button(s) (normally one).
const plusHandles = () => {
const hs = [];
for (const p of window.g_PopupManager?.GetPopups() ?? []) {
if (!/gamepadui\.bar\./.test(p.m_strName ?? '')) continue;
let d;
try { d = p.window?.document; } catch { continue; }
for (const el of d?.querySelectorAll('.VRDashboardBarSmallButton') ?? []) {
let h = null;
for (let f = fiberOf(el), i = 0; f && i < 20; f = f.return, i++) {
const mp = f.memoizedProps;
if (!mp || typeof mp !== 'object') continue;
if ('refBarPopopHandle' in mp) h = mp.refBarPopopHandle?.current ?? null;
if ('allowLaunchProgram' in mp) { if (h && !hs.includes(h)) hs.push(h); break; }
}
}
}
return hs;
};
const closeMenu = () => {
for (const h of plusHandles()) if (h.BPopupOpen?.()) h.closePopup();
};
const state = { close: CLOSE, debounceMs: DEBOUNCE_MS, last: new Map(), log: [], plusHandles, closeMenu };
const f = function LaunchNonSteamApp(cmdline, ...rest) {
let result;
const now = Date.now();
const key = String(cmdline);
const prev = state.last.get(key);
if (DEBOUNCE_MS > 0 && prev !== undefined && now - prev < DEBOUNCE_MS) {
const msg = `ignored repeated launch of ${JSON.stringify(key)} (${((now - prev) / 1000).toFixed(1)} s after the last one, at ${new Date(now).toLocaleTimeString()})`;
console.info(`sfui ${NAME}: ${msg}`);
state.log.push(msg);
if (state.log.length > 20) state.log.shift();
} else {
state.last.set(key, now);
for (const [k, t] of state.last) if (now - t >= DEBOUNCE_MS && k !== key) state.last.delete(k);
result = orig.call(this, cmdline, ...rest);
}
if (CLOSE) { try { closeMenu(); } catch (e) { console.error(`sfui ${NAME}:`, e); } }
return result;
};
f.__sfuiPatch = NAME; f.__sfuiVersion = VERSION; f.__sfuiOrig = orig; f.__sfuiState = state;
Apps.LaunchNonSteamApp = f;
const what = [CLOSE && 'close on launch', DEBOUNCE_MS > 0 && `debounce ${DEBOUNCE_MS / 1000} s`].filter(Boolean);
return `patched (${what.join(', ') || 'no-op'})`;
})
+9
View File
@@ -0,0 +1,9 @@
// Reverts launch/patch.js (restores the original
// SteamClient.Apps.LaunchNonSteamApp). Safe when not patched.
(() => {
const Apps = window.SteamClient?.Apps;
const f = Apps?.LaunchNonSteamApp;
if (f?.__sfuiPatch !== 'launcher-menu-launch') return 'not patched';
Apps.LaunchNonSteamApp = f.__sfuiOrig;
return 'unpatched';
})()