From a8d7d8ca64eb4f01d98fd30ef723add14ab9bdbb Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 1 Aug 2026 21:16:37 +0100 Subject: [PATCH] quickshell: survive Hyprland dropping the focus grab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyprland 0.55.4 CXDGShellProtocol::onPopupDestroy() calls setGrab(nullptr) unconditionally when the popup owning the xdg-popup grab dies, without checking that grab is still the installed one. So opening the launcher while some app holds a grabbing popup steals the seat grab, that popup gets popup_done, the client destroys it, and its destructor clears OUR grab — one client round trip later, measured at 1-2ms. A clear that fast is not a click-outside, so re-arm instead of closing. The offending popup is gone by then, so the second grab sticks. Capped at 3 tries so a hostile client can't spin it. --- settings/quickshell.nix | 76 +++++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 0525c17..0fe1231 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -1580,6 +1580,7 @@ in // `-> open` with no matching focusgrab line after it, // the panel really did open and something drew over it. traceFrom = Date.now(); + focusGrab.rearms = 0; console.log("launcher toggle -> " + (open ? "open" : "closed")); if (open) { searchInput.text = ""; @@ -2943,26 +2944,73 @@ in // window (or anywhere outside the bar) clears the grab and // closes whatever is open. Suspended while screenshotPinned so // slurp can grab input without dismissing the menu. + // Parked for one tick to force `active` false→true, which makes + // quickshell tear down the old grab and request a fresh one. + // Folded into the binding rather than assigning `active` + // directly, because an imperative write would destroy the + // binding and the grab would never track `open` again. + property bool grabParked: false + Timer { + id: _grabRearm + interval: 1 + onTriggered: bar.grabParked = false + } + HyprlandFocusGrab { - active: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned + id: focusGrab + active: (launcherPanel.open || bar.activeDropdown !== null) + && !bar.screenshotPinned && !bar.grabParked windows: [bar] + + // When the grab was last requested, to tell a real + // click-outside from the compositor dropping it. + property double armedAt: 0 + property int rearms: 0 + onActiveChanged: if (active) armedAt = Date.now() + onCleared: { if (bar.screenshotPinned) return; - // The only path that closes a panel without the user - // asking, so it is worth a line in the journal. An app - // spamming window-activation events as it starts clears - // the grab exactly like a click-outside does (that is - // what the steam_app_0 suppress_event rule in - // hyprland.nix exists for), and only the timing tells - // them apart — a clear within a few ms of the toggle - // above was not a human clicking. - if (launcherPanel.open || bar.activeDropdown !== null) { - // Who holds focus at clear time is the whole - // question: a competitor that stole it back, or - // nothing at all (a grab the compositor refused). + const dt = Date.now() - focusGrab.armedAt; + const anyOpen = launcherPanel.open || bar.activeDropdown !== null; + + // A clear arriving ~1ms after the grab was armed is not a + // human clicking outside — nobody clicks within a + // 60th of a second of the panel appearing. It is Hyprland + // dropping the grab out from under us, and the panel has + // to survive it. Measured cause (Hyprland 0.55.4): + // + // CXDGShellProtocol::onPopupDestroy() calls + // g_pSeatManager->setGrab(nullptr) unconditionally when + // the popup that owned the *xdg-popup* grab dies. It + // never checks that the popup grab is still the + // installed one. So: an app has a grabbing popup open, + // opening the launcher steals the seat grab, Hyprland + // sends that popup xdg_popup.popup_done, the client + // destroys it, and its destructor blindly clears + // whatever grab is installed — ours. One client round + // trip, hence the 1-2ms. + // + // Re-arming wins the race because the offending popup is + // already gone by then. Capped, so a genuinely hostile + // client can't spin us: after 3 tries the panel closes + // and the log says why. + if (dt < 60 && anyOpen) { + if (focusGrab.rearms < 3) { + focusGrab.rearms++; + console.log("focusgrab cleared +" + dt + "ms — too fast to be a click, re-arming (" + + focusGrab.rearms + "/3)"); + bar.grabParked = true; + _grabRearm.restart(); + return; + } + console.warn("focusgrab cleared +" + dt + "ms and 3 re-arms failed; closing"); + } + + focusGrab.rearms = 0; + if (anyOpen) { const top = Hyprland.activeToplevel; const cls = top && top.lastIpcObject ? top.lastIpcObject["class"] : "?"; - console.log("focusgrab cleared +" + (Date.now() - launcherPanel.traceFrom) + console.log("focusgrab cleared +" + dt + "ms: launcher=" + launcherPanel.open + " dropdown=" + (bar.activeDropdown !== null) + " focus=" + (top ? cls + " / " + top.title : "none"));