Skip to content

Commit

Permalink
Make the widgets work in KDE Wayland session
Browse files Browse the repository at this point in the history
This has two parts:

1. Set GDK_BACKEND=x11, so that GDK3 chooses to use X11 as its backend.
   This must be done while only one thread is running, as otherwise
   undefined behavior results.  It also must be done before
   gi.overrides.Gdk is imported, as otherwise it will be too late to set
   the GDK backend.  Therefore, this code is run very early, and
   RuntimeError is raised if the preconditions are violated.

2. Create a fullscreen invisible window for mouse input.  This works
   around Xwayland not passing all pointer input to X11.  The menu is
   dismissed if the user clicks on the fullscreen window.  This hack
   is only used if WAYLAND_DISPLAY is set.
  • Loading branch information
DemiMarie committed Dec 23, 2024
1 parent a3e4d17 commit 6ad383b
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 3 deletions.
6 changes: 6 additions & 0 deletions qui/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(_("<b>Current clipboard</b>"))
Expand Down
7 changes: 7 additions & 0 deletions qui/devices/device_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program; if not, see <http://www.gnu.org/licenses/>.

# 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion qui/tray/disk_space.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,7 +24,6 @@
WARN_LEVEL = 0.9
URGENT_WARN_LEVEL = 0.95


class VMUsage:
def __init__(self, vm):
self.vm = vm
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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))

Expand Down
7 changes: 7 additions & 0 deletions qui/tray/domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -537,6 +542,8 @@ def __init__(self, app_name, qapp, dispatcher, stats_dispatcher):
_('<b>Qubes Domains</b>\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()

Expand Down
148 changes: 148 additions & 0 deletions qui/tray/gross_gtk3_bug_workaround.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 9 additions & 1 deletion qui/tray/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,7 +28,6 @@
t = gettext.translation("desktop-linux-manager", fallback=True)
_ = t.gettext


class TextItem(Gtk.MenuItem):
def __init__(self, text):
super().__init__()
Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion rpm_spec/qubes-desktop-linux-manager.spec.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__
Expand Down

0 comments on commit 6ad383b

Please sign in to comment.