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

+23 -2
View File
@@ -127,7 +127,12 @@ the two files below).
steamFrame = { steamFrame = {
keyboardLayout = "de"; keyboardLayout = "de";
steamKeyboardPatch.enable = true; steamKeyboardPatch.enable = true;
launcherMenu = { sort = true; pinDesktop = "bottom"; }; launcherMenu = {
sort = true;
pinDesktop = "bottom";
closeOnLaunch = true;
launchDebounce = 10;
};
firefox.enable = true; firefox.enable = true;
# Only relevant with Steam Developer Mode on (see hidden-apps below). # Only relevant with Steam Developer Mode on (see hidden-apps below).
hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ]; hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
@@ -174,6 +179,8 @@ menu list every desktop entry, including terminals such as Konsole.
| `steamFrame.uiPatches.patches` | list of submodules | `[ ]` | Runtime patches of Steam's web UIs over their local DevTools ports, see [UI patches](#ui-patches-uipatchespatches). | | `steamFrame.uiPatches.patches` | list of submodules | `[ ]` | Runtime patches of Steam's web UIs over their local DevTools ports, see [UI patches](#ui-patches-uipatchespatches). |
| `steamFrame.launcherMenu.sort` | bool | `false` | Sort the VR "+" menu alphabetically. | | `steamFrame.launcherMenu.sort` | bool | `false` | Sort the VR "+" menu alphabetically. |
| `steamFrame.launcherMenu.pinDesktop` | null or `"top"` / `"bottom"` | `null` | Pin "Desktop" above or below the "+" menu's scrolling list (always visible). `null`: a normal list entry. | | `steamFrame.launcherMenu.pinDesktop` | null or `"top"` / `"bottom"` | `null` | Pin "Desktop" above or below the "+" menu's scrolling list (always visible). `null`: a normal list entry. |
| `steamFrame.launcherMenu.closeOnLaunch` | bool | `false` | Close the "+" menu as soon as a program in it is clicked. |
| `steamFrame.launcherMenu.launchDebounce` | unsigned int (seconds) | `0` | Ignore repeated launches of the same program from the "+" menu within this time. `0`: off. |
| `steamFrame.hiddenApps` | list of str | `[ ]` | Desktop entry ids (without `.desktop`) to hide from the "+" and KDE menus. | | `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. | | `steamFrame.clipboardSync.enable` | bool | `true` | Clipboard bridge between the Steam session and the nested desktop. |
| `steamFrame.clipboardSync.package` | package | built from `dnut/clipboard-sync` | The clipboard-sync package. | | `steamFrame.clipboardSync.package` | package | built from `dnut/clipboard-sync` | The clipboard-sync package. |
@@ -346,6 +353,11 @@ GLib hash-table order, effectively random and changing with installed apps,
with "Desktop" (the nested Plasma session) somewhere in the middle of a with "Desktop" (the nested Plasma session) somewhere in the middle of a
scrolling list. scrolling list.
Also, clicking a program only calls `SteamClient.Apps.LaunchNonSteamApp()`:
the menu stays open until the program's window appears, which can take a
while, so it looks as if the click did nothing, and a second click starts
the program twice.
**Fix:** UI patches (see above) in Steam's `SharedJSContext`: **Fix:** UI patches (see above) in Steam's `SharedJSContext`:
- `sort = true`: a wrapper around `ScanForInstalledNonSteamApps` sorts the - `sort = true`: a wrapper around `ScanForInstalledNonSteamApps` sorts the
@@ -360,8 +372,17 @@ scrolling list.
**Limitation:** the pinned copy is a plain DOM element, not part of Steam's **Limitation:** the pinned copy is a plain DOM element, not part of Steam's
controller navigation: it works with the laser pointer, but thumbstick / controller navigation: it works with the laser pointer, but thumbstick /
D-pad focus can't reach it. D-pad focus can't reach it.
- `closeOnLaunch = true` / `launchDebounce = <seconds>`: a wrapper around
`LaunchNonSteamApp` (called only by this menu, for list entries and the
pinned Desktop alike). With `closeOnLaunch`, it closes the menu right after
the launch through the "+" button's own popup handle (as Steam does after
adding a desktop window). With `launchDebounce`, another launch of the same
command line within that many seconds of the last one that went through is
ignored and logged (`journalctl --user -u steam-ui-patches`, on the next
re-injection, i.e. within 15 s). Consequence: a program that exits right
away can only be started again once the time is up.
Both are reverted when the options are turned off (next switch). All are reverted when the options are turned off (next switch).
Tested with Steam client 1790377368. Tested with Steam client 1790377368.
### Hidden apps (`hiddenApps`) ### Hidden apps (`hiddenApps`)
+40 -1
View File
@@ -7,7 +7,12 @@
# - pinned-desktop/: hides Desktop in the scrolling list and pins a copy above # - 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 # or below it, with a separator; clicking the copy clicks the hidden
# original. The position is passed by calling the patch's function. # 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. # Depends on Steam UI internals; tested with Steam client 1790377368.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
@@ -38,6 +43,29 @@ in {
patch of Steam's UI (steamFrame.uiPatches). 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 = config.steamFrame.uiPatches.patches =
@@ -54,5 +82,16 @@ in {
(${builtins.readFile ./launcher-menu/pinned-desktop/patch.js})(${builtins.toJSON { position = cfg.pinDesktop; }}) (${builtins.readFile ./launcher-menu/pinned-desktop/patch.js})(${builtins.toJSON { position = cfg.pinDesktop; }})
''; '';
unpatch = ./launcher-menu/pinned-desktop/unpatch.js; 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';
})()
+8 -2
View File
@@ -17,8 +17,14 @@
# steamFrame = { # steamFrame = {
# keyboardLayout = "de"; # XKB layout for the Steam session # keyboardLayout = "de"; # XKB layout for the Steam session
# steamKeyboardPatch.enable = true; # Esc/Ctrl/Alt/arrows on the VR keyboard # steamKeyboardPatch.enable = true; # Esc/Ctrl/Alt/arrows on the VR keyboard
# # VR "+" menu: sorted by name, Desktop pinned below the list: # # VR "+" menu: sorted by name, Desktop pinned below the list, closed
# launcherMenu = { sort = true; pinDesktop = "bottom"; }; # # on click, no second launch of the same program within 10 s:
# launcherMenu = {
# sort = true;
# pinDesktop = "bottom";
# closeOnLaunch = true;
# launchDebounce = 10;
# };
# firefox.enable = true; # launcher for the Firefox Flatpak # firefox.enable = true; # launcher for the Firefox Flatpak
# # Hidden from the "+" menu (with Steam Developer Mode on): # # Hidden from the "+" menu (with Steam Developer Mode on):
# hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ]; # hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];