Skip to content

Commit 77d9920

Browse files
committed
Make the widgets work in KDE Wayland session
This has two parts: 1. Set GDK_BACKEND=x11, so that GDK3 chooses to use X11 as its backend. This must be done while only one thread is running, as otherwise undefined behavior results. It also must be done before gi.overrides.Gdk is imported, as otherwise it will be too late to set the GDK backend. Therefore, this code is run very early, and RuntimeError is raised if the preconditions are violated. 2. Create a fullscreen invisible window for mouse input. This works around Xwayland not passing all pointer input to X11. The menu is dismissed if the user clicks on the fullscreen window. This hack is only used if WAYLAND_DISPLAY is set.
1 parent 3d5af8f commit 77d9920

File tree

7 files changed

+199
-1
lines changed

7 files changed

+199
-1
lines changed

qui/clipboard.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
via Qubes RPC """
2727
# pylint: disable=invalid-name,wrong-import-position
2828

29+
# Must be imported before creating threads
30+
from .tray.gtk3_xwayland_menu_dismisser import (
31+
get_fullscreen_window_hack,
32+
) # isort:skip
33+
2934
import asyncio
3035
import contextlib
3136
import json
@@ -285,6 +290,7 @@ def __init__(self, wm, qapp, dispatcher, **properties):
285290
self.set_application_id("org.qubes.qui.clipboard")
286291
self.register() # register Gtk Application
287292

293+
self.fullscreen_window_hack = get_fullscreen_window_hack()
288294
self.qapp = qapp
289295
self.vm = self.qapp.domains[self.qapp.local_name]
290296
self.dispatcher = dispatcher
@@ -373,6 +379,7 @@ def setup_ui(self, *_args, **_kwargs):
373379
)
374380

375381
self.menu = Gtk.Menu()
382+
self.fullscreen_window_hack.show_for_widget(self.menu)
376383

377384
title_label = Gtk.Label(xalign=0)
378385
title_label.set_markup(_("<b>Current clipboard</b>"))

qui/devices/device_widget.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
#
1818
# You should have received a copy of the GNU Lesser General Public License along
1919
# with this program; if not, see <http://www.gnu.org/licenses/>.
20+
21+
# Must be imported before creating threads
22+
from ..tray.gtk3_xwayland_menu_dismisser import (
23+
get_fullscreen_window_hack,
24+
) # isort:skip
25+
2026
from typing import Set, List, Dict
2127
import asyncio
2228
import sys
@@ -82,6 +88,7 @@ class DevicesTray(Gtk.Application):
8288

8389
def __init__(self, app_name, qapp, dispatcher):
8490
super().__init__()
91+
self.fullscreen_window_hack = get_fullscreen_window_hack()
8592
self.name: str = app_name
8693

8794
# maps: port to connected device (e.g., sys-usb:sda -> block device)
@@ -324,6 +331,7 @@ def load_css(widget) -> str:
324331
def show_menu(self, _unused, _event):
325332
"""Show menu at mouse pointer."""
326333
tray_menu = Gtk.Menu()
334+
self.fullscreen_window_hack.show_for_widget(tray_menu)
327335
theme = self.load_css(tray_menu)
328336
tray_menu.set_reserve_toggle_size(False)
329337

qui/tray/disk_space.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
# pylint: disable=wrong-import-position,import-error
2+
3+
# Must be imported before creating threads
4+
from .gtk3_xwayland_menu_dismisser import (
5+
get_fullscreen_window_hack,
6+
) # isort:skip
7+
28
import sys
39
import subprocess
410
from typing import List
@@ -349,6 +355,7 @@ class DiskSpace(Gtk.Application):
349355
def __init__(self, **properties):
350356
super().__init__(**properties)
351357

358+
self.fullscreen_window_hack = get_fullscreen_window_hack()
352359
self.pool_warned = False
353360
self.vms_warned = set()
354361

@@ -442,6 +449,7 @@ def make_menu(self, _unused, _event):
442449
vm_data = VMUsageData(self.qubes_app)
443450

444451
menu = Gtk.Menu()
452+
self.fullscreen_window_hack.show_for_widget(menu)
445453

446454
menu.append(self.make_top_box(pool_data))
447455

qui/tray/domains.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
# -*- coding: utf-8 -*-
33
# pylint: disable=wrong-import-position,import-error,superfluous-parens
44
""" A menu listing domains """
5+
6+
# Must be imported before creating threads
7+
from .gtk3_xwayland_menu_dismisser import (
8+
get_fullscreen_window_hack,
9+
) # isort:skip
10+
511
import asyncio
612
import os
713
import subprocess
@@ -637,6 +643,8 @@ def __init__(self, app_name, qapp, dispatcher, stats_dispatcher):
637643

638644
self.tray_menu = Gtk.Menu()
639645
self.tray_menu.set_reserve_toggle_size(False)
646+
self.fullscreen_window_hack = get_fullscreen_window_hack()
647+
self.fullscreen_window_hack.show_for_widget(self.tray_menu)
640648

641649
self.icon_cache = IconCache()
642650

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import os
2+
import sys
3+
from typing import Optional
4+
5+
# If gi.override.Gdk has been imported, the GDK
6+
# backend has already been set and it is too late
7+
# to override it.
8+
assert (
9+
"gi.override.Gdk" not in sys.modules
10+
), "must import this module before loading GDK"
11+
12+
# Modifying the environment while multiple threads
13+
# are running leads to use-after-free in glibc, so
14+
# ensure that only one thread is running.
15+
assert (
16+
len(os.listdir("/proc/self/task")) == 1
17+
), "multiple threads already running"
18+
19+
# Only the X11 backend is supported
20+
os.environ["GDK_BACKEND"] = "x11"
21+
22+
import gi
23+
24+
gi.require_version("Gdk", "3.0")
25+
gi.require_version("Gtk", "3.0")
26+
from gi.repository import Gtk, Gdk
27+
28+
29+
is_xwayland = "WAYLAND_DISPLAY" in os.environ
30+
31+
32+
class X11FullscreenWindowHack:
33+
"""
34+
No-op implementation of the hack, for use on stock X11.
35+
"""
36+
37+
def clear_widget(self) -> None:
38+
pass
39+
40+
def show_for_widget(self, _widget: Gtk.Widget, /) -> None:
41+
pass
42+
43+
44+
class X11FullscreenWindowHackXWayland(X11FullscreenWindowHack):
45+
"""
46+
GTK3 menus have a bug under Xwayland: if the user clicks on a native
47+
Wayland surface, the menu is not dismissed. This class works around
48+
the problem by using a fullscreen transparent override-redirect
49+
window. This is a horrible hack because if the application freezes,
50+
the user won't be able to click on any other applications. That's
51+
no worse than under native X11, though.
52+
"""
53+
54+
_window: Gtk.Window
55+
_widget: Optional[Gtk.Widget]
56+
_unmap_signal_id: int
57+
_map_signal_id: int
58+
59+
def __init__(self) -> None:
60+
self._widget = None
61+
# Get the default GDK screen.
62+
screen = Gdk.Screen.get_default()
63+
# This is deprecated, but it gets the total width and height
64+
# of all screens, which is what we want. It will go away in
65+
# GTK4, but this code will never be ported to GTK4.
66+
width = screen.get_width()
67+
height = screen.get_height()
68+
# Create a window that will fill the screen.
69+
window = self._window = Gtk.Window()
70+
# Move that window to the top left.
71+
# pylint: disable=no-member
72+
window.move(0, 0)
73+
# Make the window fill the whole screen.
74+
# pylint: disable=no-member
75+
window.resize(width, height)
76+
# Request that the window not be decorated by the window manager.
77+
window.set_decorated(False)
78+
# Connect a signal so that the window and menu can be
79+
# unmapped (no longer shown on screen) once clicked.
80+
window.connect("button-press-event", self.on_button_press)
81+
# When the window is created, mark it as override-redirect
82+
# (invisible to the window manager) and transparent.
83+
window.connect("realize", self._on_realize)
84+
# The signal IDs of the map and unmap signals, so that this class
85+
# can stop listening to signals from the old menu when it is
86+
# replaced or unregistered.
87+
self._unmap_signal_id = self._map_signal_id = 0
88+
89+
def clear_widget(self) -> None:
90+
"""
91+
Clears the connected widget. Automatically called by
92+
show_for_widget().
93+
"""
94+
widget = self._widget
95+
map_signal_id = self._map_signal_id
96+
unmap_signal_id = self._unmap_signal_id
97+
98+
# Double-disconnect is C-level undefined behavior, so ensure
99+
# it cannot happen. It is better to leak memory if an exception
100+
# is thrown here. GObject.disconnect_by_func() is buggy
101+
# (https://gitlab.gnome.org/GNOME/pygobject/-/issues/106),
102+
# so avoid it.
103+
if widget is not None:
104+
if map_signal_id != 0:
105+
# Clear the signal ID to avoid double-disconnect
106+
# if this method is interrupted and then called again.
107+
self._map_signal_id = 0
108+
widget.disconnect(map_signal_id)
109+
if unmap_signal_id != 0:
110+
# Clear the signal ID to avoid double-disconnect
111+
# if this method is interrupted and then called again.
112+
self._unmap_signal_id = 0
113+
widget.disconnect(unmap_signal_id)
114+
self._widget = None
115+
116+
def show_for_widget(self, widget: Gtk.Widget, /) -> None:
117+
# Clear any existing connections.
118+
self.clear_widget()
119+
# Store the new widget.
120+
self._widget = widget
121+
# Connect map and unmap signals.
122+
self._unmap_signal_id = widget.connect("unmap", self._hide)
123+
self._map_signal_id = widget.connect("map", self._show)
124+
125+
@staticmethod
126+
def _on_realize(window: Gtk.Window, /) -> None:
127+
window.set_opacity(0)
128+
gdk_window = window.get_window()
129+
gdk_window.set_override_redirect(True)
130+
window.get_root_window().set_cursor(
131+
Gdk.Cursor.new_for_display(
132+
display=gdk_window.get_display(),
133+
cursor_type=Gdk.CursorType.ARROW,
134+
)
135+
)
136+
137+
def _show(self, widget: Gtk.Widget, /) -> None:
138+
assert widget is self._widget, "signal not properly disconnected"
139+
# pylint: disable=no-member
140+
self._window.show_all()
141+
142+
def _hide(self, widget: Gtk.Widget, /) -> None:
143+
assert widget is self._widget, "signal not properly disconnected"
144+
self._window.hide()
145+
146+
# pylint: disable=line-too-long
147+
def on_button_press(
148+
self, window: Gtk.Window, _event: Gdk.EventButton, /
149+
) -> None:
150+
# Hide the window and the widget.
151+
window.hide()
152+
self._widget.hide()
153+
154+
155+
def get_fullscreen_window_hack() -> X11FullscreenWindowHack:
156+
if is_xwayland:
157+
return X11FullscreenWindowHackXWayland()
158+
return X11FullscreenWindowHack()

qui/tray/updates.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
# pylint: disable=wrong-import-position,import-error
44
""" A widget that monitors update availability and notifies the user
55
about new updates to templates and standalone VMs"""
6+
7+
# Must be imported before creating threads
8+
from .gtk3_xwayland_menu_dismisser import (
9+
get_fullscreen_window_hack,
10+
) # isort:skip
11+
612
import asyncio
713
import sys
814
import subprocess
@@ -62,6 +68,7 @@ def __init__(self, app_name, qapp, dispatcher):
6268
super().__init__()
6369
self.name = app_name
6470

71+
self.fullscreen_window_hack = get_fullscreen_window_hack()
6572
self.dispatcher = dispatcher
6673
self.qapp = qapp
6774

@@ -80,6 +87,7 @@ def __init__(self, app_name, qapp, dispatcher):
8087
self.obsolete_vms = set()
8188

8289
self.tray_menu = Gtk.Menu()
90+
self.fullscreen_window_hack.show_for_widget(self.tray_menu)
8391

8492
def run(self): # pylint: disable=arguments-differ
8593
self.check_vms_needing_update()
@@ -122,6 +130,7 @@ def setup_menu(self):
122130

123131
def show_menu(self, _unused, _event):
124132
self.tray_menu = Gtk.Menu()
133+
self.fullscreen_window_hack.show_for_widget(self.tray_menu)
125134

126135
self.setup_menu()
127136

rpm_spec/qubes-desktop-linux-manager.spec.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || :
137137
%{python3_sitelib}/qui/devices/actionable_widgets.py
138138
%{python3_sitelib}/qui/devices/backend.py
139139
%{python3_sitelib}/qui/devices/device_widget.py
140-
%{python3_sitelib}/qui/devices/device_widget.py
141140
%{python3_sitelib}/qui/qubes-devices-dark.css
142141
%{python3_sitelib}/qui/qubes-devices-light.css
143142
%{python3_sitelib}/qui/devices/AttachConfirmationWindow.glade
@@ -155,6 +154,7 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || :
155154
%{python3_sitelib}/qui/tray/domains.py
156155
%{python3_sitelib}/qui/tray/disk_space.py
157156
%{python3_sitelib}/qui/tray/updates.py
157+
%{python3_sitelib}/qui/tray/gtk3_xwayland_menu_dismisser.py
158158

159159
%dir %{python3_sitelib}/qubes_config
160160
%dir %{python3_sitelib}/qubes_config/__pycache__

0 commit comments

Comments
 (0)