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:
parent
a040997551
commit
a8d7d8ca64
1 changed files with 62 additions and 14 deletions
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue