Skip to content

Commit 7652c60

Browse files
committed
Merge remote-tracking branch 'origin/pr/234'
* origin/pr/234: Make the widgets work in KDE Wayland session
2 parents add35fb + 77d9920 commit 7652c60

7 files changed

+199
-1
lines changed

qui/clipboard.py

+7
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

+8
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

+8
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

+8
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
@@ -643,6 +649,8 @@ def __init__(self, app_name, qapp, dispatcher, stats_dispatcher):
643649

644650
self.tray_menu = Gtk.Menu()
645651
self.tray_menu.set_reserve_toggle_size(False)
652+
self.fullscreen_window_hack = get_fullscreen_window_hack()
653+
self.fullscreen_window_hack.show_for_widget(self.tray_menu)
646654

647655
self.icon_cache = IconCache()
648656

+158
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

+9
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

+1-1
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)