Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

widgets: fall back to Xwayland #234

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions qui/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(_("<b>Current clipboard</b>"))
Expand Down
8 changes: 8 additions & 0 deletions qui/devices/device_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
#
# 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.gtk3_xwayland_menu_dismisser import (
get_fullscreen_window_hack,
) # isort:skip

from typing import Set, List, Dict
import asyncio
import sys
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions qui/tray/disk_space.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()

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

Expand Down
8 changes: 8 additions & 0 deletions qui/tray/domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 abc
import asyncio
import subprocess
Expand Down Expand Up @@ -584,6 +590,8 @@ def __init__(self, app_name, qapp, dispatcher, stats_dispatcher):
)

self.tray_menu = Gtk.Menu()
self.fullscreen_window_hack = get_fullscreen_window_hack()
self.fullscreen_window_hack.show_for_widget(self.tray_menu)

self.icon_cache = IconCache()

Expand Down
146 changes: 146 additions & 0 deletions qui/tray/gtk3_xwayland_menu_dismisser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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 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

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()


def get_fullscreen_window_hack() -> X11FullscreenWindowHack:
if is_xwayland:
return X11FullscreenWindowHackXWayland()
return X11FullscreenWindowHack()
10 changes: 10 additions & 0 deletions qui/tray/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -122,6 +130,8 @@ def setup_menu(self):

def show_menu(self, _unused, _event):
self.tray_menu = Gtk.Menu()
# TODO: disconnect
self.fullscreen_window_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/gtk3_xwayland_menu_dismisser.py

%dir %{python3_sitelib}/qubes_config
%dir %{python3_sitelib}/qubes_config/__pycache__
Expand Down