diff --git a/README.md b/README.md index aa5dd30..1ddf41a 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,12 @@ the two files below). steamFrame = { keyboardLayout = "de"; steamKeyboardPatch.enable = true; - launcherMenu = { sort = true; pinDesktop = "bottom"; }; + launcherMenu = { + sort = true; + pinDesktop = "bottom"; + closeOnLaunch = true; + launchDebounce = 10; + }; firefox.enable = true; # Only relevant with Steam Developer Mode on (see hidden-apps below). 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.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.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.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. | @@ -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 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`: - `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 controller navigation: it works with the laser pointer, but thumbstick / D-pad focus can't reach it. +- `closeOnLaunch = true` / `launchDebounce = `: 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. ### Hidden apps (`hiddenApps`) diff --git a/modules/launcher-menu.nix b/modules/launcher-menu.nix index cf85c46..47249a0 100644 --- a/modules/launcher-menu.nix +++ b/modules/launcher-menu.nix @@ -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; }; } diff --git a/modules/launcher-menu/launch/patch.js b/modules/launcher-menu/launch/patch.js new file mode 100644 index 0000000..cc0becd --- /dev/null +++ b/modules/launcher-menu/launch/patch.js @@ -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: ()({ 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'})`; +}) diff --git a/modules/launcher-menu/launch/unpatch.js b/modules/launcher-menu/launch/unpatch.js new file mode 100644 index 0000000..257a206 --- /dev/null +++ b/modules/launcher-menu/launch/unpatch.js @@ -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'; +})() diff --git a/template/home.nix b/template/home.nix index 04db924..d762855 100644 --- a/template/home.nix +++ b/template/home.nix @@ -17,8 +17,14 @@ # steamFrame = { # keyboardLayout = "de"; # XKB layout for the Steam session # steamKeyboardPatch.enable = true; # Esc/Ctrl/Alt/arrows on the VR keyboard - # # VR "+" menu: sorted by name, Desktop pinned below the list: - # launcherMenu = { sort = true; pinDesktop = "bottom"; }; + # # VR "+" menu: sorted by name, Desktop pinned below the list, closed + # # 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 # # Hidden from the "+" menu (with Steam Developer Mode on): # hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];