diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index a5dcd70e..7b734686 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -40,11 +40,12 @@ concurrency: jobs: ruff: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false # Ruff is version and platform sensible matrix: + os: [windows-latest, ubuntu-22.04] python-version: ["3.10", "3.11", "3.12"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} @@ -59,11 +60,12 @@ jobs: shell: pwsh - run: ruff check . Pyright: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false # Pyright is version and platform sensible matrix: + os: [windows-latest, ubuntu-22.04] python-version: ["3.10", "3.11", "3.12"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} @@ -82,12 +84,13 @@ jobs: working-directory: src/ python-version: ${{ matrix.python-version }} Build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false # Only the Python version we plan on shipping matters. matrix: - python-version: ["3.11", "3.12"] + os: [windows-latest, ubuntu-22.04] + python-version: ["3.11"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} uses: actions/checkout@v3 @@ -104,13 +107,13 @@ jobs: - name: Upload Build Artifact uses: actions/upload-artifact@v3 with: - name: AutoSplit (Python ${{ matrix.python-version }}) + name: AutoSplit for ${{ matrix.os }} (Python ${{ matrix.python-version }}) path: dist/AutoSplit* if-no-files-found: error - name: Upload Build logs uses: actions/upload-artifact@v3 with: - name: Build logs (Python ${{ matrix.python-version }}) + name: Build logs for ${{ matrix.os }} (Python ${{ matrix.python-version }}) path: | build/AutoSplit/*.toc build/AutoSplit/*.txt diff --git a/README.md b/README.md index b63df63c..a0c5d943 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,30 @@ This program can be used to automatically start, split, and reset your preferred - You can also check out the [latest dev builds](/../../actions/workflows/lint-and-build.yml?query=event%3Apush+is%3Asuccess) (requires a GitHub account) (If you don't have a GitHub account, you can try [nightly.link](https://nightly.link/Toufool/AutoSplit/workflows/lint-and-build/dev)) +- Linux users must ensure they are in the `tty` and `input` groups and have write access to `/dev/uinput`. You can run the following commands to do so: + + + + ```shell + sudo usermod -a -G tty,input $USER + sudo touch /dev/uinput + sudo chmod +0666 /dev/uinput + echo 'KERNEL=="uinput", TAG+="uaccess""' | sudo tee /etc/udev/rules.d/50-uinput.rules + echo 'SUBSYSTEM=="input", MODE="0666" GROUP="plugdev"' | sudo tee /etc/udev/rules.d/12-input.rules + echo 'SUBSYSTEM=="misc", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules + echo 'SUBSYSTEM=="tty", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules + 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. +- Linux (Only tested on Ubuntu 22.04) + - Wayland is not currently supported + - WSL2/WSLg requires an additional Desktop Environment, external X11 server, and/or systemd - Python 3.10+ (Not required for normal use. Refer to the [build instructions](/docs/build%20instructions.md) if you'd like run the application directly in Python). ## OPTIONS @@ -70,6 +91,8 @@ This program can be used to automatically start, split, and reset your preferred #### Capture Method +##### Windows + - **Windows Graphics Capture** (fast, most compatible, capped at 60fps) Only available in Windows 10.0.17134 and up. Due to current technical limitations, Windows versions below 10.0.0.17763 require having at least one audio or video Capture Device connected and enabled. @@ -88,6 +111,19 @@ This program can be used to automatically start, split, and reset your preferred - **Force Full Content Rendering** (very slow, can affect rendering) Uses BitBlt behind the scene, but passes a special flag to PrintWindow to force rendering the entire desktop. About 10-15x slower than BitBlt based on original window size and can mess up some applications' rendering pipelines. + +##### Linux + +- **X11 XCB** (fast, requires XCB) + Uses the XCB library to take screenshots of the X11 server. +- **Scrot** (very slow, may leave files) + Uses Scrot (SCReenshOT) to take screenshots. + Leaves behind a screenshot file if interrupted. + + "scrot" must be installed: `sudo apt-get install scrot` + +##### All platforms + - **Video Capture Device** Uses a Video Capture Device, like a webcam, virtual cam, or capture card. diff --git a/docs/build instructions.md b/docs/build instructions.md index e74c7a9b..55f22a76 100644 --- a/docs/build instructions.md +++ b/docs/build instructions.md @@ -6,6 +6,11 @@ - Microsoft Visual C++ 14.0 or greater may be required to build the executable. Get it with [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +### Linux + +- You need to be part of the `input` and `tty` groups, as well as have permissions on a few files and folders. + If you are missing from either groups, the install script will take care of it on its first run, but you'll need to restart your session. + ### All platforms - [Python](https://www.python.org/downloads/) 3.10+. diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 54c12858..8e3f852d 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -12,12 +12,31 @@ $arguments = @( # if requirements.txt was used directly to help ensure consistency when building locally. # # Installed by PyAutoGUI - '--exclude=pyscreeze', '--exclude=pygetwindow', '--exclude=pymsgbox', '--exclude=pytweening', - '--exclude=mouseinfo', - # Used by D3DShot - '--exclude=PIL') + '--exclude=mouseinfo') +if ($IsWindows) { + $arguments += @( + # Installed by PyAutoGUI, but used by linux + '--exclude=pyscreeze' + # Used by D3DShot + '--exclude=PIL') +} +if ($IsLinux) { + $arguments += @( + # Required on the CI for PyWinCtl + '--hidden-import pynput.keyboard._xorg', + '--hidden-import pynput.mouse._xorg') +} Start-Process -Wait -NoNewWindow pyinstaller -ArgumentList $arguments + +If ($IsLinux) { + Move-Item -Force $PSScriptRoot/../dist/AutoSplit $PSScriptRoot/../dist/AutoSplit.elf + If ($?) { + Write-Host 'Added .elf extension' + } + chmod +x $PSScriptRoot/../dist/AutoSplit.elf + Write-Host 'Added execute permission' +} diff --git a/scripts/designer.ps1 b/scripts/designer.ps1 index a6a159f6..df9ff7e5 100644 --- a/scripts/designer.ps1 +++ b/scripts/designer.ps1 @@ -1,10 +1,13 @@ +$python = $IsLinux ? 'python3' : 'python' $qt6_applications_import = 'import qt6_applications; print(qt6_applications.__path__[0])' -$qt6_applications_path = python -c $qt6_applications_import + +$qt6_applications_path = &"$python" -c $qt6_applications_import if ($null -eq $qt6_applications_path) { Write-Host 'Designer not found, installing qt6_applications' - python -m pip install qt6_applications + &"$python" -m pip install qt6_applications } -$qt6_applications_path = python -c $qt6_applications_import + +$qt6_applications_path = &"$python" -c $qt6_applications_import & "$qt6_applications_path/Qt/bin/designer" ` "$PSScriptRoot/../res/design.ui" ` "$PSScriptRoot/../res/about.ui" ` diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d37b3940..1280d1f9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,7 +1,45 @@ +$python = $IsLinux ? 'python3' : 'python' + +# Validating user groups on Linux +If ($IsLinux) { + $groups = groups + if ($groups.Contains('input') -and $groups.Contains('tty')) { + Write-Host "User $Env:USER is already part of groups input and tty. No actions taken." + } + Else { + # https://github.com/boppreh/keyboard/issues/312#issuecomment-1189734564 + Write-Host "User $Env:USER isn't part of groups input and tty. It is required to install the keyboard module." + # Keep in sync with README.md and src/error_messages.py + sudo usermod -a -G 'tty,input' $Env:USER + sudo touch /dev/uinput + sudo chmod +0666 /dev/uinput + If (-not $Env:GITHUB_JOB) { + Write-Output 'KERNEL=="uinput", TAG+="uaccess""' | sudo tee /etc/udev/rules.d/50-uinput.rules + Write-Output 'SUBSYSTEM=="input", MODE="0666" GROUP="plugdev"' | sudo tee /etc/udev/rules.d/12-input.rules + Write-Output 'SUBSYSTEM=="misc", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules + Write-Output 'SUBSYSTEM=="tty", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules + } + Write-Host 'You have been added automatically,' ` + "but still need to manually terminate your session with 'loginctl terminate-user $Env:USER'" ` + 'for the changes to take effect outside of this script.' + If (-not $Env:GITHUB_JOB) { + Write-Host -NoNewline 'Press any key to continue...'; + $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown'); + } + } +} + # Installing Python dependencies $dev = If ($Env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' } +If ($IsLinux) { + If (-not $Env:GITHUB_JOB -or $Env:GITHUB_JOB -eq 'Build') { + sudo apt-get update + # python3-tk for splash screen, npm for pyright, the rest for PySide6 + sudo apt-get install -y python3-pip python3-tk npm libegl1 libxkbcommon0 + } +} # Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. -python -m pip install wheel pip setuptools --upgrade +&"$python" -m pip install wheel pip setuptools --upgrade pip install -r "$PSScriptRoot/requirements$dev.txt" --upgrade # These libraries install extra requirements we don't want # Open suggestion for support in requirements files: https://github.com/pypa/pip/issues/9948 & https://github.com/pypa/pip/pull/10837 @@ -15,21 +53,30 @@ pip install PyAutoGUI "D3DShot>=0.1.5 ; sys_platform == 'win32'" --no-deps --upg # Prevent PyAutoGUI and pywinctl from setting Process DPI Awareness, which Qt tries to do then throws warnings about it. # The unittest workaround significantly increases build time, boot time and build size with PyInstaller. # https://github.com/asweigart/pyautogui/issues/663#issuecomment-1296719464 -$libPath = python -c 'import pyautogui as _; print(_.__path__[0])' +$libPath = &"$python" -c 'import pyautogui as _; print(_.__path__[0])' (Get-Content "$libPath/_pyautogui_win.py").replace('ctypes.windll.user32.SetProcessDPIAware()', 'pass') | Set-Content "$libPath/_pyautogui_win.py" -$libPath = python -c 'import pymonctl as _; print(_.__path__[0])' -(Get-Content "$libPath/_pymonctl_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | - Set-Content "$libPath/_pymonctl_win.py" -$libPath = python -c 'import pywinbox as _; print(_.__path__[0])' -(Get-Content "$libPath/_pywinbox_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | - Set-Content "$libPath/_pywinbox_win.py" +If ($IsWindows) { + $libPath = &"$python" -c 'import pymonctl as _; print(_.__path__[0])' + (Get-Content "$libPath/_pymonctl_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | + Set-Content "$libPath/_pymonctl_win.py" + $libPath = &"$python" -c 'import pywinbox as _; print(_.__path__[0])' + (Get-Content "$libPath/_pywinbox_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | + Set-Content "$libPath/_pywinbox_win.py" +} +# Because Ubuntu 22.04 is forced to use an older version of PySide6, we do a dirty typing patch +# https://bugreports.qt.io/browse/QTBUG-114635 +If ($IsLinux) { + $libPath = &"$python" -c 'import PySide6 as _; print(_.__path__[0])' + (Get-Content "$libPath/QtWidgets.pyi").replace('-> Tuple:', '-> Tuple[str, ...]:') | + Set-Content "$libPath/QtWidgets.pyi" +} # Uninstall optional dependencies if PyAutoGUI or D3DShot was installed outside this script # pyscreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness, used to be installed on Windows # Pillow, pygetwindow, pymsgbox, pytweening, MouseInfo are picked up by PySide6 # (also --exclude from build script, but more consistent with unfrozen run) -python -m pip uninstall pyscreeze pyscreenshot mss Pillow pygetwindow pymsgbox pytweening MouseInfo -y - +pip uninstall pyscreenshot mss pygetwindow pymsgbox pytweening MouseInfo -y +If ($IsWindows) { pip uninstall pyscreeze Pillow -y } # Don't compile resources on the Build CI job as it'll do so in build script If ($dev) { diff --git a/scripts/python_build_from_source_linux.bash b/scripts/python_build_from_source_linux.bash new file mode 100644 index 00000000..20c82e75 --- /dev/null +++ b/scripts/python_build_from_source_linux.bash @@ -0,0 +1,28 @@ +cd .. + +# Update package lists +sudo apt update + +# Install dependent libraries: +sudo apt install build-essential zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev libssl-dev libsqlite3-dev libreadline-dev libffi-dev curl libbz2-dev tk-dev + +# Download Python binary package: +wget https://www.python.org/ftp/python/3.10.13/Python-3.10.13.tgz + +# Unzip the package: +tar -xzf Python-3.10.13.tgz + +# Execute configure script +cd Python-3.10.13 +./configure --enable-optimizations --enable-shared + +# Build Python 3.10 +make -j 2 + +# Install Python 3.10 +sudo make install + +# Verify the installation +python3.10 -V + +echo "If Python version did not print, you may need to stop active processes" diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index 864c2a13..135f6a9b 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -22,5 +22,6 @@ types-keyboard types-psutil types-PyAutoGUI types-pyinstaller +types-python-xlib ; sys_platform == 'linux' types-pywin32>=306.0.0.8 ; sys_platform == 'win32' types-toml diff --git a/scripts/requirements.txt b/scripts/requirements.txt index ab9ff83d..9391da99 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -11,7 +11,8 @@ psutil>=5.9.6 # Python 3.12 fixes # PyAutoGUI # See install.ps1 PyWinCtl>=0.0.42 # py.typed # When needed, dev builds can be found at https://download.qt.io/snapshots/ci/pyside/dev?C=M;O=D -PySide6-Essentials>=6.6.0 # Python 3.12 support +PySide6-Essentials>=6.6.0 ; sys_platform == 'win32' # Python 3.12 support +PySide6-Essentials<6.5.1 ; sys_platform == 'linux' # Wayland issue on Ubuntu 22.04 https://bugreports.qt.io/browse/QTBUG-114635 scipy>=1.11.2 # Python 3.12 support toml typing-extensions>=4.4.0 # @override decorator support @@ -26,3 +27,7 @@ pygrabber>=0.2 ; sys_platform == 'win32' # Completed types pywin32>=301 ; sys_platform == 'win32' winsdk>=1.0.0b10 ; sys_platform == 'win32' # Python 3.12 support # D3DShot # See install.ps1 +# +# Linux-only dependencies +pyscreeze ; sys_platform == 'linux' +python-xlib ; sys_platform == 'linux' diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 70d6fd8b..f12d9f8e 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -1,3 +1,4 @@ param ([string]$p1) & "$PSScriptRoot/compile_resources.ps1" -python "$PSScriptRoot/../src/AutoSplit.py" $p1 +$python = $IsLinux ? 'python3' : 'python' +&"$python" "$PSScriptRoot/../src/AutoSplit.py" $p1 diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 43e48a37..91a7bcc7 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -15,7 +15,6 @@ from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QFileDialog, QLabel, QMainWindow, QMessageBox from typing_extensions import override -from win32comext.shell import shell as shell32 import error_messages import user_profile @@ -23,7 +22,7 @@ from AutoSplitImage import AutoSplitImage, ImageType from capture_method import CaptureMethodBase, CaptureMethodEnum from gen import about, design, settings, update_checker -from hotkeys import HOTKEYS, after_setting_hotkey, send_command +from hotkeys import HOTKEYS, KEYBOARD_GROUPS_ISSUE, KEYBOARD_UINPUT_ISSUE, after_setting_hotkey, send_command from menu_bar import ( about_qt, about_qt_for_python, @@ -43,6 +42,7 @@ FROZEN, ONE_SECOND, QTIMER_FPS_LIMIT, + RUNNING_WAYLAND, auto_split_directory, decimal, flatten, @@ -50,8 +50,10 @@ open_file, ) -myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" -shell32.SetCurrentProcessExplicitAppUserModelID(myappid) +if sys.platform == "win32": + from win32comext.shell import shell as shell32 + myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" + shell32.SetCurrentProcessExplicitAppUserModelID(myappid) class AutoSplit(QMainWindow, design.Ui_MainWindow): @@ -912,6 +914,7 @@ def seconds_remaining_text(seconds: float): return f"{seconds:.1f} second{'' if 0 < seconds <= 1 else 's'} remaining" +# TODO: Add Linux support def is_already_open(): # When running directly in Python, any AutoSplit process means it's already open # When bundled, we must ignore itself and the splash screen @@ -936,6 +939,12 @@ def main(): if is_already_open(): error_messages.already_open() + if KEYBOARD_GROUPS_ISSUE: + 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/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 2bc97b36..695c860b 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -1,3 +1,7 @@ +import sys + +if sys.platform != "win32": + raise OSError import ctypes import numpy as np diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index 1b694422..1ec37003 100644 --- a/src/capture_method/DesktopDuplicationCaptureMethod.py +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -1,3 +1,7 @@ +import sys + +if sys.platform != "win32": + raise OSError from typing import TYPE_CHECKING, cast import cv2 diff --git a/src/capture_method/ForceFullContentRenderingCaptureMethod.py b/src/capture_method/ForceFullContentRenderingCaptureMethod.py index ebc4cc40..1d546141 100644 --- a/src/capture_method/ForceFullContentRenderingCaptureMethod.py +++ b/src/capture_method/ForceFullContentRenderingCaptureMethod.py @@ -1,3 +1,7 @@ +import sys + +if sys.platform != "win32": + raise OSError from capture_method.BitBltCaptureMethod import BitBltCaptureMethod diff --git a/src/capture_method/Screenshot using QT attempt.py b/src/capture_method/Screenshot using QT attempt.py new file mode 100644 index 00000000..7846abd1 --- /dev/null +++ b/src/capture_method/Screenshot using QT attempt.py @@ -0,0 +1,32 @@ +# flake8: noqa +import sys + +if sys.platform != "linux": + raise OSError() +from typing import cast + +import numpy as np +from cv2.typing import MatLike +from PySide6.QtCore import QBuffer, QIODeviceBase +from PySide6.QtGui import QGuiApplication +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod +from typing_extensions import override + + +class QtCaptureMethod(ThreadedLoopCaptureMethod): + _render_full_content = False + + @override + def _read_action(self): + buffer = QBuffer() + buffer.open(QIODeviceBase.OpenModeFlag.ReadWrite) + winid = self._autosplit_ref.winId() + test = QGuiApplication.primaryScreen().grabWindow(winid, 0, 0, 200, 200) + image = test.toImage() + b = image.bits() + # sip.voidptr must know size to support python buffer interface + # b.setsize(200 * 200 * 3) + frame = np.frombuffer(cast(MatLike, b), np.uint8).reshape((200, 200, 3)) + + # frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + return frame diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py new file mode 100644 index 00000000..6368109a --- /dev/null +++ b/src/capture_method/ScrotCaptureMethod.py @@ -0,0 +1,59 @@ +import sys + +if sys.platform != "linux": + raise OSError + +import cv2 +import numpy as np +import pyscreeze +from pywinctl import getWindowsWithTitle +from typing_extensions import override +from Xlib.display import Display +from Xlib.error import BadWindow + +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod +from utils import is_valid_image + + +class ScrotCaptureMethod(ThreadedLoopCaptureMethod): + name = "Scrot" + short_description = "very slow, may leave files" + description = ( + "\nUses Scrot (SCReenshOT) to take screenshots. " + + "\nLeaves behind a screenshot file if interrupted. " + ) + + @override + def _read_action(self): + if not self.check_selected_region_exists(): + return None + xdisplay = Display() + root = xdisplay.screen().root + try: + data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + except BadWindow: + return None + offset_x = data["x"] + offset_y = data["y"] + selection = self._autosplit_ref.settings_dict["capture_region"] + image = pyscreeze.screenshot( + None, + ( + selection["x"] + offset_x, + selection["y"] + offset_y, + selection["width"], + selection["height"], + ), + ) + image = np.array(image) + if not is_valid_image(image): + return None + return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) + + @override + def recover_window(self, captured_window_title: str): + windows = getWindowsWithTitle(captured_window_title) + if len(windows) == 0: + return False + self._autosplit_ref.hwnd = windows[0].getHandle() + return self.check_selected_region_exists() diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index d194ef1f..14d8e617 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -1,15 +1,18 @@ +import sys from typing import TYPE_CHECKING import cv2 import cv2.Error import numpy as np from cv2.typing import MatLike -from pygrabber.dshow_graph import FilterGraph from typing_extensions import override from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import ImageShape, is_valid_image +if sys.platform == "win32": + from pygrabber.dshow_graph import FilterGraph + if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -41,6 +44,7 @@ class VideoCaptureDeviceCaptureMethod(ThreadedLoopCaptureMethod): capture_device: cv2.VideoCapture def __init__(self, autosplit: "AutoSplit"): + super().__init__(autosplit) self.capture_device = cv2.VideoCapture(autosplit.settings_dict["capture_device_id"]) self.capture_device.setExceptionMode(True) @@ -49,19 +53,18 @@ def __init__(self, autosplit: "AutoSplit"): self.close() return - filter_graph = FilterGraph() - filter_graph.add_video_input_device(autosplit.settings_dict["capture_device_id"]) - width, height = filter_graph.get_input_device().get_current_format() - filter_graph.remove_filters() - # Ensure we're using the right camera size. And not OpenCV's default 640x480 - try: - self.capture_device.set(cv2.CAP_PROP_FRAME_WIDTH, width) - self.capture_device.set(cv2.CAP_PROP_FRAME_HEIGHT, height) - except cv2.error: - # Some cameras don't allow changing the resolution - pass - super().__init__(autosplit) + if sys.platform == "win32": + filter_graph = FilterGraph() + filter_graph.add_video_input_device(autosplit.settings_dict["capture_device_id"]) + width, height = filter_graph.get_input_device().get_current_format() + filter_graph.remove_filters() + try: + self.capture_device.set(cv2.CAP_PROP_FRAME_WIDTH, width) + self.capture_device.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + except cv2.error: + # Some cameras don't allow changing the resolution + pass @override def close(self): diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index f0b757eb..4b1c92fa 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -1,3 +1,7 @@ +import sys + +if sys.platform != "win32": + raise OSError import asyncio from typing import TYPE_CHECKING, cast diff --git a/src/capture_method/XcbCaptureMethod.py b/src/capture_method/XcbCaptureMethod.py new file mode 100644 index 00000000..41d4e040 --- /dev/null +++ b/src/capture_method/XcbCaptureMethod.py @@ -0,0 +1,62 @@ +import sys + +if sys.platform != "linux": + raise OSError + +import cv2 +import numpy as np +from PIL import ImageGrab +from pywinctl import getWindowsWithTitle +from typing_extensions import override +from Xlib.display import Display +from Xlib.error import BadWindow + +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod +from utils import is_valid_image + + +class XcbCaptureMethod(ThreadedLoopCaptureMethod): + name = "X11 XCB" + short_description = "fast, requires XCB" + description = "\nUses the XCB library to take screenshots of the X11 server." + + _xdisplay: str | None = "" # ":0" + + @override + def _read_action(self): + if not self.check_selected_region_exists(): + return None + xdisplay = Display() + root = xdisplay.screen().root + try: + data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + except BadWindow: + return None + offset_x = data["x"] + offset_y = data["y"] + # image = window.get_image(selection["x"], selection["y"], selection["width"], selection["height"], 1, 0) + + selection = self._autosplit_ref.settings_dict["capture_region"] + x = selection["x"] + offset_x + y = selection["y"] + offset_y + image = ImageGrab.grab( + ( + x, + y, + x + selection["width"], + y + selection["height"], + ), + xdisplay=self._xdisplay, + ) + image = np.array(image) + if not is_valid_image(image): + return None + return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) + + @override + def recover_window(self, captured_window_title: str): + windows = getWindowsWithTitle(captured_window_title) + if len(windows) == 0: + return False + self._autosplit_ref.hwnd = windows[0].getHandle() + return self.check_selected_region_exists() diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 5abadd91..b00ebce1 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -1,22 +1,35 @@ import asyncio +import os +import sys from collections import OrderedDict from dataclasses import dataclass from enum import Enum, EnumMeta, auto, unique from itertools import starmap -from typing import TYPE_CHECKING, NoReturn, TypedDict, cast +from typing import TYPE_CHECKING, TypedDict, cast -from _ctypes import COMError -from pygrabber.dshow_graph import FilterGraph from typing_extensions import Never, override -from capture_method.BitBltCaptureMethod import BitBltCaptureMethod from capture_method.CaptureMethodBase import CaptureMethodBase -from capture_method.DesktopDuplicationCaptureMethod import DesktopDuplicationCaptureMethod -from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod from capture_method.VideoCaptureDeviceCaptureMethod import VideoCaptureDeviceCaptureMethod -from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod from utils import WGC_MIN_BUILD, WINDOWS_BUILD_NUMBER, first, try_get_direct3d_device +if sys.platform == "win32": + from _ctypes import COMError + from pygrabber.dshow_graph import FilterGraph + + from capture_method.BitBltCaptureMethod import BitBltCaptureMethod + from capture_method.DesktopDuplicationCaptureMethod import DesktopDuplicationCaptureMethod + from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod + from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod + +if sys.platform == "linux": + import pyscreeze + from PIL import UnidentifiedImageError, features + + from capture_method.ScrotCaptureMethod import ScrotCaptureMethod + from capture_method.XcbCaptureMethod import XcbCaptureMethod + + if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -71,6 +84,8 @@ def _generate_next_value_(name: "str | CaptureMethodEnum", *_): WINDOWS_GRAPHICS_CAPTURE = auto() PRINTWINDOW_RENDERFULLCONTENT = auto() DESKTOP_DUPLICATION = auto() + SCROT = auto() + XCB = auto() VIDEO_CAPTURE_DEVICE = auto() @@ -99,7 +114,7 @@ def get_method_by_index(self, index: int): def __getitem__( # type:ignore[override] # pyright: ignore[reportIncompatibleMethodOverride] self, __key: Never, - ) -> NoReturn | type[CaptureMethodBase]: + ) -> type[CaptureMethodBase]: return super().__getitem__(__key) @override @@ -115,22 +130,34 @@ def get(self, key: CaptureMethodEnum, __default: object = None): CAPTURE_METHODS = CaptureMethodDict() -if ( # Windows Graphics Capture requires a minimum Windows Build - WINDOWS_BUILD_NUMBER >= WGC_MIN_BUILD - # Our current implementation of Windows Graphics Capture does not ensure we can get an ID3DDevice - and try_get_direct3d_device() -): - CAPTURE_METHODS[CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE] = WindowsGraphicsCaptureMethod -CAPTURE_METHODS[CaptureMethodEnum.BITBLT] = BitBltCaptureMethod -try: # Test for laptop cross-GPU Desktop Duplication issue - import d3dshot - - d3dshot.create(capture_output="numpy") -except (ModuleNotFoundError, COMError): - pass -else: - CAPTURE_METHODS[CaptureMethodEnum.DESKTOP_DUPLICATION] = DesktopDuplicationCaptureMethod -CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = ForceFullContentRenderingCaptureMethod +if sys.platform == "win32": + if ( # Windows Graphics Capture requires a minimum Windows Build + WINDOWS_BUILD_NUMBER >= WGC_MIN_BUILD + # Our current implementation of Windows Graphics Capture does not ensure we can get an ID3DDevice + and try_get_direct3d_device() + ): + CAPTURE_METHODS[CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE] = WindowsGraphicsCaptureMethod + CAPTURE_METHODS[CaptureMethodEnum.BITBLT] = BitBltCaptureMethod + try: # Test for laptop cross-GPU Desktop Duplication issue + import d3dshot + + d3dshot.create(capture_output="numpy") + except (ModuleNotFoundError, COMError): + pass + else: + CAPTURE_METHODS[CaptureMethodEnum.DESKTOP_DUPLICATION] = DesktopDuplicationCaptureMethod + CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = ForceFullContentRenderingCaptureMethod +elif sys.platform == "linux": + if features.check_feature(feature="xcb"): + CAPTURE_METHODS[CaptureMethodEnum.XCB] = XcbCaptureMethod + try: + pyscreeze.screenshot() + except UnidentifiedImageError: + pass + else: + # TODO: Investigate solution for Slow Scrot: + # https://github.com/asweigart/pyscreeze/issues/68 + CAPTURE_METHODS[CaptureMethodEnum.SCROT] = ScrotCaptureMethod CAPTURE_METHODS[CaptureMethodEnum.VIDEO_CAPTURE_DEVICE] = VideoCaptureDeviceCaptureMethod @@ -159,7 +186,24 @@ class CameraInfo: resolution: tuple[int, int] -def get_input_device_resolution(index: int): +def get_input_devices(): + if sys.platform == "win32": + return FilterGraph().get_input_devices() + + cameras: list[str] = [] + if sys.platform == "linux": + try: + for index in range(len(os.listdir("/sys/class/video4linux"))): + with open(f"/sys/class/video4linux/video{index}/name", encoding="utf-8") as file: + cameras.append(file.readline()[:-2]) + except FileNotFoundError: + pass + return cameras + + +def get_input_device_resolution(index: int) -> tuple[int, int] | None: + if sys.platform != "win32": + return (0, 0) filter_graph = FilterGraph() try: filter_graph.add_video_input_device(index) @@ -174,7 +218,7 @@ def get_input_device_resolution(index: int): async def get_all_video_capture_devices(): - named_video_inputs = FilterGraph().get_input_devices() + named_video_inputs = get_input_devices() async def get_camera_info(index: int, device_name: str): backend = "" diff --git a/src/error_messages.py b/src/error_messages.py index b9007ae3..cd006c95 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -145,6 +145,40 @@ def already_open(): ) +def linux_groups(): + set_text_message( + "Linux users must ensure they are in the 'tty' and 'input' groups " + + "and have write access to '/dev/uinput'. You can run the following commands to do so:", + # Keep in sync with README.md and scripts/install.ps1 + "sudo usermod -a -G tty,input $USER" + + "\nsudo touch /dev/uinput" + + "\nsudo chmod +0666 /dev/uinput" + + "\necho 'KERNEL==\"uinput\", TAG+=\"uaccess\"' | sudo tee /etc/udev/rules.d/50-uinput.rules" + + "\necho 'SUBSYSTEM==\"input\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee /etc/udev/rules.d/12-input.rules" + + "\necho 'SUBSYSTEM==\"misc\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee -a /etc/udev/rules.d/12-input.rules" + + "\necho 'SUBSYSTEM==\"tty\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee -a /etc/udev/rules.d/12-input.rules" + + "\nloginctl terminate-user $USER", + ) + + +def linux_uinput(): + set_text_message( + "Failed to create a device file using `uinput` module. " + + "This can happen when running Linux under WSL. " + + "Keyboard events have been disabled.", + ) + + +# 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: " \ + + '\n' \ + + "https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop" + + +def linux_wayland(): + set_text_message(WAYLAND_WARNING) + + def exception_traceback(exception: BaseException, message: str = ""): if not message: message = ( diff --git a/src/hotkeys.py b/src/hotkeys.py index b33058ef..ea83651b 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -1,3 +1,4 @@ +import sys from collections.abc import Callable from typing import TYPE_CHECKING, Literal, cast @@ -6,7 +7,18 @@ from PySide6 import QtWidgets import error_messages -from utils import fire_and_forget, is_digit +from utils import fire_and_forget, is_digit, try_input_device_access + +if sys.platform == "linux": + import grp + import os + + groups = {grp.getgrgid(group).gr_name for group in os.getgroups()} + KEYBOARD_GROUPS_ISSUE = not {"input", "tty"}.issubset(groups) + KEYBOARD_UINPUT_ISSUE = not try_input_device_access() +else: + KEYBOARD_GROUPS_ISSUE = False + KEYBOARD_UINPUT_ISSUE = False if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -23,7 +35,8 @@ def remove_all_hotkeys(): - keyboard.unhook_all() + if not KEYBOARD_GROUPS_ISSUE and not KEYBOARD_UINPUT_ISSUE: + keyboard.unhook_all() def before_setting_hotkey(autosplit: "AutoSplit"): @@ -239,6 +252,15 @@ def is_valid_hotkey_name(hotkey_name: str): def set_hotkey(autosplit: "AutoSplit", hotkey: Hotkey, preselected_hotkey_name: str = ""): + if KEYBOARD_GROUPS_ISSUE: + if not preselected_hotkey_name: + error_messages.linux_groups() + return + if KEYBOARD_UINPUT_ISSUE: + if not preselected_hotkey_name: + error_messages.linux_uinput() + return + if autosplit.SettingsWidget: # Unfocus all fields cast(QtWidgets.QWidget, autosplit.SettingsWidget).setFocus() diff --git a/src/menu_bar.py b/src/menu_bar.py index 20d51f16..8ae61121 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -1,5 +1,6 @@ import asyncio import json +import sys import webbrowser from typing import TYPE_CHECKING, Any, cast from urllib.error import URLError @@ -29,6 +30,13 @@ from AutoSplit import AutoSplit 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-get install scrot" +) if sys.platform == "linux" else "" class __AboutWidget(QtWidgets.QWidget, about.Ui_AboutAutoSplitWidget): # noqa: N801 # Private class @@ -166,7 +174,7 @@ def __init__(self, autosplit: "AutoSplit"): "\n\n".join([ f"{method.name} :\n{method.description}" for method in capture_method_values - ]), + ]) + LINUX_SCREENSHOT_SUPPORT, ) # endregion @@ -263,6 +271,10 @@ def __set_readme_link(self): lambda: webbrowser.open(f"https://github.com/{GITHUB_REPOSITORY}#readme"), ) self.readme_link_button.setStyleSheet("border: 0px; background-color:rgba(0,0,0,0%);") + if sys.platform == "linux": + geometry = self.readme_link_button.geometry() + self.readme_link_button.setText("#DOC#") # In-button font has different width so "README" doesn't fit -.- + self.readme_link_button.setGeometry(QtCore.QRect(116, 220, geometry.width(), geometry.height())) def __select_screenshot_directory(self): self._autosplit_ref.settings_dict["screenshot_directory"] = QFileDialog.getExistingDirectory( diff --git a/src/region_selection.py b/src/region_selection.py index 47665275..51431284 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -1,20 +1,14 @@ import os +import sys from math import ceil from typing import TYPE_CHECKING import cv2 import numpy as np -import win32api -import win32gui from cv2.typing import MatLike from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtTest import QTest -from pywinctl import getTopWindowAt from typing_extensions import override -from win32con import SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN -from winsdk._winrt import initialize_with_window -from winsdk.windows.foundation import AsyncStatus, IAsyncOperation -from winsdk.windows.graphics.capture import GraphicsCaptureItem, GraphicsCapturePicker import error_messages from capture_method import Region @@ -28,9 +22,27 @@ is_valid_image, ) +if sys.platform == "win32": + import win32api + import win32gui + from win32con import SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN + from winsdk._winrt import initialize_with_window + from winsdk.windows.foundation import AsyncStatus, IAsyncOperation + from winsdk.windows.graphics.capture import GraphicsCaptureItem, GraphicsCapturePicker + +if sys.platform == "linux": + from Xlib.display import Display + + # This variable may be missing in desktopless environment. x11 | wayland + os.environ.setdefault("XDG_SESSION_TYPE", "x11") + +# Must come after the linux XDG_SESSION_TYPE environment variable is set +from pywinctl import getTopWindowAt + if TYPE_CHECKING: from AutoSplit import AutoSplit +GNOME_DESKTOP_ICONS_EXTENSION = "@!0,0;BDHF" ALIGN_REGION_THRESHOLD = 0.9 BORDER_WIDTH = 2 SUPPORTED_IMREAD_FORMATS = [ @@ -56,9 +68,19 @@ ) +def get_top_window_at(x: int, y: int): + """Give QWidget time to disappear to avoid Xlib.error.BadDrawable on Linux.""" + if sys.platform == "linux": + # Tested in increments of 10ms on my Pop!_OS 22.04 VM + QTest.qWait(80) + return getTopWindowAt(x, y) + + # TODO: For later as a different picker option def __select_graphics_item(autosplit: "AutoSplit"): # pyright: ignore [reportUnusedFunction] """Uses the built-in GraphicsCapturePicker to select the Window.""" + if sys.platform != "win32": + raise OSError def callback(async_operation: IAsyncOperation[GraphicsCaptureItem], async_status: AsyncStatus): try: @@ -96,7 +118,7 @@ def select_region(autosplit: "AutoSplit"): if selection is None: return # No selection done - window = getTopWindowAt(selection["x"], selection["y"]) + window = get_top_window_at(selection["x"], selection["y"]) if not window: error_messages.region() return @@ -110,10 +132,16 @@ def select_region(autosplit: "AutoSplit"): autosplit.settings_dict["captured_window_title"] = window_text autosplit.capture_method.reinitialize() - left_bounds, top_bounds, *_ = get_window_bounds(hwnd) - window_x, window_y, *_ = win32gui.GetWindowRect(hwnd) - offset_x = window_x + left_bounds - offset_y = window_y + top_bounds + if sys.platform == "win32": + left_bounds, top_bounds, *_ = get_window_bounds(hwnd) + window_x, window_y, *_ = win32gui.GetWindowRect(hwnd) + offset_x = window_x + left_bounds + offset_y = window_y + top_bounds + else: + data = window._xWin.translate_coords(autosplit.hwnd, 0, 0)._data # pyright:ignore[reportPrivateUsage] # noqa: SLF001 + offset_x = data["x"] + offset_y = data["y"] + __set_region_values( autosplit, x=selection["x"] - offset_x, @@ -136,7 +164,7 @@ def select_window(autosplit: "AutoSplit"): if selection is None: return # No selection done - window = getTopWindowAt(selection["x"], selection["y"]) + window = get_top_window_at(selection["x"], selection["y"]) if not window: error_messages.region() return @@ -150,11 +178,18 @@ def select_window(autosplit: "AutoSplit"): autosplit.settings_dict["captured_window_title"] = window_text autosplit.capture_method.reinitialize() - # Exlude the borders and titlebar from the window selection. To only get the client area. - _, __, window_width, window_height = get_window_bounds(hwnd) - _, __, client_width, client_height = win32gui.GetClientRect(hwnd) - border_width = ceil((window_width - client_width) / 2) - titlebar_with_border_height = window_height - client_height - border_width + if sys.platform == "win32": + # Exlude the borders and titlebar from the window selection. To only get the client area. + _, __, window_width, window_height = get_window_bounds(hwnd) + _, __, client_width, client_height = win32gui.GetClientRect(hwnd) + border_width = ceil((window_width - client_width) / 2) + titlebar_with_border_height = window_height - client_height - border_width + else: + data = window._xWin.get_geometry()._data # pyright:ignore[reportPrivateUsage] # noqa: SLF001 + client_height = data["height"] + client_width = data["width"] + border_width = data["border_width"] + titlebar_with_border_height = border_width __set_region_values( autosplit, @@ -294,10 +329,17 @@ def __init__(self): super().__init__() # We need to pull the monitor information to correctly draw the geometry covering all portions # of the user's screen. These parameters create the bounding box with left, top, width, and height - x = win32api.GetSystemMetrics(SM_XVIRTUALSCREEN) - y = win32api.GetSystemMetrics(SM_YVIRTUALSCREEN) - width = win32api.GetSystemMetrics(SM_CXVIRTUALSCREEN) - height = win32api.GetSystemMetrics(SM_CYVIRTUALSCREEN) + if sys.platform == "win32": + x = win32api.GetSystemMetrics(SM_XVIRTUALSCREEN) + y = win32api.GetSystemMetrics(SM_YVIRTUALSCREEN) + width = win32api.GetSystemMetrics(SM_CXVIRTUALSCREEN) + height = win32api.GetSystemMetrics(SM_CYVIRTUALSCREEN) + else: + data = Display().screen().root.get_geometry()._data # noqa: SLF001 + x = data["x"] + y = data["y"] + width = data["width"] + height = data["height"] self.setGeometry(x, y, width, height) self.setFixedSize(width, height) # Prevent move/resizing on Linux self.setWindowTitle(type(self).__name__) diff --git a/src/utils.py b/src/utils.py index 651f83a8..62bb8f24 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,7 +1,6 @@ import asyncio -import ctypes -import ctypes.wintypes import os +import subprocess import sys from collections.abc import Callable, Iterable from enum import IntEnum @@ -10,14 +9,28 @@ from threading import Thread from typing import TYPE_CHECKING, Any, TypeGuard, TypeVar -import win32gui -import win32ui from cv2.typing import MatLike -from winsdk.windows.ai.machinelearning import LearningModelDevice, LearningModelDeviceKind -from winsdk.windows.media.capture import MediaCapture from gen.build_vars import AUTOSPLIT_BUILD_NUMBER, AUTOSPLIT_GITHUB_REPOSITORY +if sys.platform == "win32": + import ctypes + import ctypes.wintypes + + import win32gui + import win32ui + from winsdk.windows.ai.machinelearning import LearningModelDevice, LearningModelDeviceKind + from winsdk.windows.media.capture import MediaCapture + +RUNNING_WAYLAND: bool = False +if sys.platform == "linux": + import fcntl + + from pyscreeze import ( + RUNNING_WAYLAND as RUNNING_WAYLAND, # pyright: ignore[reportConstantRedefinition, reportGeneralTypeIssues, reportUnknownVariableType] # noqa: PLC0414 + ) + + if TYPE_CHECKING: # Source does not exist, keep this under TYPE_CHECKING from _win32typing import PyCDC # pyright: ignore[reportMissingModuleSource] @@ -83,6 +96,8 @@ def first(iterable: Iterable[T]) -> T: def try_delete_dc(dc: "PyCDC"): + if sys.platform != "win32": + raise OSError try: dc.DeleteDC() except win32ui.error: @@ -90,6 +105,9 @@ def try_delete_dc(dc: "PyCDC"): def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: + if sys.platform != "win32": + raise OSError + extended_frame_bounds = ctypes.wintypes.RECT() ctypes.windll.dwmapi.DwmGetWindowAttribute( hwnd, @@ -107,7 +125,11 @@ def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: def open_file(file_path: str | bytes | os.PathLike[str] | os.PathLike[bytes]): - os.startfile(file_path) # noqa: S606 + if sys.platform == "win32": + os.startfile(file_path) # noqa: S606 + else: + opener = "xdg-open" if sys.platform == "linux" else "open" + subprocess.call([opener, file_path]) # noqa: S603 def get_or_create_eventloop(): @@ -120,13 +142,15 @@ def get_or_create_eventloop(): def get_direct3d_device(): + if sys.platform != "win32": + raise OSError("Direct3D Device is only available on Windows") + # Note: Must create in the same thread (can't use a global) otherwise when ran from LiveSplit it will raise: # OSError: The application called an interface that was marshalled for a different thread media_capture = MediaCapture() async def init_mediacapture(): await media_capture.initialize_async() - asyncio.run(init_mediacapture()) direct_3d_device = media_capture.media_capture_settings and media_capture.media_capture_settings.direct3_d11_device if not direct_3d_device: @@ -148,6 +172,19 @@ def try_get_direct3d_device(): return None +def try_input_device_access(): + """Same as `make_uinput` in `keyboard/_nixcommon.py`.""" + if sys.platform != "linux": + return False + try: + UI_SET_EVBIT = 0x40045564 # noqa: N806 + with open("/dev/uinput", "wb") as uinput: + fcntl.ioctl(uinput, UI_SET_EVBIT) + except OSError: + return False + return True + + def fire_and_forget(func: Callable[..., Any]): """ Runs synchronous function asynchronously without waiting for a response.