From ed5b6abfccb7b3f0f8b71c091083b064103b4f7b Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 23 Oct 2026 21:54:12 -0400 Subject: [PATCH 1/3] - X11 capture method now supports covered or off-screen windows when a compositor is running - X11 apps running through XWayland can now be captured with the X11 capture method - Removed Wayland warning on startup and updated docs - Scrot is now available on X11 sessions that don't set `XDG_SESSION_TYPE` (like `startx` or `ssh -X`), and works when installed outside `/usr/bin`. --- README.md | 10 ++++---- docs/tutorial.md | 9 +++---- src/AutoSplit.py | 3 --- src/capture_method/ScrotCaptureMethod.py | 30 +++++++++++++++++++----- src/capture_method/X11CaptureMethod.py | 28 ++++++++++++---------- src/capture_method/__init__.py | 5 ++-- src/error_messages.py | 10 -------- src/menu_bar.py | 9 +++---- src/utils.py | 10 ++++---- 9 files changed, 64 insertions(+), 50 deletions(-) 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..d478081b 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( diff --git a/src/capture_method/X11CaptureMethod.py b/src/capture_method/X11CaptureMethod.py index d9e0cf18..977b1b26 100644 --- a/src/capture_method/X11CaptureMethod.py +++ b/src/capture_method/X11CaptureMethod.py @@ -10,7 +10,7 @@ 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 @@ -38,7 +38,9 @@ def _image_reply_to_bgr(reply: GetImage, width: int, height: int): 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,24 +55,26 @@ 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"], + reply = window.get_image( + selection["x"], + selection["y"], selection["width"], selection["height"], 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), or the region extends past its bounds. + # Without a compositor, also when the region is (partially) outside the screen. return None return _image_reply_to_bgr(reply, selection["width"], selection["height"]) 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/utils.py b/src/utils.py index 9e671632..43d42fa1 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: From 81ff9bd80b1e49e83fd7e2622cee465be5a91f27 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 23 Oct 2026 21:54:38 -0400 Subject: [PATCH 2/3] non-user-facing fixes for window targetting from pywinctl --- src/capture_method/ScrotCaptureMethod.py | 2 +- src/capture_method/X11CaptureMethod.py | 5 ++--- src/region_selection.py | 9 ++++++++ src/utils.py | 28 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py index d478081b..b4a07d24 100644 --- a/src/capture_method/ScrotCaptureMethod.py +++ b/src/capture_method/ScrotCaptureMethod.py @@ -95,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 977b1b26..8044b67e 100644 --- a/src/capture_method/X11CaptureMethod.py +++ b/src/capture_method/X11CaptureMethod.py @@ -7,13 +7,12 @@ import cv2 import numpy as np -from pywinctl import getWindowsWithTitle from Xlib import X from Xlib.display import Display 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 @@ -80,7 +79,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/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 43d42fa1..56b6adc6 100644 --- a/src/utils.py +++ b/src/utils.py @@ -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)) From edf3ebd710f3a9d2f388b71f6bfbdb6f6832caaa Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 23 Oct 2026 22:28:25 -0400 Subject: [PATCH 3/3] Window region clamping in X11CaptureMethod --- src/capture_method/X11CaptureMethod.py | 41 +++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/capture_method/X11CaptureMethod.py b/src/capture_method/X11CaptureMethod.py index 8044b67e..f04f6fd8 100644 --- a/src/capture_method/X11CaptureMethod.py +++ b/src/capture_method/X11CaptureMethod.py @@ -18,6 +18,7 @@ from Xlib.protocol.request import GetImage from AutoSplit import AutoSplit + from capture_method import Region ALL_PLANES = 0xFFFFFFFF @@ -34,6 +35,16 @@ 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" @@ -60,22 +71,32 @@ def get_frame(self): window = self._display.create_resource_object("window", self._autosplit_ref.hwnd) selection = self._autosplit_ref.settings_dict["capture_region"] try: - reply = window.get_image( - selection["x"], - selection["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: - # Window isn't viewable (unmapped or minimized), or the region extends past its bounds. + # Window isn't viewable (unmapped or minimized). # Without a compositor, also when the region is (partially) outside the screen. return None - return _image_reply_to_bgr(reply, selection["width"], selection["height"]) + image = _image_reply_to_bgr(reply, right - left, bottom - top) + if image is None: + return None + 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):