diff --git a/qui/clipboard.py b/qui/clipboard.py
index 0118bf9..e561a41 100644
--- a/qui/clipboard.py
+++ b/qui/clipboard.py
@@ -26,6 +26,11 @@
via Qubes RPC """
# pylint: disable=invalid-name,wrong-import-position
+# Must be imported before creating threads
+from .tray.gtk3_xwayland_menu_dismisser import (
+ get_fullscreen_window_hack,
+) # isort:skip
+
import asyncio
import contextlib
import json
@@ -285,6 +290,7 @@ def __init__(self, wm, qapp, dispatcher, **properties):
self.set_application_id("org.qubes.qui.clipboard")
self.register() # register Gtk Application
+ self.fullscreen_window_hack = get_fullscreen_window_hack()
self.qapp = qapp
self.vm = self.qapp.domains[self.qapp.local_name]
self.dispatcher = dispatcher
@@ -373,6 +379,7 @@ def setup_ui(self, *_args, **_kwargs):
)
self.menu = Gtk.Menu()
+ self.fullscreen_window_hack.show_for_widget(self.menu)
title_label = Gtk.Label(xalign=0)
title_label.set_markup(_("Current clipboard"))
diff --git a/qui/devices/device_widget.py b/qui/devices/device_widget.py
index 6b44006..a296161 100644
--- a/qui/devices/device_widget.py
+++ b/qui/devices/device_widget.py
@@ -17,6 +17,12 @@
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program; if not, see .
+
+# Must be imported before creating threads
+from ..tray.gtk3_xwayland_menu_dismisser import (
+ get_fullscreen_window_hack,
+) # isort:skip
+
from typing import Set, List, Dict
import asyncio
import sys
@@ -82,6 +88,7 @@ class DevicesTray(Gtk.Application):
def __init__(self, app_name, qapp, dispatcher):
super().__init__()
+ self.fullscreen_window_hack = get_fullscreen_window_hack()
self.name: str = app_name
# maps: port to connected device (e.g., sys-usb:sda -> block device)
@@ -324,6 +331,7 @@ def load_css(widget) -> str:
def show_menu(self, _unused, _event):
"""Show menu at mouse pointer."""
tray_menu = Gtk.Menu()
+ self.fullscreen_window_hack.show_for_widget(tray_menu)
theme = self.load_css(tray_menu)
tray_menu.set_reserve_toggle_size(False)
diff --git a/qui/tray/disk_space.py b/qui/tray/disk_space.py
index 6e61e0f..459f496 100644
--- a/qui/tray/disk_space.py
+++ b/qui/tray/disk_space.py
@@ -1,4 +1,10 @@
# pylint: disable=wrong-import-position,import-error
+
+# Must be imported before creating threads
+from .gtk3_xwayland_menu_dismisser import (
+ get_fullscreen_window_hack,
+) # isort:skip
+
import sys
import subprocess
from typing import List
@@ -349,6 +355,7 @@ class DiskSpace(Gtk.Application):
def __init__(self, **properties):
super().__init__(**properties)
+ self.fullscreen_window_hack = get_fullscreen_window_hack()
self.pool_warned = False
self.vms_warned = set()
@@ -442,6 +449,7 @@ def make_menu(self, _unused, _event):
vm_data = VMUsageData(self.qubes_app)
menu = Gtk.Menu()
+ self.fullscreen_window_hack.show_for_widget(menu)
menu.append(self.make_top_box(pool_data))
diff --git a/qui/tray/domains.py b/qui/tray/domains.py
index 61bca07..5ec80f8 100644
--- a/qui/tray/domains.py
+++ b/qui/tray/domains.py
@@ -2,6 +2,12 @@
# -*- coding: utf-8 -*-
# pylint: disable=wrong-import-position,import-error,superfluous-parens
""" A menu listing domains """
+
+# Must be imported before creating threads
+from .gtk3_xwayland_menu_dismisser import (
+ get_fullscreen_window_hack,
+) # isort:skip
+
import asyncio
import os
import subprocess
@@ -637,6 +643,8 @@ def __init__(self, app_name, qapp, dispatcher, stats_dispatcher):
self.tray_menu = Gtk.Menu()
self.tray_menu.set_reserve_toggle_size(False)
+ self.fullscreen_window_hack = get_fullscreen_window_hack()
+ self.fullscreen_window_hack.show_for_widget(self.tray_menu)
self.icon_cache = IconCache()
diff --git a/qui/tray/gtk3_xwayland_menu_dismisser.py b/qui/tray/gtk3_xwayland_menu_dismisser.py
new file mode 100644
index 0000000..c1b553a
--- /dev/null
+++ b/qui/tray/gtk3_xwayland_menu_dismisser.py
@@ -0,0 +1,158 @@
+import os
+import sys
+from typing import Optional
+
+# If gi.override.Gdk has been imported, the GDK
+# backend has already been set and it is too late
+# to override it.
+assert (
+ "gi.override.Gdk" not in sys.modules
+), "must import this module before loading GDK"
+
+# Modifying the environment while multiple threads
+# are running leads to use-after-free in glibc, so
+# ensure that only one thread is running.
+assert (
+ len(os.listdir("/proc/self/task")) == 1
+), "multiple threads already running"
+
+# Only the X11 backend is supported
+os.environ["GDK_BACKEND"] = "x11"
+
+import gi
+
+gi.require_version("Gdk", "3.0")
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk
+
+
+is_xwayland = "WAYLAND_DISPLAY" in os.environ
+
+
+class X11FullscreenWindowHack:
+ """
+ No-op implementation of the hack, for use on stock X11.
+ """
+
+ def clear_widget(self) -> None:
+ pass
+
+ def show_for_widget(self, _widget: Gtk.Widget, /) -> None:
+ pass
+
+
+class X11FullscreenWindowHackXWayland(X11FullscreenWindowHack):
+ """
+ GTK3 menus have a bug under Xwayland: if the user clicks on a native
+ Wayland surface, the menu is not dismissed. This class works around
+ the problem by using a fullscreen transparent override-redirect
+ window. This is a horrible hack because if the application freezes,
+ the user won't be able to click on any other applications. That's
+ no worse than under native X11, though.
+ """
+
+ _window: Gtk.Window
+ _widget: Optional[Gtk.Widget]
+ _unmap_signal_id: int
+ _map_signal_id: int
+
+ def __init__(self) -> None:
+ self._widget = None
+ # Get the default GDK screen.
+ screen = Gdk.Screen.get_default()
+ # This is deprecated, but it gets the total width and height
+ # of all screens, which is what we want. It will go away in
+ # GTK4, but this code will never be ported to GTK4.
+ width = screen.get_width()
+ height = screen.get_height()
+ # Create a window that will fill the screen.
+ window = self._window = Gtk.Window()
+ # Move that window to the top left.
+ # pylint: disable=no-member
+ window.move(0, 0)
+ # Make the window fill the whole screen.
+ # pylint: disable=no-member
+ window.resize(width, height)
+ # Request that the window not be decorated by the window manager.
+ window.set_decorated(False)
+ # Connect a signal so that the window and menu can be
+ # unmapped (no longer shown on screen) once clicked.
+ window.connect("button-press-event", self.on_button_press)
+ # When the window is created, mark it as override-redirect
+ # (invisible to the window manager) and transparent.
+ window.connect("realize", self._on_realize)
+ # The signal IDs of the map and unmap signals, so that this class
+ # can stop listening to signals from the old menu when it is
+ # replaced or unregistered.
+ self._unmap_signal_id = self._map_signal_id = 0
+
+ def clear_widget(self) -> None:
+ """
+ Clears the connected widget. Automatically called by
+ show_for_widget().
+ """
+ widget = self._widget
+ map_signal_id = self._map_signal_id
+ unmap_signal_id = self._unmap_signal_id
+
+ # Double-disconnect is C-level undefined behavior, so ensure
+ # it cannot happen. It is better to leak memory if an exception
+ # is thrown here. GObject.disconnect_by_func() is buggy
+ # (https://gitlab.gnome.org/GNOME/pygobject/-/issues/106),
+ # so avoid it.
+ if widget is not None:
+ if map_signal_id != 0:
+ # Clear the signal ID to avoid double-disconnect
+ # if this method is interrupted and then called again.
+ self._map_signal_id = 0
+ widget.disconnect(map_signal_id)
+ if unmap_signal_id != 0:
+ # Clear the signal ID to avoid double-disconnect
+ # if this method is interrupted and then called again.
+ self._unmap_signal_id = 0
+ widget.disconnect(unmap_signal_id)
+ self._widget = None
+
+ def show_for_widget(self, widget: Gtk.Widget, /) -> None:
+ # Clear any existing connections.
+ self.clear_widget()
+ # Store the new widget.
+ self._widget = widget
+ # Connect map and unmap signals.
+ self._unmap_signal_id = widget.connect("unmap", self._hide)
+ self._map_signal_id = widget.connect("map", self._show)
+
+ @staticmethod
+ def _on_realize(window: Gtk.Window, /) -> None:
+ window.set_opacity(0)
+ gdk_window = window.get_window()
+ gdk_window.set_override_redirect(True)
+ window.get_root_window().set_cursor(
+ Gdk.Cursor.new_for_display(
+ display=gdk_window.get_display(),
+ cursor_type=Gdk.CursorType.ARROW,
+ )
+ )
+
+ def _show(self, widget: Gtk.Widget, /) -> None:
+ assert widget is self._widget, "signal not properly disconnected"
+ # pylint: disable=no-member
+ self._window.show_all()
+
+ def _hide(self, widget: Gtk.Widget, /) -> None:
+ assert widget is self._widget, "signal not properly disconnected"
+ self._window.hide()
+
+ # pylint: disable=line-too-long
+ def on_button_press(
+ self, window: Gtk.Window, _event: Gdk.EventButton, /
+ ) -> None:
+ # Hide the window and the widget.
+ window.hide()
+ self._widget.hide()
+
+
+def get_fullscreen_window_hack() -> X11FullscreenWindowHack:
+ if is_xwayland:
+ return X11FullscreenWindowHackXWayland()
+ return X11FullscreenWindowHack()
diff --git a/qui/tray/updates.py b/qui/tray/updates.py
index 37ce643..d29ec97 100644
--- a/qui/tray/updates.py
+++ b/qui/tray/updates.py
@@ -3,6 +3,12 @@
# pylint: disable=wrong-import-position,import-error
""" A widget that monitors update availability and notifies the user
about new updates to templates and standalone VMs"""
+
+# Must be imported before creating threads
+from .gtk3_xwayland_menu_dismisser import (
+ get_fullscreen_window_hack,
+) # isort:skip
+
import asyncio
import sys
import subprocess
@@ -62,6 +68,7 @@ def __init__(self, app_name, qapp, dispatcher):
super().__init__()
self.name = app_name
+ self.fullscreen_window_hack = get_fullscreen_window_hack()
self.dispatcher = dispatcher
self.qapp = qapp
@@ -80,6 +87,7 @@ def __init__(self, app_name, qapp, dispatcher):
self.obsolete_vms = set()
self.tray_menu = Gtk.Menu()
+ self.fullscreen_window_hack.show_for_widget(self.tray_menu)
def run(self): # pylint: disable=arguments-differ
self.check_vms_needing_update()
@@ -122,6 +130,7 @@ def setup_menu(self):
def show_menu(self, _unused, _event):
self.tray_menu = Gtk.Menu()
+ self.fullscreen_window_hack.show_for_widget(self.tray_menu)
self.setup_menu()
diff --git a/rpm_spec/qubes-desktop-linux-manager.spec.in b/rpm_spec/qubes-desktop-linux-manager.spec.in
index 781f09b..c19f32b 100644
--- a/rpm_spec/qubes-desktop-linux-manager.spec.in
+++ b/rpm_spec/qubes-desktop-linux-manager.spec.in
@@ -137,7 +137,6 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || :
%{python3_sitelib}/qui/devices/actionable_widgets.py
%{python3_sitelib}/qui/devices/backend.py
%{python3_sitelib}/qui/devices/device_widget.py
-%{python3_sitelib}/qui/devices/device_widget.py
%{python3_sitelib}/qui/qubes-devices-dark.css
%{python3_sitelib}/qui/qubes-devices-light.css
%{python3_sitelib}/qui/devices/AttachConfirmationWindow.glade
@@ -155,6 +154,7 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || :
%{python3_sitelib}/qui/tray/domains.py
%{python3_sitelib}/qui/tray/disk_space.py
%{python3_sitelib}/qui/tray/updates.py
+%{python3_sitelib}/qui/tray/gtk3_xwayland_menu_dismisser.py
%dir %{python3_sitelib}/qubes_config
%dir %{python3_sitelib}/qubes_config/__pycache__