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__