Skip to content
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,14 @@ To understand how to use AutoSplit and how it works in-depth, please read the [t
loginctl terminate-user $USER
```

<!-- Keep in sync with src/error_messages.py -->
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.
- Wine 10.1+ or 11.3+ ([very limited](#wine-limitations))
- 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.
Expand Down Expand Up @@ -80,6 +77,11 @@ Linux users, note that LiveSplit Desktop, with additional components, is only av
- Incompatible with LiveSplitOne on Linux (see <https://github.com/LiveSplit/LiveSplitOne/issues/1025>)
- Antivirus false positives. Please read <https://github.com/pyinstaller/pyinstaller/blob/develop/.github/ISSUE_TEMPLATE/antivirus.md>

### 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)
Expand Down
9 changes: 5 additions & 4 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- Keep in sync with src/menu_bar.py -->
"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

Expand Down
3 changes: 0 additions & 3 deletions src/AutoSplit.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ def do_nothing(*_): ...
AUTOSPLIT_VERSION,
FROZEN,
ONE_SECOND,
RUNNING_WAYLAND,
auto_split_directory,
decimal,
flatten,
Expand Down Expand Up @@ -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()

Expand Down
32 changes: 25 additions & 7 deletions src/capture_method/ScrotCaptureMethod.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,40 @@
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):
with tempfile.TemporaryDirectory() as tmp:
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",
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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()
Expand Down
66 changes: 45 additions & 21 deletions src/capture_method/X11CaptureMethod.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions src/capture_method/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 0 additions & 10 deletions src/error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<a href="https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop"> \
https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop</a>"""

CREATE_NEW_ISSUE_MESSAGE = (
f"Please create a New Issue at <a href='https://github.com/{GITHUB_REPOSITORY}/issues'>"
+ f"github.com/{GITHUB_REPOSITORY}/issues</a>, describe what happened, "
Expand Down Expand Up @@ -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 = (
Expand Down
9 changes: 5 additions & 4 deletions src/menu_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = (
Expand Down
9 changes: 9 additions & 0 deletions src/region_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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


Expand Down
38 changes: 33 additions & 5 deletions src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down
Loading