diff --git a/README.md b/README.md index 7ee7eea2..012b104a 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,6 @@ To understand how to use AutoSplit and how it works in-depth, please read the [t loginctl terminate-user $USER ``` - - All screen capture method are incompatible with Wayland. Follow [this guide](https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop) to disable it. - ### Compatibility - Windows 10 and 11. @@ -49,7 +46,7 @@ To understand how to use AutoSplit and how it works in-depth, please read the [t - Useful if you want to use Desktop version of LiveSplit on Linux - Linux - Should work on Ubuntu 24.04+ (glibc 2.39) (tested on Kubuntu 26.04) - - Wayland is not currently supported + - Wayland is [limited](#wayland-limitations) - WSL2/WSLg requires an additional Desktop Environment, external X11 server, and/or systemd - x64 and ARM64 architectures - Native ARM64 builds go completely untested. There may be unforeseen issues. @@ -80,6 +77,11 @@ Linux users, note that LiveSplit Desktop, with additional components, is only av - Incompatible with LiveSplitOne on Linux (see ) - Antivirus false positives. Please read +### Wayland Limitations + +- Only X11 apps running through XWayland can be captured, with the X11 capture method. Scrot is not available. +- To capture native Wayland windows, use a [Virtual Camera](./docs/tutorial.md#all-platforms), or log into an X11 session if your Desktop Environment still offers one. See guides for [GNOME](https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop) and [KDE Plasma](https://www.itechguides.com/how-to-switch-kde-plasma-from-x11-to-wayland/#When_auto-login_hides_the_session_menu). + ### Wine Limitations - For Window targetting, only the BitBlt Capture method is supported. Wine [does not support `CreateDirect3D11DeviceFromDXGIDevice`](https://bugs.winehq.org/show_bug.cgi?id=52487) diff --git a/docs/tutorial.md b/docs/tutorial.md index 6da52037..aa90ca0f 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -76,12 +76,13 @@ ##### Linux - **X11** (fastest)\ - Request screenshots directly from the X11 server. + Requests the window's content directly from the X11 server.\ + Also works with X11 apps running through XWayland on Wayland. - **Scrot** (slow, may leave files in `/tmp`)\ Uses Scrot (SCReenshOT) to take screenshots.\ - Leaves behind a screenshot file in `/tmp` if interrupted. - - "scrot" must be installed: `sudo apt install scrot` + Leaves behind a screenshot file in `/tmp` if interrupted.\ + "scrot" must be installed: `sudo apt install scrot`\ + Not available on Wayland. ##### All platforms diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 2baeed40..cc6d3329 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -144,7 +144,6 @@ def do_nothing(*_): ... AUTOSPLIT_VERSION, FROZEN, ONE_SECOND, - RUNNING_WAYLAND, auto_split_directory, decimal, flatten, @@ -1375,8 +1374,6 @@ def main(): error_messages.linux_groups() if KEYBOARD_UINPUT_ISSUE: error_messages.linux_uinput() - if RUNNING_WAYLAND: - error_messages.linux_wayland() AutoSplit() diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py index 8088060d..b4a07d24 100644 --- a/src/capture_method/ScrotCaptureMethod.py +++ b/src/capture_method/ScrotCaptureMethod.py @@ -10,17 +10,32 @@ from typing import TYPE_CHECKING, override import cv2 -from pywinctl import getWindowsWithTitle from Xlib.display import Display -from Xlib.error import BadWindow +from Xlib.error import BadWindow, DisplayError from capture_method.CaptureMethodBase import CaptureMethodBase -from utils import RUNNING_X11, imread, is_valid_image +from utils import HAS_X_DISPLAY, get_windows_with_title, imread, is_valid_image if TYPE_CHECKING: from AutoSplit import AutoSplit -IS_SCROT_SUPPORTED = RUNNING_X11 and bool(shutil.which("scrot")) + +def _is_xwayland(): + """Ask the X server itself, env vars can disagree with the actual `DISPLAY`.""" + try: + display = Display() + except DisplayError: + return False + try: + return display.query_extension("XWAYLAND") is not None + finally: + display.close() + + +SCROT_PATH = shutil.which("scrot") or "" +# Scrot always grabs from the root window (even with `--window`), +# which rootless XWayland leaves blank. +IS_SCROT_SUPPORTED = HAS_X_DISPLAY and bool(SCROT_PATH) and not _is_xwayland() def _scrot_screenshot(x: int, y: int, width: int, height: int): @@ -28,7 +43,7 @@ def _scrot_screenshot(x: int, y: int, width: int, height: int): screenshot_file = os.path.join(tmp, "autosplit") try: subprocess.check_call(( # noqa: S603 # Not user input - "/usr/bin/scrot", + SCROT_PATH, "-a", f"{x},{y},{width},{height}", "-z", @@ -46,7 +61,9 @@ class ScrotCaptureMethod(CaptureMethodBase): short_description = "slow, may leave files in `/tmp`" description = """ Uses Scrot (SCReenshOT) to take screenshots. -Leaves behind a screenshot file in `/tmp` if interrupted.""" +Leaves behind a screenshot file in `/tmp` if interrupted. +"scrot" must be installed: `sudo apt install scrot` +Not available on Wayland.""" def __init__(self, autosplit: AutoSplit): super().__init__(autosplit) @@ -65,6 +82,7 @@ def get_frame(self): try: window_coords = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 except BadWindow: + # Window was closed return None selection = self._autosplit_ref.settings_dict["capture_region"] image = _scrot_screenshot( @@ -77,7 +95,7 @@ def get_frame(self): @override def recover_window(self, captured_window_title: str): - windows = getWindowsWithTitle(captured_window_title) + windows = get_windows_with_title(captured_window_title) if len(windows) == 0: return False self._autosplit_ref.hwnd = windows[0].getHandle() diff --git a/src/capture_method/X11CaptureMethod.py b/src/capture_method/X11CaptureMethod.py index d9e0cf18..f04f6fd8 100644 --- a/src/capture_method/X11CaptureMethod.py +++ b/src/capture_method/X11CaptureMethod.py @@ -7,18 +7,18 @@ import cv2 import numpy as np -from pywinctl import getWindowsWithTitle from Xlib import X from Xlib.display import Display -from Xlib.error import BadMatch, BadWindow +from Xlib.error import BadDrawable, BadMatch from capture_method.CaptureMethodBase import CaptureMethodBase -from utils import ALPHA_CHANNEL_COUNT, is_valid_image +from utils import ALPHA_CHANNEL_COUNT, get_windows_with_title, is_valid_image if TYPE_CHECKING: from Xlib.protocol.request import GetImage from AutoSplit import AutoSplit + from capture_method import Region ALL_PLANES = 0xFFFFFFFF @@ -35,10 +35,22 @@ def _image_reply_to_bgr(reply: GetImage, width: int, height: int): return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) +def _clamp_to_window(selection: Region, window_width: int, window_height: int): + """@return: The (left, top, right, bottom) edges of the selection that are inside the window.""" + return ( + max(selection["x"], 0), + max(selection["y"], 0), + min(selection["x"] + selection["width"], window_width), + min(selection["y"] + selection["height"], window_height), + ) + + class X11CaptureMethod(CaptureMethodBase): name = "X11" short_description = "fastest" - description = "\nRequests screenshots directly from the X11 server." + description = """ +Requests the window's content directly from the X11 server. +Also works with X11 apps running through XWayland on Wayland.""" def __init__(self, autosplit: AutoSplit): super().__init__(autosplit) @@ -53,30 +65,42 @@ def get_frame(self): if not self.check_selected_region_exists(): return None - root = self._display.screen().root - try: - window_coords = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 - except BadWindow: - return None - + # Grab from the window's own drawable rather than the root window: compositors and + # XWayland redirect toplevels to their own pixmap, so this works even when the window is + # partially off-screen or covered, and under rootless XWayland where the root is blank. + window = self._display.create_resource_object("window", self._autosplit_ref.hwnd) selection = self._autosplit_ref.settings_dict["capture_region"] try: - reply = root.get_image( - selection["x"] + window_coords["x"], - selection["y"] + window_coords["y"], - selection["width"], - selection["height"], - X.ZPixmap, - ALL_PLANES, - ) + geometry = window.get_geometry() + # GetImage requires the rectangle to be inside the window, so only request the part + # that is, and pad the rest to keep the selected size. + left, top, right, bottom = _clamp_to_window(selection, geometry.width, geometry.height) + if right <= left or bottom <= top: + # Region is entirely outside the window + return None + reply = window.get_image(left, top, right - left, bottom - top, X.ZPixmap, ALL_PLANES) + except BadDrawable: + # Window was closed + return None except BadMatch: - # Region is (partially) outside the screen + # Window isn't viewable (unmapped or minimized). + # Without a compositor, also when the region is (partially) outside the screen. + return None + image = _image_reply_to_bgr(reply, right - left, bottom - top) + if image is None: return None - return _image_reply_to_bgr(reply, selection["width"], selection["height"]) + return cv2.copyMakeBorder( + image, + top - selection["y"], + selection["y"] + selection["height"] - bottom, + left - selection["x"], + selection["x"] + selection["width"] - right, + cv2.BORDER_CONSTANT, + ) @override def recover_window(self, captured_window_title: str): - windows = getWindowsWithTitle(captured_window_title) + windows = get_windows_with_title(captured_window_title) if len(windows) == 0: return False self._autosplit_ref.hwnd = windows[0].getHandle() diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 7c9cf4fa..aaaf5695 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -10,7 +10,7 @@ from capture_method.CaptureMethodBase import CaptureMethodBase from capture_method.VideoCaptureDeviceCaptureMethod import VideoCaptureDeviceCaptureMethod -from utils import first, get_input_device_resolution +from utils import HAS_X_DISPLAY, first, get_input_device_resolution if sys.platform == "win32": import winerror @@ -136,7 +136,8 @@ def get(self, key: CaptureMethodEnum, default: object = None, /): ForceFullContentRenderingCaptureMethod ) elif sys.platform == "linux": - CAPTURE_METHODS[CaptureMethodEnum.X11] = X11CaptureMethod + if HAS_X_DISPLAY: + CAPTURE_METHODS[CaptureMethodEnum.X11] = X11CaptureMethod if IS_SCROT_SUPPORTED: CAPTURE_METHODS[CaptureMethodEnum.SCROT] = ScrotCaptureMethod CAPTURE_METHODS[CaptureMethodEnum.VIDEO_CAPTURE_DEVICE] = VideoCaptureDeviceCaptureMethod diff --git a/src/error_messages.py b/src/error_messages.py index 6beaf7e5..f0b88f75 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -18,12 +18,6 @@ if TYPE_CHECKING: from AutoSplit import AutoSplit -# Keep in sync with README.md#DOWNLOAD_AND_OPEN -WAYLAND_WARNING = """\ -All screen capture method are incompatible with Wayland. Follow this guide to disable it: - \ -https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop""" - CREATE_NEW_ISSUE_MESSAGE = ( f"Please create a New Issue at " + f"github.com/{GITHUB_REPOSITORY}/issues, describe what happened, " @@ -254,10 +248,6 @@ def linux_uinput(): ) -def linux_wayland(): - _set_text_message(WAYLAND_WARNING, rich_text=True) - - def exception_traceback(exception: BaseException, message: str = ""): if not message: message = ( diff --git a/src/menu_bar.py b/src/menu_bar.py index 73f127a8..d18aecd4 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -33,10 +33,11 @@ HALF_BRIGHTNESS = 128 LINUX_SCREENSHOT_SUPPORT = ( "\n\n----------------------------------------------------\n\n" - + error_messages.WAYLAND_WARNING - # Keep in sync with README.md#Capture_Method_Linux - + '\n"scrot" must be installed to use SCReenshOT. ' - + "\nRun: sudo apt install scrot" + # Keep in sync with README.md#wayland-limitations + + "To capture native Wayland windows, use a Virtual Camera, " + + "or log into an X11 session if your Desktop Environment still offers one. See guides for" + + "\n- GNOME: https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop" + + "\n- KDE Plasma: https://www.itechguides.com/how-to-switch-kde-plasma-from-x11-to-wayland/#When_auto-login_hides_the_session_menu" ) if sys.platform == "linux" else "" # fmt: skip _DEBUG_SCREENSHOT_COMMANDS: tuple[CommandStr, ...] = ( diff --git a/src/region_selection.py b/src/region_selection.py index 2df4fb7e..20198dde 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -28,6 +28,11 @@ if sys.platform == "win32": import win32gui +if sys.platform == "linux": + from pywinbox import pointInBox + + from utils import get_all_windows + if TYPE_CHECKING: from cv2.typing import MatLike, Point @@ -55,6 +60,10 @@ def get_top_window_at(x: int, y: int): if sys.platform == "linux": # 80ms was the minimum needed when tested in increments of 10ms on my Pop!_OS 22.04 VM QTest.qWait(80) + return next( + (window for window in reversed(get_all_windows()) if pointInBox(x, y, window.box)), + None, + ) return getTopWindowAt(x, y) diff --git a/src/utils.py b/src/utils.py index 9e671632..56b6adc6 100644 --- a/src/utils.py +++ b/src/utils.py @@ -37,12 +37,12 @@ if sys.platform == "linux": import fcntl - RUNNING_X11 = os.environ.get("XDG_SESSION_TYPE") == "x11" - RUNNING_WAYLAND = not RUNNING_X11 and ( - os.environ.get("XDG_SESSION_TYPE") == "wayland" or "WAYLAND_DISPLAY" in os.environ - ) + from Xlib.error import BadWindow + + # An X server (real or XWayland) to connect to, regardless of the session type + HAS_X_DISPLAY = bool(os.environ.get("DISPLAY")) else: - RUNNING_X11 = RUNNING_WAYLAND = False + HAS_X_DISPLAY = False if TYPE_CHECKING: @@ -182,6 +182,34 @@ def is_valid_hwnd(hwnd: int): return True +if sys.platform == "linux": + + def get_all_windows(): + """ + Like `pywinctl.getAllWindows`, but always asks the X server. + + pywinctl asks GNOME Shell instead when `XDG_SESSION_TYPE` is "wayland", + which fails outside of GNOME, and requires unsafe_mode on GNOME. + AutoSplit is itself an X11 client (`QT_QPA_PLATFORM=xcb`), + so XWayland windows are listed by the X server like any other. + """ + # Connects to the X server on import + from ewmhlib import defaultEwmhRoot # noqa: PLC0415 + from pywinctl import Window # noqa: PLC0415 + + windows: list[Window] = [] + for hwnd in defaultEwmhRoot.getClientListStacking() or (): + try: + windows.append(Window(hwnd)) + except BadWindow: + # Window was closed in the meantime + continue + return windows + + def get_windows_with_title(title: str): + return [window for window in get_all_windows() if window.title == title] + + def first(iterable: Iterable[T]) -> T: """@return: The first element of a collection. Dictionaries will return the first key.""" return next(iter(iterable))