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__