diff --git a/qui/clipboard.py b/qui/clipboard.py
index 0118bf94..3caaeae4 100644
--- a/qui/clipboard.py
+++ b/qui/clipboard.py
@@ -26,6 +26,10 @@
via Qubes RPC """
# pylint: disable=invalid-name,wrong-import-position
+# Must be imported before creating threads
+from .tray.gross_gtk3_bug_workaround \
+ import DisgustingX11FullscreenWindowHack # isort:skip
+
import asyncio
import contextlib
import json
@@ -285,6 +289,7 @@ def __init__(self, wm, qapp, dispatcher, **properties):
self.set_application_id("org.qubes.qui.clipboard")
self.register() # register Gtk Application
+ self.disgusting_hack = DisgustingX11FullscreenWindowHack.get()
self.qapp = qapp
self.vm = self.qapp.domains[self.qapp.local_name]
self.dispatcher = dispatcher
@@ -373,6 +378,7 @@ def setup_ui(self, *_args, **_kwargs):
)
self.menu = Gtk.Menu()
+ self.disgusting_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 942ae298..eecd84cf 100644
--- a/qui/devices/device_widget.py
+++ b/qui/devices/device_widget.py
@@ -17,6 +17,11 @@
#
# 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.gross_gtk3_bug_workaround \
+ import DisgustingX11FullscreenWindowHack # isort:skip
+
from typing import Set, List, Dict
import asyncio
import sys
@@ -73,6 +78,7 @@ class DevicesTray(Gtk.Application):
"""Tray application for handling devices."""
def __init__(self, app_name, qapp, dispatcher):
super().__init__()
+ self.disgusting_hack = DisgustingX11FullscreenWindowHack.get()
self.name: str = app_name
# maps: port to connected device (e.g., sys-usb:sda -> block device)
@@ -300,6 +306,7 @@ def load_css(widget) -> str:
def show_menu(self, _unused, _event):
"""Show menu at mouse pointer."""
tray_menu = Gtk.Menu()
+ self.disgusting_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 8d8c8094..91349e3d 100644
--- a/qui/tray/disk_space.py
+++ b/qui/tray/disk_space.py
@@ -1,4 +1,9 @@
# pylint: disable=wrong-import-position,import-error
+
+# Must be imported before creating threads
+from .gross_gtk3_bug_workaround \
+ import DisgustingX11FullscreenWindowHack # isort:skip
+
import sys
import subprocess
from typing import List
@@ -19,7 +24,6 @@
WARN_LEVEL = 0.9
URGENT_WARN_LEVEL = 0.95
-
class VMUsage:
def __init__(self, vm):
self.vm = vm
@@ -338,6 +342,7 @@ class DiskSpace(Gtk.Application):
def __init__(self, **properties):
super().__init__(**properties)
+ self.disgusting_hack = DisgustingX11FullscreenWindowHack.get()
self.pool_warned = False
self.vms_warned = set()
@@ -423,6 +428,7 @@ def make_menu(self, _unused, _event):
vm_data = VMUsageData(self.qubes_app)
menu = Gtk.Menu()
+ self.disgusting_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 1eae1310..2a7e6591 100644
--- a/qui/tray/domains.py
+++ b/qui/tray/domains.py
@@ -2,6 +2,11 @@
# -*- coding: utf-8 -*-
# pylint: disable=wrong-import-position,import-error,superfluous-parens
''' A menu listing domains '''
+
+# Must be imported before creating threads
+from .gross_gtk3_bug_workaround \
+ import DisgustingX11FullscreenWindowHack # isort:skip
+
import abc
import asyncio
import subprocess
@@ -537,6 +542,8 @@ def __init__(self, app_name, qapp, dispatcher, stats_dispatcher):
_('Qubes Domains\nView and manage running domains.'))
self.tray_menu = Gtk.Menu()
+ self.disgusting_hack = DisgustingX11FullscreenWindowHack.get()
+ self.disgusting_hack.show_for_widget(self.tray_menu)
self.icon_cache = IconCache()
diff --git a/qui/tray/gross_gtk3_bug_workaround.py b/qui/tray/gross_gtk3_bug_workaround.py
new file mode 100644
index 00000000..2a6a7d2b
--- /dev/null
+++ b/qui/tray/gross_gtk3_bug_workaround.py
@@ -0,0 +1,148 @@
+#!/usr/bin/python3 --
+import os
+import sys
+from typing import Optional
+
+# Modifying the environment while multiple threads
+# are running leads to use-after-free in glibc, so
+# ensure that only one thread is running.
+if len(os.listdir("/proc/self/task")) != 1:
+ raise RuntimeError("threads already running")
+# If gi.override.Gdk has been imported, the GDK
+# backend has already been set and it is too late
+# to override it.
+if "gi.override.Gdk" in sys.modules:
+ raise RuntimeError("must import this module before loading GDK")
+# 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 DisgustingX11FullscreenWindowHack:
+ """
+ No-op implementation of the hack, for use on stock X11.
+ """
+
+ __slots__ = ()
+
+ @classmethod
+ def get(cls, /) -> "DisgustingX11FullscreenWindowHack":
+ if is_xwayland:
+ return DisgustingX11FullscreenWindowHackXWayland()
+ return cls()
+
+ def clear_widget(self, /) -> None:
+ pass
+
+ def show_for_widget(self, _widget: Gtk.Widget, /) -> None:
+ pass
+
+
+class DisgustingX11FullscreenWindowHackXWayland(
+ DisgustingX11FullscreenWindowHack
+):
+ """
+ GTK3 menus have a horrible 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 evil X11 hacks, such as a fullscreen override-redirect
+ window that is made transparent.
+ """
+
+ _window: Gtk.Window
+ _widget: Optional[Gtk.Widget]
+ _unmap_signal_id: int
+ _map_signal_id: int
+ __slots__ = ("_window", "_widget", "_unmap_signal_id", "_map_signal_id")
+
+ 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)
+ 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)
+ window.get_window().set_override_redirect(True)
+
+ 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()
diff --git a/qui/tray/updates.py b/qui/tray/updates.py
index 8533dea3..8214460e 100644
--- a/qui/tray/updates.py
+++ b/qui/tray/updates.py
@@ -3,6 +3,11 @@
# 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 .gross_gtk3_bug_workaround \
+ import DisgustingX11FullscreenWindowHack # isort:skip
+
import asyncio
import sys
import subprocess
@@ -23,7 +28,6 @@
t = gettext.translation("desktop-linux-manager", fallback=True)
_ = t.gettext
-
class TextItem(Gtk.MenuItem):
def __init__(self, text):
super().__init__()
@@ -59,6 +63,7 @@ def __init__(self, app_name, qapp, dispatcher):
super().__init__()
self.name = app_name
+ self.disgusting_hack = DisgustingX11FullscreenWindowHack.get()
self.dispatcher = dispatcher
self.qapp = qapp
@@ -76,6 +81,7 @@ def __init__(self, app_name, qapp, dispatcher):
self.obsolete_vms = set()
self.tray_menu = Gtk.Menu()
+ self.disgusting_hack.show_for_widget(self.tray_menu)
def run(self): # pylint: disable=arguments-differ
self.check_vms_needing_update()
@@ -107,6 +113,8 @@ def setup_menu(self):
def show_menu(self, _unused, _event):
self.tray_menu = Gtk.Menu()
+ # TODO: disconnect
+ self.disgusting_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 781f09b8..c0c48aa4 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/gross_gtk3_bug_workaround.py
%dir %{python3_sitelib}/qubes_config
%dir %{python3_sitelib}/qubes_config/__pycache__