quickshell: survive Hyprland dropping the focus grab

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.
This commit is contained in:
rope 2026-08-01 21:16:37 +01:00
parent a040997551
commit a8d7d8ca64

View file

@ -1580,6 +1580,7 @@ in
// `-> open` with no matching focusgrab line after it, // `-> open` with no matching focusgrab line after it,
// the panel really did open and something drew over it. // the panel really did open and something drew over it.
traceFrom = Date.now(); traceFrom = Date.now();
focusGrab.rearms = 0;
console.log("launcher toggle -> " + (open ? "open" : "closed")); console.log("launcher toggle -> " + (open ? "open" : "closed"));
if (open) { if (open) {
searchInput.text = ""; searchInput.text = "";
@ -2943,26 +2944,73 @@ in
// window (or anywhere outside the bar) clears the grab and // window (or anywhere outside the bar) clears the grab and
// closes whatever is open. Suspended while screenshotPinned so // closes whatever is open. Suspended while screenshotPinned so
// slurp can grab input without dismissing the menu. // slurp can grab input without dismissing the menu.
// Parked for one tick to force `active` falsetrue, 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 { HyprlandFocusGrab {
active: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned id: focusGrab
active: (launcherPanel.open || bar.activeDropdown !== null)
&& !bar.screenshotPinned && !bar.grabParked
windows: [bar] 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: { onCleared: {
if (bar.screenshotPinned) return; if (bar.screenshotPinned) return;
// The only path that closes a panel without the user const dt = Date.now() - focusGrab.armedAt;
// asking, so it is worth a line in the journal. An app const anyOpen = launcherPanel.open || bar.activeDropdown !== null;
// spamming window-activation events as it starts clears
// the grab exactly like a click-outside does (that is // A clear arriving ~1ms after the grab was armed is not a
// what the steam_app_0 suppress_event rule in // human clicking outside nobody clicks within a
// hyprland.nix exists for), and only the timing tells // 60th of a second of the panel appearing. It is Hyprland
// them apart a clear within a few ms of the toggle // dropping the grab out from under us, and the panel has
// above was not a human clicking. // to survive it. Measured cause (Hyprland 0.55.4):
if (launcherPanel.open || bar.activeDropdown !== null) { //
// Who holds focus at clear time is the whole // CXDGShellProtocol::onPopupDestroy() calls
// question: a competitor that stole it back, or // g_pSeatManager->setGrab(nullptr) unconditionally when
// nothing at all (a grab the compositor refused). // 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 top = Hyprland.activeToplevel;
const cls = top && top.lastIpcObject ? top.lastIpcObject["class"] : "?"; 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 + "ms: launcher=" + launcherPanel.open
+ " dropdown=" + (bar.activeDropdown !== null) + " dropdown=" + (bar.activeDropdown !== null)
+ " focus=" + (top ? cls + " / " + top.title : "none")); + " focus=" + (top ? cls + " / " + top.title : "none"));