From e73a5549ffe66702003616143adf54bf486ab06d Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 8 Dec 2023 21:47:50 -0500 Subject: [PATCH 1/5] Merge back linux changes --- .github/workflows/lint-and-build.yml | 15 ++-- .pre-commit-config.yaml | 4 - .vscode/settings.json | 2 + .vscode/tasks.json | 22 +++++ README.md | 35 ++++++++ docs/build instructions.md | 7 +- scripts/build.ps1 | 20 ++++- scripts/designer.ps1 | 9 +- scripts/install.ps1 | 56 ++++++++++-- scripts/requirements-dev.txt | 1 + scripts/requirements.txt | 4 + scripts/start.ps1 | 3 +- src/AutoSplit.py | 15 +++- src/capture_method/BitBltCaptureMethod.py | 4 + .../DesktopDuplicationCaptureMethod.py | 4 + .../ForceFullContentRenderingCaptureMethod.py | 4 + .../Screenshot using QT attempt.py | 36 ++++++++ src/capture_method/ScrotCaptureMethod.py | 50 +++++++++++ .../VideoCaptureDeviceCaptureMethod.py | 27 +++--- .../WindowsGraphicsCaptureMethod.py | 4 + src/capture_method/XDisplayCaptureMethod.py | 65 ++++++++++++++ src/capture_method/__init__.py | 90 ++++++++++++++----- src/error_messages.py | 34 +++++++ src/hotkeys.py | 27 +++++- src/menu_bar.py | 14 ++- src/region_selection.py | 81 +++++++++++------ src/utils.py | 46 ++++++++-- 27 files changed, 584 insertions(+), 95 deletions(-) create mode 100644 src/capture_method/Screenshot using QT attempt.py create mode 100644 src/capture_method/ScrotCaptureMethod.py create mode 100644 src/capture_method/XDisplayCaptureMethod.py 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index ffd3a619..2e5a5187 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,3 @@ repos: ci: autoupdate_branch: dev autoupdate_schedule: monthly - skip: - # Ignore until Linux support. We don't want lf everywhere yet - # And crlf fails on CI because pre-commit runs on linux - - "mixed-line-ending" diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c75e33f..5adca23d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -97,6 +97,8 @@ "powershell.codeFormatting.whitespaceBetweenParameters": true, "powershell.integratedConsole.showOnStartup": false, "terminal.integrated.defaultProfile.windows": "PowerShell", + "terminal.integrated.defaultProfile.linux": "pwsh", + "terminal.integrated.defaultProfile.osx": "pwsh", "xml.codeLens.enabled": true, "xml.format.spaceBeforeEmptyCloseTag": false, "xml.format.preserveSpace": [ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 65ac2dc5..a38720e1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,6 +13,28 @@ } } }, + "linux": { + "options": { + "shell": { + "executable": "pwsh", + "args": [ + "-NoProfile", + "-Command" + ] + } + } + }, + "osx": { + "options": { + "shell": { + "executable": "pwsh", + "args": [ + "-NoProfile", + "-Command" + ] + } + } + }, "tasks": [ { "label": "Compile resources", diff --git a/README.md b/README.md index c8a4e856..f651eb03 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,29 @@ 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 supported - 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 +90,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 +110,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 + +- **XDisplay** (fast, requires xcb) + Uses X to take screenshots of the display. +- **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 3d700c87..bcf29568 100644 --- a/docs/build instructions.md +++ b/docs/build instructions.md @@ -6,12 +6,17 @@ - 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+. - [Node](https://nodejs.org) is optional, but required for complete linting. - Alternatively you can install the [pyright python wrapper](https://pypi.org/project/pyright/) which has a bit of an overhead delay. -- [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) +- [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) is used to run all the scripts - [VSCode](https://code.visualstudio.com/Download) is not required, but highly recommended. - Everything already configured in the workspace, including Run (F5) and Build (Ctrl+Shift+B) commands, default shell, and recommended extensions. - [PyCharm](https://www.jetbrains.com/pycharm/) is also a good Python IDE, but nothing is configured. If you are a PyCharm user, feel free to open a PR with all necessary workspace configurations! diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 94ebd2e9..fc284fe0 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -12,12 +12,30 @@ $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 imagehash.whash '--exclude=pywt') +if ($IsWindows) { + # Installed by PyAutoGUI + $arguments += '--exclude=pyscreeze' +} +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 140af292..762c2c1d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,7 +1,50 @@ +$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 + sudo apt-get install -y python3-pip python3-tk npm + # Helps ensure build machine has the required PySide6 libraries for all target machines. + # Not everything here is required, but using the documentation from + # https://wiki.qt.io/Building_Qt_5_from_Git#Libxcb + # TODO: Test if still necessary with PySide6 + sudo apt-get install -y '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev + } +} # 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,20 +58,23 @@ pip install PyAutoGUI ImageHash scipy --no-deps --upgrade # 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])' +$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])' +$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" # Uninstall optional dependencies if PyAutoGUI was installed outside this script # pyscreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness # 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 pygetwindow pymsgbox pytweening MouseInfo -y +pip uninstall pyscreenshot mss pygetwindow pymsgbox pytweening MouseInfo -y +If (-not $IsLinux) { + pip uninstall pyscreeze +} # Don't compile resources on the Build CI job as it'll do so in build script diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index 6d233d8e..0e354f68 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -23,6 +23,7 @@ types-Pillow types-psutil types-PyAutoGUI types-pyinstaller +types-python-xlib ; sys_platform == 'linux' types-pywin32 ; sys_platform == 'win32' types-requests types-toml diff --git a/scripts/requirements.txt b/scripts/requirements.txt index e01f9fcc..33ad5096 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -30,3 +30,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 git+https://github.com/ranchen421/D3DShot.git#egg=D3DShot ; sys_platform == 'win32' # D3DShot from PyPI with Pillow>=7.2.0 will install 0.1.3 instead of 0.1.5 +# +# 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 5633bfd4..90f1fa1e 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -24,7 +24,7 @@ from AutoSplitImage import START_KEYWORD, 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, @@ -42,6 +42,7 @@ AUTOSPLIT_VERSION, BGRA_CHANNEL_COUNT, FROZEN, + IS_WAYLAND, ONE_SECOND, QTIMER_FPS_LIMIT, auto_split_directory, @@ -53,8 +54,9 @@ # Needed when compiled, along with the custom hook-requests PyInstaller hook os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() -myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" -ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) +if sys.platform == "win32": + myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) class AutoSplit(QMainWindow, design.Ui_MainWindow): @@ -954,6 +956,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 @@ -978,6 +981,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 IS_WAYLAND: + error_messages.linux_wayland() AutoSplit() diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 94dc667f..901c5e57 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 ctypes.wintypes diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index 6e15a6dd..f76fed24 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 import ctypes from typing import TYPE_CHECKING, cast 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..b87b8caf --- /dev/null +++ b/src/capture_method/Screenshot using QT attempt.py @@ -0,0 +1,36 @@ +# flake8: noqa +import sys + +if sys.platform != "linux": + raise OSError() +from typing import TYPE_CHECKING, cast + +import cv2 +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 CaptureMethodBase +from typing_extensions import override + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +class ScrotCaptureMethod(CaptureMethodBase): + _render_full_content = False + + @override + def get_frame(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..349562ea --- /dev/null +++ b/src/capture_method/ScrotCaptureMethod.py @@ -0,0 +1,50 @@ +import sys + +if sys.platform != "linux": + raise OSError + +import cv2 +import numpy as np +import pyscreeze +from typing_extensions import override +from Xlib.display import Display + +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 + data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + 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"], + ), + ) + return np.array(image) + + @override + def get_frame(self): + image = super().get_frame() + if not is_valid_image(image): + return None + return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 84765f23..d838a128 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 @@ -47,18 +50,18 @@ def __init__(self, autosplit: "AutoSplit"): if not self.capture_device.isOpened(): 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 + 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 super().__init__(autosplit) @override diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 10d4faa1..77a43f3c 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/XDisplayCaptureMethod.py b/src/capture_method/XDisplayCaptureMethod.py new file mode 100644 index 00000000..b3209460 --- /dev/null +++ b/src/capture_method/XDisplayCaptureMethod.py @@ -0,0 +1,65 @@ +import sys + +if sys.platform != "linux": + raise OSError + +import cv2 +import numpy as np +from PIL import ImageGrab +from typing_extensions import override +from Xlib.display import Display + +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod +from utils import is_valid_image + + +class XDisplayCaptureMethod(ThreadedLoopCaptureMethod): + name = "XDisplay" + short_description = "fast, requires xcb" + description = "\nUses XCB to take screenshots of the display" + + _xdisplay: str | None = "" # ":0" + + @override + def _read_action(self): + if not self.check_selected_region_exists(): + return None + xdisplay = Display() + root = xdisplay.screen().root + data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + 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, + ) + return np.array(image) + + @override + def get_frame(self): + image = super().get_frame() + if not is_valid_image(image): + return None + return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) + + @override + def recover_window(self, captured_window_title: str): + xdisplay = Display() + root = xdisplay.screen().root + children = root.query_tree().children + for window in children: + wm_class = window.get_wm_class() + if wm_class and wm_class[1] == captured_window_title: + self._autosplit_ref.hwnd = window.id + return self.check_selected_region_exists() + return False diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index de7e2581..46648da2 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 _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 features + + from capture_method.ScrotCaptureMethod import ScrotCaptureMethod + from capture_method.XDisplayCaptureMethod import XDisplayCaptureMethod + + 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() + XDISPLAY = auto() VIDEO_CAPTURE_DEVICE = auto() @@ -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.XDISPLAY] = XDisplayCaptureMethod + try: + pyscreeze.screenshot() + except NotImplementedError: + 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 @@ -160,7 +187,24 @@ class CameraInfo: resolution: tuple[int, 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): + if sys.platform != "win32": + return (0, 0) filter_graph = FilterGraph() try: filter_graph.add_video_input_device(index) @@ -175,7 +219,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 c455f265..9d5df4fe 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -149,6 +149,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 runnign 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 34f4519e..6c1676fc 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,19 @@ 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 + + # https://github.com/PyCQA/pylint/issues/7240 + 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 +36,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"): @@ -246,6 +260,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 e1f9cb20..37dea594 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -1,4 +1,5 @@ import asyncio +import sys import webbrowser from typing import TYPE_CHECKING, Any, cast @@ -28,6 +29,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 @@ -164,7 +172,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 @@ -262,6 +270,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%);") + # TODO: Check if this is still necessary now that fusion theme is used on both + # if sys.platform == "linux": + # geometry = self.readme_link_button.geometry() + # self.readme_link_button.setGeometry(QtCore.QRect(51, 225, 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 832c300a..b5c9dcb9 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -1,6 +1,5 @@ -import ctypes -import ctypes.wintypes import os +import sys from math import ceil from typing import TYPE_CHECKING @@ -11,11 +10,6 @@ from PySide6.QtTest import QTest from pywinctl import getTopWindowAt from typing_extensions import override -from win32 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 import error_messages from utils import ( @@ -28,12 +22,23 @@ is_valid_image, ) -user32 = ctypes.windll.user32 +if sys.platform == "win32": + import ctypes + from win32 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 + user32 = ctypes.windll.user32 + +if sys.platform == "linux": + from Xlib.display import Display 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 = [ @@ -62,6 +67,8 @@ # 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: @@ -106,7 +113,7 @@ def select_region(autosplit: "AutoSplit"): if not window: error_messages.region() return - hwnd = window.getHandle() + hwnd = window.getHandle().id if sys.platform == "linux" else window.getHandle() window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() @@ -116,10 +123,18 @@ 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: + xdisplay = Display() + root = xdisplay.screen().root + data = root.translate_coords(autosplit.hwnd, 0, 0)._data # noqa: SLF001 + offset_x = data["x"] + offset_y = data["y"] + __set_region_values( autosplit, left=x - offset_x, @@ -147,7 +162,7 @@ def select_window(autosplit: "AutoSplit"): if not window: error_messages.region() return - hwnd = window.getHandle() + hwnd = window.getHandle().id if sys.platform == "linux" else window.getHandle() window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() @@ -157,11 +172,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.getHandle().get_geometry()._data # 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, @@ -310,12 +332,21 @@ 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 - self.setGeometry( - user32.GetSystemMetrics(SM_XVIRTUALSCREEN), - user32.GetSystemMetrics(SM_YVIRTUALSCREEN), - user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), - user32.GetSystemMetrics(SM_CYVIRTUALSCREEN), - ) + if sys.platform == "win32": + self.setGeometry( + user32.GetSystemMetrics(SM_XVIRTUALSCREEN), + user32.GetSystemMetrics(SM_YVIRTUALSCREEN), + user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), + user32.GetSystemMetrics(SM_CYVIRTUALSCREEN), + ) + else: + data = Display().screen().root.get_geometry()._data # noqa: SLF001 + self.setGeometry( + data["x"], + data["y"], + data["width"], + data["height"], + ) self.setWindowTitle(" ") self.setWindowOpacity(0.5) self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) diff --git a/src/utils.py b/src/utils.py index c3507c3a..fae6c11d 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,22 @@ from threading import Thread from typing import TYPE_CHECKING, Any, TypeGuard, TypeVar -import win32ui from cv2.typing import MatLike -from win32 import win32gui -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 win32ui + from win32 import win32gui + from winsdk.windows.ai.machinelearning import LearningModelDevice, LearningModelDeviceKind + from winsdk.windows.media.capture import MediaCapture + +if sys.platform == "linux": + import fcntl + if TYPE_CHECKING: # Source does not exist, keep this under TYPE_CHECKING from _win32typing import PyCDC # pyright: ignore[reportMissingModuleSource] @@ -90,6 +97,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 +117,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 +134,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 +164,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. @@ -182,6 +211,7 @@ def flatten(nested_iterable: Iterable[Iterable[T]]) -> chain[T]: """Running from build made by PyInstaller""" auto_split_directory = os.path.dirname(sys.executable if FROZEN else os.path.abspath(__file__)) """The directory of either the AutoSplit executable or AutoSplit.py""" +IS_WAYLAND = bool(os.environ.get("WAYLAND_DISPLAY", False)) # Shared strings # Check `excludeBuildNumber` during workflow dispatch build generate a clean version number From d6d75c2b87b167f33b168e03c3dfd5c9143cdf69 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 8 Dec 2023 21:51:17 -0500 Subject: [PATCH 2/5] mixed-line-ending --fix=lf --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e5a5187..e75d93a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: mixed-line-ending - args: [--fix=crlf] + args: [--fix=lf] - id: check-case-conflict - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.11.0 From 9c7b2ae430bca18b1fc1a7329c4b0497f4da4b1d Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Dec 2023 05:09:04 -0500 Subject: [PATCH 3/5] All scripts and WSL GUI work --- .vscode/settings.json | 4 +- README.md | 5 ++- docs/CONTRIBUTING.md | 2 +- scripts/install.ps1 | 38 ++++++++++--------- scripts/linux_build_and_install_python.bash | 28 ++++++++++++++ scripts/requirements.txt | 3 +- .../Screenshot using QT attempt.py | 12 ++---- src/capture_method/ScrotCaptureMethod.py | 6 +-- .../VideoCaptureDeviceCaptureMethod.py | 3 +- src/capture_method/XDisplayCaptureMethod.py | 6 +-- src/capture_method/__init__.py | 6 +-- src/error_messages.py | 2 +- src/region_selection.py | 12 ++++-- src/utils.py | 2 + 14 files changed, 79 insertions(+), 50 deletions(-) create mode 100644 scripts/linux_build_and_install_python.bash diff --git a/.vscode/settings.json b/.vscode/settings.json index 5adca23d..815e9679 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,9 +21,9 @@ "editor.tabSize": 2, "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": true, + "source.fixAll": "explicit", // Let dedicated linter (Ruff) organize imports - "source.organizeImports": false, + "source.organizeImports": "never" }, "emeraldwalk.runonsave": { "commands": [ diff --git a/README.md b/README.md index f651eb03..695f63cd 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ This program can be used to automatically start, split, and reset your preferred - Windows 10 and 11. - Linux (Only tested on Ubuntu 22.04) - - Wayland is not supported + - 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 @@ -286,6 +287,8 @@ Not a developer? You can still help through the following methods: - Sharing AutoSplit with other speedrunners - Upvoting the following upstream issues in libraries and tools we use: - + - + - - - - diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 41f66bbe..d6066670 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,4 +1,4 @@ - + # Contributing guidelines diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 762c2c1d..7735670d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -34,13 +34,8 @@ $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 - sudo apt-get install -y python3-pip python3-tk npm - # Helps ensure build machine has the required PySide6 libraries for all target machines. - # Not everything here is required, but using the documentation from - # https://wiki.qt.io/Building_Qt_5_from_Git#Libxcb - # TODO: Test if still necessary with PySide6 - sudo apt-get install -y '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev + # python3-tk for splash screen, npm for pyright, the rest for PySide6 + sudo apt-get install -y python3-pip python3-tk npm libegl1 libxkbcommon } } # Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. @@ -61,21 +56,28 @@ pip install PyAutoGUI ImageHash scipy --no-deps --upgrade $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" + pip uninstall pyscreeze +} +# 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 was installed outside this script -# pyscreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness +# pyscreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness, used to be installed # pygetwindow, pymsgbox, pytweening, MouseInfo are picked up by PySide6 # (also --exclude from build script, but more consistent with unfrozen run) pip uninstall pyscreenshot mss pygetwindow pymsgbox pytweening MouseInfo -y -If (-not $IsLinux) { - pip uninstall pyscreeze -} - +If ($IsWindows) { pip uninstall pyscreeze } # Don't compile resources on the Build CI job as it'll do so in build script If ($dev) { diff --git a/scripts/linux_build_and_install_python.bash b/scripts/linux_build_and_install_python.bash new file mode 100644 index 00000000..20c82e75 --- /dev/null +++ b/scripts/linux_build_and_install_python.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.txt b/scripts/requirements.txt index 33ad5096..8871a35d 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -14,7 +14,8 @@ psutil>=5.9.6 # Python 3.12 fixes PyAutoGUI 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 requests>=2.28.2 # charset_normalizer 3.x update toml typing-extensions>=4.4.0 # @override decorator support diff --git a/src/capture_method/Screenshot using QT attempt.py b/src/capture_method/Screenshot using QT attempt.py index b87b8caf..7846abd1 100644 --- a/src/capture_method/Screenshot using QT attempt.py +++ b/src/capture_method/Screenshot using QT attempt.py @@ -3,25 +3,21 @@ if sys.platform != "linux": raise OSError() -from typing import TYPE_CHECKING, cast +from typing import cast -import cv2 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 CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from typing_extensions import override -if TYPE_CHECKING: - from AutoSplit import AutoSplit - -class ScrotCaptureMethod(CaptureMethodBase): +class QtCaptureMethod(ThreadedLoopCaptureMethod): _render_full_content = False @override - def get_frame(self): + def _read_action(self): buffer = QBuffer() buffer.open(QIODeviceBase.OpenModeFlag.ReadWrite) winid = self._autosplit_ref.winId() diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py index 349562ea..21c54489 100644 --- a/src/capture_method/ScrotCaptureMethod.py +++ b/src/capture_method/ScrotCaptureMethod.py @@ -40,11 +40,7 @@ def _read_action(self): selection["height"], ), ) - return np.array(image) - - @override - def get_frame(self): - image = super().get_frame() + image = np.array(image) if not is_valid_image(image): return None return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index d838a128..4cce4924 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -43,11 +43,13 @@ 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) # The video capture device isn't accessible, don't bother with it. if not self.capture_device.isOpened(): + self.close() return # Ensure we're using the right camera size. And not OpenCV's default 640x480 @@ -62,7 +64,6 @@ def __init__(self, autosplit: "AutoSplit"): except cv2.error: # Some cameras don't allow changing the resolution pass - super().__init__(autosplit) @override def close(self): diff --git a/src/capture_method/XDisplayCaptureMethod.py b/src/capture_method/XDisplayCaptureMethod.py index b3209460..7a9aa121 100644 --- a/src/capture_method/XDisplayCaptureMethod.py +++ b/src/capture_method/XDisplayCaptureMethod.py @@ -43,11 +43,7 @@ def _read_action(self): ), xdisplay=self._xdisplay, ) - return np.array(image) - - @override - def get_frame(self): - image = super().get_frame() + image = np.array(image) if not is_valid_image(image): return None return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 46648da2..65e6d832 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -24,7 +24,7 @@ if sys.platform == "linux": import pyscreeze - from PIL import features + from PIL import UnidentifiedImageError, features from capture_method.ScrotCaptureMethod import ScrotCaptureMethod from capture_method.XDisplayCaptureMethod import XDisplayCaptureMethod @@ -152,7 +152,7 @@ def get(self, key: CaptureMethodEnum, __default: object = None): CAPTURE_METHODS[CaptureMethodEnum.XDISPLAY] = XDisplayCaptureMethod try: pyscreeze.screenshot() - except NotImplementedError: + except UnidentifiedImageError: pass else: # TODO: Investigate solution for Slow Scrot: @@ -202,7 +202,7 @@ def get_input_devices(): return cameras -def get_input_device_resolution(index: int): +def get_input_device_resolution(index: int) -> tuple[int, int] | None: if sys.platform != "win32": return (0, 0) filter_graph = FilterGraph() diff --git a/src/error_messages.py b/src/error_messages.py index 9d5df4fe..6cf64805 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -168,7 +168,7 @@ def linux_groups(): def linux_uinput(): set_text_message( "Failed to create a device file using `uinput` module. " - + "This can happen when runnign Linux under WSL. " + + "This can happen when running Linux under WSL. " + "Keyboard events have been disabled.", ) diff --git a/src/region_selection.py b/src/region_selection.py index b5c9dcb9..da6880f0 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -8,7 +8,6 @@ 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 import error_messages @@ -34,6 +33,11 @@ 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 @@ -113,7 +117,7 @@ def select_region(autosplit: "AutoSplit"): if not window: error_messages.region() return - hwnd = window.getHandle().id if sys.platform == "linux" else window.getHandle() + hwnd = window.getHandle() window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() @@ -162,7 +166,7 @@ def select_window(autosplit: "AutoSplit"): if not window: error_messages.region() return - hwnd = window.getHandle().id if sys.platform == "linux" else window.getHandle() + hwnd = window.getHandle() window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() @@ -179,7 +183,7 @@ def select_window(autosplit: "AutoSplit"): border_width = ceil((window_width - client_width) / 2) titlebar_with_border_height = window_height - client_height - border_width else: - data = window.getHandle().get_geometry()._data # noqa: SLF001 + data = window._xWin.get_geometry()._data # pyright:ignore[reportPrivateUsage] # noqa: SLF001 client_height = data["height"] client_width = data["width"] border_width = data["border_width"] diff --git a/src/utils.py b/src/utils.py index fae6c11d..ea7425e9 100644 --- a/src/utils.py +++ b/src/utils.py @@ -90,6 +90,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: From b24730e23e2f0b2cd85792cb374fd892523c02e8 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Dec 2023 22:14:17 -0500 Subject: [PATCH 4/5] Linux window recording and recovery complete --- README.md | 4 +-- scripts/install.ps1 | 2 +- ...sh => python_build_from_source_linux.bash} | 0 src/AutoSplit.py | 4 +-- src/capture_method/ScrotCaptureMethod.py | 15 +++++++++- ...ayCaptureMethod.py => XcbCaptureMethod.py} | 29 ++++++++++--------- src/capture_method/__init__.py | 17 +++++------ src/menu_bar.py | 8 ++--- src/region_selection.py | 19 ++++++++---- src/utils.py | 11 +++++-- 10 files changed, 67 insertions(+), 42 deletions(-) rename scripts/{linux_build_and_install_python.bash => python_build_from_source_linux.bash} (100%) rename src/capture_method/{XDisplayCaptureMethod.py => XcbCaptureMethod.py} (65%) diff --git a/README.md b/README.md index 695f63cd..603ce5fc 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ This program can be used to automatically start, split, and reset your preferred ##### Linux -- **XDisplay** (fast, requires xcb) - Uses X to take screenshots of the display. +- **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. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7735670d..3f49f81a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -35,7 +35,7 @@ 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 libxkbcommon + 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. diff --git a/scripts/linux_build_and_install_python.bash b/scripts/python_build_from_source_linux.bash similarity index 100% rename from scripts/linux_build_and_install_python.bash rename to scripts/python_build_from_source_linux.bash diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 90f1fa1e..3651b33b 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -42,9 +42,9 @@ AUTOSPLIT_VERSION, BGRA_CHANNEL_COUNT, FROZEN, - IS_WAYLAND, ONE_SECOND, QTIMER_FPS_LIMIT, + RUNNING_WAYLAND, auto_split_directory, decimal, flatten, @@ -985,7 +985,7 @@ def main(): error_messages.linux_groups() if KEYBOARD_UINPUT_ISSUE: error_messages.linux_uinput() - if IS_WAYLAND: + if RUNNING_WAYLAND: error_messages.linux_wayland() AutoSplit() diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py index 21c54489..6368109a 100644 --- a/src/capture_method/ScrotCaptureMethod.py +++ b/src/capture_method/ScrotCaptureMethod.py @@ -6,8 +6,10 @@ 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 @@ -27,7 +29,10 @@ def _read_action(self): return None xdisplay = Display() root = xdisplay.screen().root - data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + 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"] @@ -44,3 +49,11 @@ def _read_action(self): 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/XDisplayCaptureMethod.py b/src/capture_method/XcbCaptureMethod.py similarity index 65% rename from src/capture_method/XDisplayCaptureMethod.py rename to src/capture_method/XcbCaptureMethod.py index 7a9aa121..41d4e040 100644 --- a/src/capture_method/XDisplayCaptureMethod.py +++ b/src/capture_method/XcbCaptureMethod.py @@ -6,17 +6,19 @@ 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 XDisplayCaptureMethod(ThreadedLoopCaptureMethod): - name = "XDisplay" - short_description = "fast, requires xcb" - description = "\nUses XCB to take screenshots of the display" +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" @@ -26,7 +28,10 @@ def _read_action(self): return None xdisplay = Display() root = xdisplay.screen().root - data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + 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) @@ -50,12 +55,8 @@ def _read_action(self): @override def recover_window(self, captured_window_title: str): - xdisplay = Display() - root = xdisplay.screen().root - children = root.query_tree().children - for window in children: - wm_class = window.get_wm_class() - if wm_class and wm_class[1] == captured_window_title: - self._autosplit_ref.hwnd = window.id - return self.check_selected_region_exists() - return False + 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 65e6d832..ec9df778 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -27,7 +27,7 @@ from PIL import UnidentifiedImageError, features from capture_method.ScrotCaptureMethod import ScrotCaptureMethod - from capture_method.XDisplayCaptureMethod import XDisplayCaptureMethod + from capture_method.XcbCaptureMethod import XcbCaptureMethod if TYPE_CHECKING: @@ -85,7 +85,7 @@ def _generate_next_value_(name: "str | CaptureMethodEnum", *_): PRINTWINDOW_RENDERFULLCONTENT = auto() DESKTOP_DUPLICATION = auto() SCROT = auto() - XDISPLAY = auto() + XCB = auto() VIDEO_CAPTURE_DEVICE = auto() @@ -149,7 +149,7 @@ def get(self, key: CaptureMethodEnum, __default: object = None): CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = ForceFullContentRenderingCaptureMethod elif sys.platform == "linux": if features.check_feature(feature="xcb"): - CAPTURE_METHODS[CaptureMethodEnum.XDISPLAY] = XDisplayCaptureMethod + CAPTURE_METHODS[CaptureMethodEnum.XCB] = XcbCaptureMethod try: pyscreeze.screenshot() except UnidentifiedImageError: @@ -167,15 +167,14 @@ def change_capture_method(selected_capture_method: CaptureMethodEnum, autosplit: initialize the new one with transfered subscriptions and update UI as needed. """ + subscriptions = autosplit.capture_method._subscriptions # pyright: ignore[reportPrivateUsage] # noqa: SLF001 autosplit.capture_method.close() autosplit.capture_method = CAPTURE_METHODS.get(selected_capture_method)(autosplit) + autosplit.capture_method._subscriptions = subscriptions # pyright: ignore[reportPrivateUsage] # noqa: SLF001 - if selected_capture_method == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: - autosplit.select_region_button.setDisabled(True) - autosplit.select_window_button.setDisabled(True) - else: - autosplit.select_region_button.setDisabled(False) - autosplit.select_window_button.setDisabled(False) + disable_selection_buttons = selected_capture_method == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE + autosplit.select_region_button.setDisabled(disable_selection_buttons) + autosplit.select_window_button.setDisabled(disable_selection_buttons) @dataclass diff --git a/src/menu_bar.py b/src/menu_bar.py index 37dea594..9ee889ac 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -270,10 +270,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%);") - # TODO: Check if this is still necessary now that fusion theme is used on both - # if sys.platform == "linux": - # geometry = self.readme_link_button.geometry() - # self.readme_link_button.setGeometry(QtCore.QRect(51, 225, geometry.width(), geometry.height())) + 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 da6880f0..217bf8ed 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -33,6 +33,7 @@ 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") @@ -68,6 +69,14 @@ ) +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.""" @@ -113,7 +122,7 @@ def select_region(autosplit: "AutoSplit"): QTest.qWait(1) del selector - window = getTopWindowAt(x, y) + window = get_top_window_at(x, y) if not window: error_messages.region() return @@ -133,9 +142,7 @@ def select_region(autosplit: "AutoSplit"): offset_x = window_x + left_bounds offset_y = window_y + top_bounds else: - xdisplay = Display() - root = xdisplay.screen().root - data = root.translate_coords(autosplit.hwnd, 0, 0)._data # noqa: SLF001 + data = window._xWin.translate_coords(autosplit.hwnd, 0, 0)._data # pyright:ignore[reportPrivateUsage] # noqa: SLF001 offset_x = data["x"] offset_y = data["y"] @@ -162,7 +169,7 @@ def select_window(autosplit: "AutoSplit"): QTest.qWait(1) del selector - window = getTopWindowAt(x, y) + window = get_top_window_at(x, y) if not window: error_messages.region() return @@ -351,7 +358,7 @@ def __init__(self): data["width"], data["height"], ) - self.setWindowTitle(" ") + self.setWindowTitle(type(self).__name__) self.setWindowOpacity(0.5) self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) self.show() diff --git a/src/utils.py b/src/utils.py index ea7425e9..2b84ba03 100644 --- a/src/utils.py +++ b/src/utils.py @@ -22,21 +22,27 @@ 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] T = TypeVar("T") +DWMWA_EXTENDED_FRAME_BOUNDS = 9 +MAXBYTE = 255 ONE_SECOND = 1000 """1000 milliseconds in 1 second""" QTIMER_FPS_LIMIT = 1000 """QTimers are accurate to the millisecond""" -DWMWA_EXTENDED_FRAME_BOUNDS = 9 -MAXBYTE = 255 BGR_CHANNEL_COUNT = 3 """How many channels in a BGR image""" BGRA_CHANNEL_COUNT = 4 @@ -213,7 +219,6 @@ def flatten(nested_iterable: Iterable[Iterable[T]]) -> chain[T]: """Running from build made by PyInstaller""" auto_split_directory = os.path.dirname(sys.executable if FROZEN else os.path.abspath(__file__)) """The directory of either the AutoSplit executable or AutoSplit.py""" -IS_WAYLAND = bool(os.environ.get("WAYLAND_DISPLAY", False)) # Shared strings # Check `excludeBuildNumber` during workflow dispatch build generate a clean version number From 00ecc590ea2b9412a8e7ace53f5ac1fd33bb8153 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 00:23:34 -0500 Subject: [PATCH 5/5] Update src/hotkeys.py --- src/hotkeys.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hotkeys.py b/src/hotkeys.py index 6c1676fc..e803e4db 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -13,7 +13,6 @@ import grp import os - # https://github.com/PyCQA/pylint/issues/7240 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()