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,
|
// `-> 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` 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 {
|
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"));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue