Compare commits

..
Author SHA1 Message Date
Boria138 d1018019d2 feat: added exe network block
Code check / Check code (pull_request) Successful in 1m44s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-01 10:07:13 +05:00
273 changed files with 12252 additions and 96910 deletions

No files matched your search

@@ -7,7 +7,7 @@ on:
- main
- scripts
paths:
- 'build-aux/src/vk_gpu_info.c'
- 'build-aux/vk_gpu_info.c'
- '.gitea/workflows/build-vk-gpu-info-debian.yml'
jobs:
@@ -34,16 +34,17 @@ jobs:
- name: Compile vk_gpu_info
run: |
mkdir -p build-aux/bin
gcc -o build-aux/bin/vk_gpu_info build-aux/src/vk_gpu_info.c -lvulkan
cd build-aux
gcc -o vk_gpu_info vk_gpu_info.c -lvulkan
- name: Test vk_gpu_info execution
run: |
cd build-aux
# Check if the executable was created
if [ -f "build-aux/bin/vk_gpu_info" ]; then
if [ -f "./vk_gpu_info" ]; then
echo "vk_gpu_info executable created successfully"
# Try to run it (may fail if no Vulkan devices present, but that's OK for build test)
timeout 10s build-aux/bin/vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
timeout 10s ./vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
else
echo "ERROR: vk_gpu_info executable was not created"
exit 1
@@ -53,4 +54,4 @@ jobs:
uses: https://git.linux-gaming.ru/actions/gitea-upload-artifact@v4
with:
name: vk_gpu_info-debian11-binary
path: build-aux/bin/vk_gpu_info
path: build-aux/vk_gpu_info
+10 -31
View File
@@ -17,7 +17,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 1.5.0
VERSION: 1.3.1
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -32,10 +32,10 @@ jobs:
options: --privileged --device /dev/fuse
steps:
- name: Prepare container
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
- name: Install CI dependencies
run: pacman -S --noconfirm --needed nodejs npm
run: |
pacman-key --init
pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
- uses: https://git.linux-gaming.ru/actions/checkout@v4
@@ -119,7 +119,7 @@ jobs:
strategy:
matrix:
fedora_version: [43, 44, 45, rawhide]
fedora_version: [42, 43, 44, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
@@ -128,16 +128,9 @@ jobs:
steps:
- name: Install build dependencies
run: |
# Select nearby mirrors and abandon slow downloads after 20 seconds.
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
--setopt=minrate=100k --setopt=timeout=20 \
upgrade -y --exclude=openh264,mozilla-openh264
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
--setopt=minrate=100k --setopt=timeout=20 \
install -y --disablerepo=fedora-cisco-openh264 \
git rpmdevtools meson ninja-build python3-devel \
dnf install -y git rpmdevtools meson ninja-build python3-devel \
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
gcc gettext 'pkgconfig(sdl3)'
gcc gettext
- name: Setup rpmbuild environment
run: |
@@ -177,23 +170,9 @@ jobs:
steps:
- name: Install build dependencies
run: |
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
/etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y --no-install-recommends nala
if timeout --kill-after=5s 120s nala fetch --auto --fetches=3 &&
test -s /etc/apt/sources.list.d/fetch.sources; then
# Keep updates and security; use only ranked mirrors for the base suite.
sed -i 's/^Suites: trixie trixie-updates$/Suites: trixie-updates/' \
/etc/apt/sources.list.d/debian.sources
else
echo "::warning::Mirror selection failed; keeping the bootstrap Debian sources"
fi
nala update
nala install -y build-essential debhelper dpkg-dev gettext git \
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
apt-get install -y build-essential debhelper dpkg-dev gettext git \
libvulkan-dev meson ninja-build nodejs npm \
pkgconf python3
- name: Checkout
+4 -20
View File
@@ -22,31 +22,15 @@ jobs:
name: Check code
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
steps:
- name: Rank Arch mirrors
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Install system dependencies
run: |
pacman -Syu --noconfirm --needed git nodejs npm mesa libarchive libpulse sdl3 pyside6 qt6-svg qt6-imageformats uv
pacman -Syuu --noconfirm --disable-download-timeout
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
pacman -Scc --noconfirm
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Sync dependencies into venv
run: uv sync --all-extras --dev
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
stats:
runs-on: ubuntu-latest
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Set up Python
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
uses: https://git.linux-gaming.ru/actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version-file: "pyproject.toml"
+22 -77
View File
@@ -26,7 +26,7 @@ jobs:
arch: ${{ steps.check.outputs.arch }}
debian: ${{ steps.check.outputs.debian }}
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'pull_request'
with:
fetch-depth: 0
@@ -80,36 +80,19 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
options: --privileged --device /dev/fuse
steps:
- name: Rank Arch mirrors
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@b2d1ce4216f71162e00ae58fa11ee9aeb723d0d5 # v5
run: |
pacman-key --init
pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
- name: Install CI dependencies
run: pacman -S --noconfirm --needed nodejs npm
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'pull_request'
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ env.BUILD_BRANCH }}
@@ -143,7 +126,7 @@ jobs:
strategy:
matrix:
fedora_version: [43, 44, 45, rawhide]
fedora_version: [42, 43, 44, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
@@ -152,16 +135,9 @@ jobs:
steps:
- name: Install build dependencies
run: |
# Select nearby mirrors and abandon slow downloads after 20 seconds.
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
--setopt=minrate=100k --setopt=timeout=20 \
upgrade -y --exclude=openh264,mozilla-openh264
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
--setopt=minrate=100k --setopt=timeout=20 \
install -y --disablerepo=fedora-cisco-openh264 \
git rpmdevtools meson ninja-build python3-devel \
dnf install -y git rpmdevtools meson ninja-build python3-devel \
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
gcc gettext 'pkgconfig(sdl3)'
gcc gettext
- name: Setup rpmbuild environment
run: |
@@ -171,11 +147,11 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout PR
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'pull_request'
- name: Checkout branch
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ env.BUILD_BRANCH }}
@@ -207,29 +183,12 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
steps:
- name: Rank Arch mirrors
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
run: |
pacman -Syu --noconfirm --needed git wget gnupg nodejs npm
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
yes | pacman -Scc
@@ -252,11 +211,11 @@ jobs:
chown user -R ..
- name: Checkout PR
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'pull_request'
- name: Checkout branch
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ env.BUILD_BRANCH }}
@@ -290,37 +249,23 @@ jobs:
needs: changes
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
container:
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
image: debian:trixie-20260623@sha256:d07d1b51c39f51188e60be9b64e6bf769fa94e187f092bc32b91305cfa34ba5a
options: --privileged
steps:
- name: Install build dependencies
run: |
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
/etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y --no-install-recommends nala
if timeout --kill-after=5s 120s nala fetch --auto --fetches=3 &&
test -s /etc/apt/sources.list.d/fetch.sources; then
# Keep updates and security; use only ranked mirrors for the base suite.
sed -i 's/^Suites: trixie trixie-updates$/Suites: trixie-updates/' \
/etc/apt/sources.list.d/debian.sources
else
echo "::warning::Mirror selection failed; keeping the bootstrap Debian sources"
fi
nala update
nala install -y build-essential debhelper dpkg-dev gettext git \
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
apt-get install -y build-essential debhelper dpkg-dev gettext git \
libvulkan-dev meson ninja-build nodejs npm \
pkgconf python3
- name: Checkout PR
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'pull_request'
- name: Checkout branch
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ env.BUILD_BRANCH }}
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Set up Node.js
uses: https://git.linux-gaming.ru/actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
uses: https://git.linux-gaming.ru/actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 20
-2
View File
@@ -43,5 +43,3 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
# AI
.codex
CODE_QUALITY_PLAN.md
.repowise
+1 -1
View File
@@ -1 +1 @@
3.11
3.10
+27 -168
View File
@@ -1,7 +1,7 @@
# PortProtonQt — AI Agent Guidelines
**Project:** PortProtonQt — Game library manager and launcher for Linux
**Language:** Python 3.11+
**Project:** PortProtonQt — GUI for PortProton, Steam, Epic Games Store
**Language:** Python 3.10+
**Platform:** Linux (POSIX)
**License:** GPL-3.0
**Build:** Meson + uv
@@ -78,31 +78,6 @@ Example:
---
## Commit Classification
Use Conventional Commit prefixes according to the primary purpose of the change:
| Prefix | Use for |
|--------|---------|
| `feat` | A new user-facing capability or supported workflow |
| `fix` | A correction for a confirmed defect or regression in existing behavior |
| `chore` | Maintenance, integration adjustments, filtering, metadata, or routine upkeep that is neither a feature nor a confirmed bug fix |
| `refactor` | Internal restructuring with no intended behavior change |
| `perf` | A measurable performance improvement |
| `test` | Test-only changes |
| `docs` | Documentation-only changes |
| `build` | Build system or dependency changes |
| `ci` | CI or automation configuration changes |
| `style` | Formatting-only changes with no behavior change |
| `revert` | Reverting an earlier commit |
- Classify by intent, not merely by whether code behavior changed.
- Do not default to `fix` for every behavioral correction or edge-case filter.
- Use `fix` only when the task identifies a bug/regression or the defect is clearly demonstrated.
- If `feat`, `fix`, and `chore` are ambiguous, prefer `chore` unless the task clearly establishes a new capability or confirmed defect.
---
## Forbidden Patterns
```python
@@ -275,13 +250,11 @@ AI agents MAY:
- No blocking I/O in UI thread
- No synchronous HTTP in UI thread
- Cache API responses in `$XDG_DATA_HOME/PortProtonQt/cache` (default: `~/.local/share/PortProtonQt/cache`)
- Cache API responses in `~/.cache/PortProtonQt`
- Avoid repeated disk reads in loops
- Avoid O(n²) in game lists
- Lazy load images
- Limit threads to CPU cores
- Skip worker creation for valid cached downloads and URLs already marked failed; still complete callbacks exactly once through the owner's event loop
- Reuse cached download paths only while the file exists and matches the requested destination
### Clarification: "No Shared Mutable Global State" Does NOT Mean Removing Caches
@@ -290,10 +263,10 @@ Rule intent: avoid shared mutable globals that hold application state or cause h
This does not prohibit:
- Module-level cache constants (e.g., cache TTL values)
- Dedicated cache managers or classes with explicit lifecycle
- Disk caches under `$XDG_DATA_HOME/PortProtonQt/cache` (default: `~/.local/share/PortProtonQt/cache`)
- Disk caches under `~/.cache/PortProtonQt`
- `requests.Session()` objects if they are encapsulated and not mutated unpredictably
Do not delete or disable caching or downloaders to satisfy this rule. Caching is required by the Performance Rules ("Cache API responses in `$XDG_DATA_HOME/PortProtonQt/cache`" and "Avoid repeated disk reads in loops").
Do not delete or disable caching or downloaders to satisfy this rule. Caching is required by the Performance Rules ("Cache API responses in `~/.cache/PortProtonQt`" and "Avoid repeated disk reads in loops").
---
@@ -304,9 +277,6 @@ Do not delete or disable caching or downloaders to satisfy this rule. Caching is
- No heavy operations in paintEvent
- Use methods instead of lambda for complex logic
- Use QThread for long tasks
- Retain every background worker that can outlive its caller in an owner field or collection until it finishes
- Register window-owned workers and retained worker collections in the application shutdown path; cancel when supported and wait before interpreter exit
- Add or update shutdown regression tests when adding or changing a background worker, including executor-backed workers
---
@@ -334,7 +304,7 @@ Do not assume conventions. Never invent behavior.
```bash
# Setup
uv python install 3.11
uv python install 3.10
uv sync
source .venv/bin/activate
pre-commit install --install-hooks
@@ -414,24 +384,16 @@ tests/
├── test_appimage_updater.py # AppImage auto-update worker, mirrors, changelog parsing
├── test_dark_theme.py # Dark theme detection (XFCE/MATE/Cinnamon/GNOME)
├── test_steam_cache.py # exiftool skip, cache eviction, delete_cached_app_files
├── test_downloader.py # Cache/error callbacks, worker ownership, and shutdown regressions
├── test_base_config.py # BaseConfig read/write, caching, versioning
├── test_cli.py # normalize_launch_path, URL/resolution parsing
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
├── test_input_manager.py # Gamepad input navigation and focus regressions
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
├── test_boot_animation.py # Theme video inheritance, fullscreen boot rendering, gamepad skip, loading transitions, and shutdown
├── test_main_window.py # Main window data processing and callback regressions
├── test_library_resize.py # Library resize batching, cover reuse, and flow layout regressions
├── test_library_loading.py # Incremental loading, source switching, first-paint focus, and cover callbacks
├── test_store_games.py # GOG and EGS library, launch, install, and stop regressions
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
├── test_portproton_scripts.py # Shell script launch, second exe, exit codes, user.conf
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
├── test_file_explorer.py # File explorer themed icon state regressions
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
@@ -439,9 +401,7 @@ tests/
├── test_theme_store.py # Theme store UI worker lifecycle and race regressions
├── test_theme_manager.py # Theme AST injection, parent resolution, ThemeWrapper, style integrity
├── test_theme_security.py # Theme security checker (allowlist AST, forbidden modules/methods, SVG/font/image safety)
├── test_settings_search.py # MangoHud, vkBasalt, Gamescope settings search and layout regressions
├── test_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
└── test_proton_manager.py # Local Wine/Proton drag-and-drop and safe archive extraction
└── test_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
```
### Running Tests
@@ -466,18 +426,9 @@ pre-commit run pytest
- Tests MUST mock external resources (network, system paths, env vars)
- Tests MUST NOT depend on system state (installed apps, real Steam dirs)
- Use `monkeypatch` for environment variables and function patching
- Keep tests locale-independent: assert stable identifiers or numbers instead of translated strings; resolve values created through `_()` from the same application mapping using stable identifiers; use `monkeypatch` to provide deterministic text when testing translated search or matching
- Keep tests locale-independent (assert numbers, not translated strings)
- Test regression scenarios from git fix commits
### Library Refresh Regressions
- Late metadata/download completion must preserve scroll position and focused card in an already populated library; first-card focus belongs to initial loading
- Cover both library layouts and refresh completion while the autoinstall tab is active
- For reported scrolling regressions, supplement unit tests with a full application cycle: isolated empty cache, forced ProtonDB connection failure, scroll and refresh both tabs, switch back, then close the application
- Record scroll values and visible cards around late updates; distinguish an intentional refresh reset from an unsolicited rollback after the user resumes scrolling
- Use temporary XDG config/data directories for GUI reproduction; never clear the user's real cache or alter system-wide network settings
- Report baseline reproduction and final-code results separately; passing callback tests alone does not prove the visible regression is fixed
### Key Regression Areas
| Module | Regression | Commit |
@@ -491,7 +442,6 @@ pre-commit run pytest
| `classic-light/styles.py` | NameError: border_none not defined (no styles/constants.py) | 519edd1 |
| `detail_pages` | DETAIL_PAGE_GRADIENT stops override, wave background modes | — |
| `detail_pages` | Edit/Add shortcut buttons shown together for non-Steam games | 7e38d9a |
| `wine_extractor` | Archive traversal and process-wide cwd changes during extraction | — |
---
@@ -513,7 +463,6 @@ pre-commit run pytest
- [ ] Prefer dedicated functions for subprocess calls when practical within task scope
- [ ] No shared mutable global state (except logger and explicit cache/session infrastructure)
- [ ] No blocking calls introduced in the UI thread
- [ ] Background workers have explicit ownership and shutdown cleanup with regression coverage
- [ ] **No hardcoded styles or constants (use theme constants)**
- [ ] **New constants added to theme files only**
@@ -925,113 +874,21 @@ install_data(
```
PortProtonQt/
├── portprotonqt/ # Python package
│ ├── animations/ # UI animation modules
│ │ ├── detail_background.py
│ │ ├── detail_page.py
│ │ ├── game_card.py
│ │ ├── library_controls.py
│ │ └── virtual_keyboard.py
│ ├── config/ # Application configuration
│ │ ├── base.py
│ │ ├── cache.py
│ │ ├── display.py
│ │ ├── favorites.py
│ │ ├── game.py
│ │ ├── gamepad.py
│ │ ├── portproton.py
│ │ ├── proxy.py
│ │ ├── ui.py
│ │ ├── validators.py
│ │ └── window.py
│ ├── debug_utils/ # Debug data collection and processing
│ │ ├── debug_log_manager.py
│ │ ├── env_utils.py
│ │ ├── game_debug.py
│ │ ├── gpu_info.py
│ │ ├── log_processor.py
│ │ ├── system_info.py
│ │ └── xorg_utils.py
│ ├── detail_pages/ # Game detail page components
│ │ ├── utils.py
│ │ └── widgets.py
│ ├── dialogs/ # Application dialogs
│ │ ├── appimage_update.py
│ │ ├── base.py
│ │ ├── compatibility_report.py
│ │ ├── dialog_utils.py
│ │ ├── file_explorer.py
│ │ ├── prefix_backup.py
│ │ ├── proton_manager.py
│ │ ├── settings_dialog.py
│ │ ├── settings_gamescope.py
│ │ ├── settings_mangohud.py
│ │ ├── settings_vkbasalt.py
│ │ ├── wine_downloader.py
│ │ ├── wine_extractor.py
│ │ ├── wine_loader.py
│ │ └── winetricks_dialog.py
│ ├── input_manager/ # Keyboard and gamepad input routing
│ │ ├── buttons.py
│ │ ├── constants.py
│ │ ├── dialog_modes.py
│ │ ├── dpad.py
│ │ ├── file_explorer.py
│ │ ├── keyboard.py
│ │ ├── mixin.py
│ │ ├── runtime.py
│ │ ├── settings.py
│ │ └── settings_visual.py
│ ├── scripts_utils/ # Helpers used by PortProton scripts
│ │ ├── dbus_tools.py
│ │ ├── easyterm.py
│ │ ├── graphics_detector.py
│ │ ├── idle_inhibit.py
│ │ ├── json_tools.py
│ │ ├── mount_points.py
│ │ ├── power_profiles.py
│ │ ├── prefix_backup.py
│ │ └── shortcut_tools.py
│ ├── steam_api/ # Steam API, cache, and shortcuts
│ │ ├── api.py
│ │ ├── cache.py
│ │ ├── shortcuts.py
│ │ └── utils.py
│ ├── system_manager/ # System settings backends
│ │ ├── audio.py
│ │ ├── bluetooth.py
│ │ ├── common.py
│ │ ├── network.py
│ │ └── storage.py
│ ├── tabs/ # Main window tabs and workers
│ │ ├── autoinstall_tab.py
│ │ ├── control_hints.py
│ │ ├── download_tab.py
│ │ ├── library_tab.py
│ │ ├── settings_tab.py
│ │ ├── system_tab.py
│ │ ├── theme_store.py
│ │ ├── theme_store_workers.py
│ │ ├── theme_tab.py
│ │ ├── wine_tab.py
│ │ └── workers.py
│ ├── locales/ # Translation catalogs
│ ├── terminal_schemes/ # Terminal color schemes
│ ├── themes/ # Built-in themes and assets
│ ├── app.py # Entry point
│ ├── game_card.py # Game card widget
│ ├── gog_api.py # GOG integration
│ ├── main_window.py # Main window
│ ├── native_gamepad.py # Native SDL3 gamepad backend
│ ├── portproton_api.py # PortProton integration
│ ├── theme_manager.py # Theme management
│ └── logger.py # Logging
├── tests/ # Unit tests
├── build-aux/ # Build resources
├── dev-scripts/ # Development scripts
├── documentation/ # Documentation
├── meson.build # Meson config
├── pyproject.toml # Python config
├── portprotonqt/ # Python package
│ ├── app.py # Entry point
│ ├── main_window.py # Main window
│ ├── game_card.py # Game card widget
│ ├── steam_api.py # Steam integration
│ ├── egs_api.py # Epic Games integration
│ ├── theme_manager.py # Theme management
│ ├── logger.py # Logging
│ └── themes/ # Theme files
├── tests/ # Unit tests
├── build-aux/ # Build resources
├── dev-scripts/ # Development scripts
├── documentation/ # Documentation
├── meson.build # Meson config
├── pyproject.toml # Python config
└── .pre-commit-config.yaml
```
@@ -1083,10 +940,12 @@ PortProtonQt/
- [Theme documentation](documentation/theme_guide)
- [Localization guide](documentation/localization_guide)
- [Metadata override guide](documentation/metadata_override)
- [TODO list](TODO.md)
- [Changelog](CHANGELOG.md)
---
**Last updated:** 2026-08-11
**Version:** 1.3
**Last updated:** 2026-07-20
**Version:** 1.2
**Status:** Release
-168
View File
@@ -5,168 +5,21 @@
## [Unreleased]
### Added
### Changed
### Fixed
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
### Contributors
---
## [1.5.0] - 2026-10-14
### Added
- Добавлен поиск настроек игры вводом с клавиатуры без предварительного выбора поля поиска
- Добавлена навигация геймпадом к кнопкам под таблицами
- Добавлена настройка скрытия курсора в полноэкранном режиме, включённая по умолчанию
- Добавлена настройка отключения поиска установленных Proton в Steam для списков выбора версий и окна управления WINE/Proton
- Добавлена возможность добавления загрузочной WebM-заставки из тем при переходе в fullscreen, включая автоматический переход при подключении геймпада
- Добавлен переход к загрузке Wine/Proton из списка версий в настройках игры
- Добавлены выбор и установка DLC при установке игр GOG
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
- Добавлена настройка отключения проверки i586-зависимостей в ALT Linux
- Добавлена настройка принудительного применения английского языка
- Добавлено редактирование названия и обложки игр GOG по ID
- Добавлена интеграция Epic Games Store с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
- Добавлена поддержка сборок Proton CachyOS WineLand
- Добавлена кнопка возврата на рабочий стол в системной вкладке при указании команды в `PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT`
- Добавлены режимы библиотеки `horizontal` и `horizontal_top` с циклической навигацией и плиткой открытия полной сетки в топ-режиме
- В панель библиотеки добавлен выбор типа сетки
- В виджет с исчезающей полосой прокрутки добавлена поддержка горизонтальной ориентации
- Добавлен режим библиотеки `vertical` с табличным отображением названия, последнего запуска, проведённого времени и источника игры
- При открытии карточки с отсутствующим исполняемым файлом добавлен выбор: удалить карточку или указать другой файл
- Добавлен отдельный раздел настройки шрифтов MangoHud с масштабом, размером и выбором шрифта
- Добавлен графический редактор глобальных настроек игр из `user.conf`
- В режиме `PW_USE_SUPPLIED_DXVK_VKD3D` добавлена поддержка D7VK из сборки Proton
### Changed
- Настройка `WINE_FULLSCREEN_FSR` по умолчанию отключена
- Кнопки менеджера Wine/Proton приведены к общему стилю приложения и дополнены иконками
- Списки DLL и шрифтов в Winetricks теперь сортируются по алфавиту
- Оформление Winetricks приведено к общему стилю приложения
- В поле запуска второго `.exe` / враппера теперь можно указать тот же исполняемый файл, что и основной
- Описания приложения в README, метаданных и пакетах сделаны нейтральными, без перечисления источников игр и привязки к Windows
- Ускорено изменение размера карточек библиотеки
- Удалён GDK Proton из списка доступных сборок
- Улучшено отображение описаний параметров в настройках игры: они размещаются под названием, а текущая строка выделяется рамкой
- После завершения загрузки показывается системное уведомление, а строка результата открывает карточку игры мышью или кнопкой A геймпада
- Улучшено определение вылетов игр: учитывается код завершения команды Wine/Proton и время работы игровых процессов, включая дочерний движок, без учёта времени завершения скрипта запуска
- Каталоги пользовательских данных игр переведены на единый формат `источник-ID`
- Данные приложения организованы в `$XDG_DATA_HOME/PortProtonQt` (по умолчанию `~/.local/share/PortProtonQt`): кеш перенесён из `.cache` в `cache`, данные лаунчеров — в `launcher`, загружаемые бинарники — в `bin`, чтобы временный кеш AnyLinux не вызывал повторную загрузку при каждом запуске AppImage
- Минимальная поддерживаемая версия Python повышена с 3.10 до 3.11
- Поиск в библиотеке теперь находит совпадения в любой части названия и показывает одноимённые игры из разных источников
- Тема теперь применяется без перезапуска приложения
- Добавлена сборка Fedora 45
- Автоматическая загрузка PPDB с linux-gaming.ru теперь отключена по умолчанию
- Кнопка «Открыть PPDB» переименована в «Редактировать PPDB», чтобы не путать открытие файла с его запуском
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
- Обновлён перевод на Испанский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/`
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
- Открытие карточек и тихий запуск унифицированы
- `minazuki` добавлен в список конфликтующих с GameMode процессов
- Встроенный терминал после ошибки остаётся открытым и закрывается клавишей Enter или кнопкой A геймпада
- В список советов при вылете добавлено отключение Optiscaler
- Повышен статус разработки в `pyproject.toml` с Alpha до Production
- DXVK и VKD3D-Proton теперь загружаются только для выбранного режима Vulkan, а D7VK — только при его включении
### Fixed
- Исправлена запись `MANGOHUD_CONFIG` в `.ppdb` при выключенном MangoHud
- Исправлены ложный статус установки и открытие чужой карточки для Rockstar Games Launcher при совпадении имени `launcher.exe`
- Исправлен запуск второго `.exe` через `PW_RUN_AFTER_EXE` из пути с кириллицей
- Исправлены мерцание сетки и задержка выделения карточек при открытии библиотеки
- Исправлено обновление сетки комбинацией Guide + Select
- Исправлены создание и восстановление `.desktop`-файлов с переносами строк в названии игры
- Исправлено появление дубля карточки при добавлении и после установки игры
- Исправлено отсутствие новых `.exe` в поиске библиотеки до обновления сетки
- Исправлено обновление названия на детальной странице после редактирования ярлыка без повторного открытия страницы
- Исправлено отображение дополнений и наборов GOG как отдельных игр в библиотеке
- Исправлено отсутствие установленных демоверсий, модов, приложений и бета-версий Steam в библиотеке
- Исправлена синтаксическая ошибка в `functions_helper`, препятствовавшая запуску PortProton с Bash 5.1
- Исправлено управление интерфейсом при одновременном подключении нескольких геймпадов
- Исправлена коллизия пользовательских данных автоустановок с одинаковыми именами исходных исполняемых файлов
- Исправлен запуск PortProtonQt на NixOS через `steam-run`
- Исправлена проверка и установка недостающих i586-зависимостей в ALT Linux перед запуском игр
- Исправлено отсутствие иконки у окна встроенного терминала
- Исправлена потеря финального вывода при ошибке встроенного терминала
- Исправлено открытие карточек GOG через URI при старте приложения
- Исправлен сброс состояния кнопки записи лога при переключении детальной страницы в компактный режим
- Исправлено отображение значка PortProtonQt в системном трее при медленной инициализации приложения
- Исправлено отсутствие вкладок Wi-Fi и Bluetooth при запуске до инициализации системных адаптеров
- Исправлена ошибка, из-за которой не работал поиск по настройкам при отсутствии доступных параметров MangoHud или Gamescope
- Фильтр установленных игр теперь переключается без повторной загрузки библиотеки
- Некорректные изображения в кэше Steam теперь пропускаются при поиске обложек
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
- Исправлено создание резервной копии префикса с `libarchive-c 5.1`
- Исправлено завершение приложения при активных фоновых операциях
- Исправлено автоматическое включение NVAPI, DLSS и трассировки лучей для видеокарт NVIDIA RTX
- Исправлено применение принудительно выбранного языка в конфигурациях эмуляторов Steam
### Contributors
- @dark_siders
- @rogerio-mattos
- @Rubin13
- @adem4ik
- @gallegonovato
---
## [1.4.1] - 2026-08-26
### Changed
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
### Fixed
- Произведён откат Steam Runtime Sniper до версии V3 из-за несовместимости GStreamer внутри Proton 10.x с SLR 4
### Contributors
---
## [1.4.0] - 2026-08-23
### Added
- Добавлен анализатор проблем в случае вылета Windows-программы
- Добавлены анимированные и статические фоны детальной страницы `aurora`, `diagnostics`, `metaballs`, `leaf` и `veins`, вдохновлённые проектом omikuji
- Добавлена интеграция GOG с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
- Добавлена отдельная галочка `Only Installed`, сочетаемая с фильтрами источника и скрывающая неустановленные игры
- Добавлено отображение иконок `.exe` вместо обложек на компактной детальной странице
- Добавлено редактирование ярлыков Steam по AppID
- Добавлены тихий и логируемый режимы запуска `.exe` через меню «Открыть с помощью»
- Добавлена пользовательская интеграция AppImage
- Добавлен однокнопочный установщик AppImage с загрузкой через Gitea или GitHub и автоматической интеграцией в систему
- В игровые ярлыки добавлены действия для запуска в тихом режиме и с записью лога
- Добавлен аргумент командной строки `--log` для запуска игры в режиме логирования
- Добавлены звуки интерфейса
- Добавлен выбор аккаунта Steam в настройках с автоматическим режимом по умолчанию и режимом `All` для добавления ярлыков всем пользователям
- Добавлено безопасное чтение вариантов dark/light палитры matugen из Dank Material Shell
- Добавлен поиск параметров MangoHud, vkBasalt и Gamescope по названиям и описаниям
- Добавлена установка локальных архивов WINE/Proton перетаскиванием в библиотеку или менеджер версий
- Добавлена кнопка открытия папки WINE в менеджере версий
- Добавлена настройка для скрытия подсказок по управлению
- Добавлена кнопка удаления пользовательских тем
### Changed
- GitHub-зеркала [PortProtonQt](https://github.com/linux-gaming-ru/PortProtonQt) и [метаданных WINE/Proton](https://github.com/linux-gaming-ru/PortProtonQt-Wine-Metadata) перенесены в организацию `linux-gaming-ru`
- Иконки системных уведомлений перенесены в темы и теперь соответствуют выбранной теме
- Выбранное зеркало теперь используется для загрузки метаданных WINE/Proton, проверки обновлений AppImage и получения списка изменений
- Обновлена ссылка на раздел помощи в логе ошибки
- В AppImage добавлены `7z` для извлечения содержимого ISO и MDF-образов и `vk_gpu_info` для определения видеокарт Vulkan
- Вертикальная навигация геймпадом в сетках настроек больше не переходит между столбцами
- Удалена сборка для Fedora 42
- Версия Windows по умолчанию изменена с 10 на 11
- Обновлены все иконки подсказок управления
- API и ссылки PPDB переведены с `ppdb.linux-gaming.ru` на `linux-gaming.ru`
- Блок игровой статистики на полной детальной странице помещён в тематизируемый контейнер, как в компактном режиме
- Кэш EXE-иконок унифицирован для карточек и детальных страниц
@@ -180,18 +33,6 @@
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
### Fixed
- Настройки прокси теперь передаются `curl` при загрузке файлов скриптами PortProton
- Устранена повторная перестройка индексов Steam и PPDB для каждой карточки в экономном режиме
- Скрытие бейджей больше не скрывает ленту источника игры на карточках библиотеки
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
- Исправлено обрезание подсказок управления при переносе на вторую строку
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
@@ -202,23 +43,14 @@
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
- Исправлено зависание интерфейса после завершения игры
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
- Распаковка WINE/Proton больше не меняет рабочий каталог приложения и отклоняет пути из архива, выходящие за каталог установки
### Contributors
- @Dervart
- @Rubin13
- @rogerio-mattos
- @Vekt0R_null
---
## [1.3.2] - 2026-08-10
### Fixed
- Исправлено определение SteamID по `AutoLogin` или единственному профилю `userdata`, если в `loginusers.vdf` отсутствует `MostRecent`
---
## [1.3.1] - 2026-07-18
### Fixed
-12
View File
@@ -1,12 +0,0 @@
[Desktop Entry]
Name=Install PortProtonQt
Name[ru]=Установить PortProtonQt
Name[es]=Instalar PortProtonQt
Comment=Download and install the latest PortProtonQt AppImage
Comment[ru]=Скачать и установить последнюю версию PortProtonQt AppImage
Exec=sh -c "(curl -fsSL https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/install.sh || curl -fsSL https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/main/install.sh) | sh"
Icon=system-software-install
StartupNotify=false
Terminal=true
Type=Application
Categories=Game;
+436
View File
@@ -15,3 +15,439 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
We make use of various projects, some of which require their license to be preserved. Here's their respective licenses:
-----------------------------------------------------------------------------------------------------------------------
===============
= icoextract: =
===============
MIT License
Copyright (c) 2015-2016 Fadhil Mandaga
Copyright (c) 2019-2025 James Lu <james@overdrivenetworks.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===============
= portproton: =
===============
MIT License
Copyright (c) 2025 Mikhail Tergoev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===============================
= HowLongToBeat-Python-API : =
===============================
MIT License
Copyright (c) 2020 JaeguKim
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
===============================
= mdf2iso : =
===============================
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.
=====================================================================
===========================
= Kenney Interface Sounds =
===========================
Interface Sounds (1.0)
Created/distributed by Kenney (www.kenney.nl)
License: Creative Commons Zero, CC0
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
+9 -23
View File
@@ -3,27 +3,16 @@
---
<div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
<p align="center">An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.</p>
<p>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&amp;label=Release" alt="Releases"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-14.1k-green?style=flat-square" alt="Total Downloads"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&amp;logo=python" alt="Python 3.11+"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt"><img src="https://img.shields.io/badge/Platform-Linux-blue?style=flat-square&amp;logo=linux" alt="Platform: Linux"></a>
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Translations-Weblate-2eccaa?style=flat-square&amp;logo=weblate" alt="Translate on Weblate"></a>
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg" alt="Packaging status on Repology"></a>
</p>
<p><a href="https://github.com/linux-gaming-ru/PortProtonQt/releases">Releases</a> · <a href="https://github.com/linux-gaming-ru/PortProtonQt/issues">Issue tracker</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Translations</a> · <a href="https://repology.org/project/portprotonqt/versions">Packages</a></p>
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-5471-green?style=flat-square" alt="Total Downloads"></a>
<p align="center">A modern and convenient interface for managing and launching games from PortProton and Steam. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
</div>
### Installation (devel)
```sh
uv python install 3.11
uv python install 3.10
uv sync
source .venv/bin/activate # For bash/zsh
# or
@@ -40,7 +29,7 @@ Launch using the `portprotonqt` command or via the shortcut in your application
### Development
To automatically prepare the environment (install Python 3.11, dependencies, pre-commit hooks, and generate translations), run the following script:
To automatically prepare the environment (install Python 3.10, dependencies, pre-commit hooks, and generate translations), run the following script:
```sh
./dev-scripts/prepare_env.sh
@@ -68,7 +57,7 @@ pre-commit run --all-files
### Contributors
We thank everyone who has contributed to the development of PortProtonQt, including those who participate through commits as well as those who help in other ways (testing, ideas, translations, documentation, etc.). A full list of participants can be found in the [repository activity list](https://github.com/linux-gaming-ru/PortProtonQt/graphs/contributors). Additional participants are also listed in the [CHANGELOG.md](CHANGELOG.md) file. If you have contributed but are not listed, please contact the lead developers so we can acknowledge you!
We thank everyone who has contributed to the development of PortProtonQt, including those who participate through commits as well as those who help in other ways (testing, ideas, translations, documentation, etc.). A full list of participants can be found in the [repository activity list](https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/activity/contributors). Additional participants are also listed in the [CHANGELOG.md](CHANGELOG.md) file. If you have contributed but are not listed, please contact the lead developers so we can acknowledge you!
## Dependencies and Licenses
@@ -76,16 +65,13 @@ PortProtonQt uses code and dependencies from the following projects:
- [Icoextract](https://github.com/jlu5/icoextract) — icon extraction library, [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE) license.
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — library for interacting with HowLongToBeat, [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md) license.
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — library for converting mdf and nrg to iso, GPLv2 license.
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — GOG downloading module, [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE) license.
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — Epic Games launcher alternative, [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE) license.
- [iat](https://sourceforge.net/projects/iat.berlios) — library for converting mdf and nrg to iso, GPLv3 license.
- [pyte](https://github.com/selectel/pyte) — ANSI escape code parser, [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file) license.
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — base for virtual keyboard layout data, GPLv3 license.
- [omikuji](https://github.com/omikuji-launcher/omikuji) — inspiration and source material for the procedural detail page backgrounds, [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE) license.
- [Bottles](https://github.com/bottlesdevs/Bottles) — source of the compatibility analysis rules, GPL-3.0-only license.
PortProtonQt is licensed under the terms in [LICENSE](LICENSE). See
[THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) for third-party licenses.
See the [LICENSE](LICENSE) file for the full text of the licenses.
> [!WARNING]
> **Be careful!** If you are using a theme not from the official repository or a trusted source, make sure its `styles.py` file does not contain malicious or unwanted code. Since `styles.py` is a regular Python file, it can contain any instructions. Always check the contents of third-party themes before use.
+9 -23
View File
@@ -1,29 +1,18 @@
📘 This documentation is also available in [English](README.md)
📖 This documentation is also available in [English](README.md)
---
<div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
<p align="center">Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.</p>
<p>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&amp;label=Релиз" alt="Релизы"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-14.1k-green?style=flat-square" alt="Всего загрузок"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&amp;logo=python" alt="Python 3.11+"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"><img src="https://img.shields.io/badge/Платформа-Linux-blue?style=flat-square&amp;logo=linux" alt="Платформа: Linux"></a>
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Переводы-Weblate-2eccaa?style=flat-square&amp;logo=weblate" alt="Перевести на Weblate"></a>
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg?header=Репозитории" alt="Пакеты в Repology"></a>
</p>
<p><a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases">Релизы</a> · <a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/issues">Сообщить о проблеме</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Переводы</a> · <a href="https://repology.org/project/portprotonqt/versions">Пакеты</a></p>
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-5471-green?style=flat-square" alt="Всего загрузок"></a>
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
</div>
### Установка (devel)
```sh
uv python install 3.11
uv python install 3.10
uv sync
source .venv/bin/activate # For bash/zsh
# or
@@ -40,7 +29,7 @@ source .venv/bin/activate.fish # For fish
### Разработка
Для автоматической подготовки окружения (установка Python 3.11, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
Для автоматической подготовки окружения (установка Python 3.10, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
```sh
./dev-scripts/prepare_env.sh
@@ -76,16 +65,13 @@ PortProtonQt использует код и зависимости от след
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv2
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — модуль загрузки игр GOG, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE).
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — альтернатива Epic Games Launcher, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE).
- [iat](https://sourceforge.net/projects/iat.berlios) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv3
- [pyte](https://github.com/selectel/pyte) — разбор ANSI escape-кодов, лицензия [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file)
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — основа данных раскладок виртуальной клавиатуры, лицензия GPLv3.
- [omikuji](https://github.com/omikuji-launcher/omikuji) — источник вдохновения и материалов для процедурных фонов детальной страницы, лицензия [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE).
- [Bottles](https://github.com/bottlesdevs/Bottles) — источник правил анализа совместимости, лицензия GPL-3.0-only.
Условия лицензии PortProtonQt приведены в файле [LICENSE](LICENSE). Лицензии
сторонних проектов приведены в файле [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES).
Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING]
> **Будьте осторожны!** Если вы берёте тему не из официального репозитория или надёжного источника, убедитесь, что в её файле `styles.py` нет вредоносного или нежелательного кода. Поскольку `styles.py` — это обычный Python-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.
-42
View File
@@ -1,42 +0,0 @@
# Security Policy
## Reporting a vulnerability
Do not publish suspected vulnerability details in public issues, discussions,
or pull requests. Send reports to [support@linux-gaming.ru](mailto:support@linux-gaming.ru).
Please include enough information to reproduce and assess the issue:
- PortProtonQt version or commit and installation source.
- Linux distribution and relevant launcher or runtime details.
- Affected feature and clear reproduction steps.
- Expected behavior, observed behavior, and potential security impact.
- A minimal proof of concept or sanitized logs, if available.
- A suggested mitigation or fix, if known.
Do not include passwords, tokens, private keys, or personal data. Redact
usernames, home directory paths, and other sensitive details from logs where
practical. Test only with systems, accounts, and files you own or are
authorized to use.
## Scope
This policy covers security issues in PortProtonQt, including its handling of
local files and archives, launcher data, network requests, downloaded content,
and application configuration. Issues in third-party launchers, libraries,
compatibility tools, or distribution packages should also be reported to their
maintainers. Please report cases where PortProtonQt's handling creates or
increases the risk.
## Supported versions
Please check whether the issue affects the latest published release and
include the affected version in your report. Older releases may not receive
security fixes; users should update when a fix becomes available.
## Coordinated disclosure
Keep vulnerability details private while maintainers investigate and prepare a
fix or mitigation. Coordinate public disclosure with the maintainers, allowing
reasonable time for a fix to reach users. Response and remediation times depend
on the issue; no fixed response time is promised.
-1278
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -52,6 +52,7 @@ while [ $# -gt 0 ]; do
done
ARCH="$(uname -m)"
EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/get-debloated-pkgs.sh"
if [ "$LOCAL_MODE" = true ]; then
echo "Using local PKGBUILD-git from repository..."
@@ -103,7 +104,9 @@ makepkg -si --noconfirm
echo "Installing debloated packages..."
echo "---------------------------------------------------------------"
get-debloated-pkgs --add-common --prefer-nano
wget --retry-connrefused --tries=30 "$EXTRA_PACKAGES" -O ./get-debloated-pkgs.sh
chmod +x ./get-debloated-pkgs.sh
./get-debloated-pkgs.sh --add-common --prefer-nano
if [ "$LOCAL_MODE" = true ]; then
# For git version, we use portprotonqt-git
+9 -17
View File
@@ -2,6 +2,7 @@
set -eu
SHARUN="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/quick-sharun.sh"
ARCH="$(uname -m)"
VERSION="$(cat ~/version)"
export ARCH VERSION
@@ -19,12 +20,16 @@ export OPTIMIZE_LAUNCH=1
# Adjust comp settings to bypass oom-killer
export DWARFS_COMP="zstd:level=15 -S22 -B5"
# DEPLOY ALL LIBS
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
chmod +x ./quick-sharun
# Add udev rules
mkdir -p ./AppDir/etc/udev/rules.d
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
# Add PortProton scripts
# Copy manually to avoid tracing 208 .ppdb files with quick-sharun
# Copy manual because im to lazzy for wait ./quick-sharun strace 208 .ppdb
mkdir -p ./AppDir/share
cp -r /usr/share/portproton ./AppDir/share
@@ -34,28 +39,15 @@ pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
# Deploy dependencies
# Qt libs have to be passed manually due to the app being a python script
GAMEPAD_LIBRARY=$(find /usr/lib -type f \
-path '*/site-packages/portprotonqt/libportprotonqt_gamepad.so' \
-print -quit)
test -n "$GAMEPAD_LIBRARY"
quick-sharun \
./quick-sharun \
/usr/bin/portprotonqt* \
/usr/bin/bash \
/usr/bin/update-desktop-database \
/usr/bin/update-mime-database \
/usr/bin/vk_gpu_info \
"$GAMEPAD_LIBRARY" \
/usr/lib/libQt6Core.so* \
/usr/lib/libQt6Gui.so* \
/usr/lib/libQt6Multimedia.so* \
/usr/lib/libQt6Network.so* \
/usr/lib/qt6/plugins/imageformats/libqwebp.so \
/usr/lib/7zip/7z*
# quick-sharun removes MIME package XML files during dependency deployment
mkdir -p ./AppDir/share/mime/packages
cp /usr/share/mime/packages/ru.linux_gaming.PortProtonQt.xml \
./AppDir/share/mime/packages
/usr/lib/qt6/plugins/imageformats/libqwebp.so
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
# build/development content and PySide bindings not imported by PortProtonQt.
@@ -98,4 +90,4 @@ done
rm -rf "$PYSIDE_DIR"/QtAsyncio
# Turn AppDir into AppImage
quick-sharun --make-appimage
./quick-sharun --make-appimage
+16 -16
View File
@@ -1,26 +1,16 @@
# Maintainer: Boris Yumankulov <boriabloger[at]protonmail[dot]com>
pkgname=portprotonqt
pkgver=1.5.0
pkgname=('portprotonqt')
pkgver=1.3.1
pkgrel=1
pkgdesc="Game library manager and launcher for Linux"
arch=('x86_64')
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
arch=('any')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'sdl3')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
conflicts=("portprotonqt-steam-compat")
provides=('portprotonqt-steam-compat')
replaces=('portprotonqt-steam-compat')
optdepends=(
'networkmanager: System tab network management'
'bluez: System tab bluetooth management'
'upower: System tab bluetooth battery level'
'libpulse: System tab audio volume/output management'
'python-qrcode: Wi-Fi QR code generation'
'squashfs-tools: for legacy PortProton prefix backup support'
)
source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP')
@@ -29,7 +19,17 @@ build() {
meson compile -C build
}
package() {
package_portprotonqt() {
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
optdepends=(
'networkmanager: System tab network management'
'bluez: System tab bluetooth management'
'upower: System tab bluetooth battery level'
'libpulse: System tab audio volume/output management'
'python-qrcode: Wi-Fi QR code generation'
'squashfs-tools: for legacy PortProton prefix backup support'
)
DESTDIR="$pkgdir" meson install -C build --skip-subprojects
bash "$srcdir/PortProtonQt/dev-scripts/generate-completions.sh"
+15 -15
View File
@@ -1,26 +1,16 @@
# Maintainer: Boris Yumankulov <boriabloger[at]protonmail[dot]com>
pkgname=portprotonqt-git
pkgname=('portprotonqt-git')
pkgver=.
pkgrel=1
pkgdesc="Game library manager and launcher for Linux (development build)"
arch=('x86_64')
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
arch=('any')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'sdl3')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
conflicts=("portprotonqt-steam-compat")
provides=('portprotonqt-steam-compat')
replaces=('portprotonqt-steam-compat')
optdepends=(
'networkmanager: System tab network management'
'bluez: System tab bluetooth management'
'upower: System tab bluetooth battery level'
'libpulse: System tab audio volume/output management'
'python-qrcode: Wi-Fi QR code generation'
'squashfs-tools: for legacy PortProton prefix backup support'
)
source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git")
sha256sums=('SKIP')
@@ -34,7 +24,17 @@ build() {
meson compile -C build
}
package() {
package_portprotonqt-git() {
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
optdepends=(
'networkmanager: System tab network management'
'bluez: System tab bluetooth management'
'upower: System tab bluetooth battery level'
'libpulse: System tab audio volume/output management'
'python-qrcode: Wi-Fi QR code generation'
'squashfs-tools: for legacy PortProton prefix backup support'
)
DESTDIR="$pkgdir" meson install -C build --skip-subprojects
bash "$srcdir/PortProtonQt/dev-scripts/generate-completions.sh"
+5 -4
View File
@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 1.5.0
%global pypi_version 1.3.1
%global oname PortProtonQt
%global build_timestamp %(date +"%Y%m%d")
%global _python_no_extras_requires 1
@@ -9,7 +9,7 @@
Name: %{pypi_name}-git
Version: %{pypi_version}
Release: %{rel_build}
Summary: Game library manager and launcher for Linux (development build)
Summary: Modern GUI for managing and launching games from PortProton and Steam (development build)
License: GPL-3.0
URL: https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt
@@ -22,7 +22,6 @@ BuildRequires: git
BuildRequires: gettext
BuildRequires: systemd-rpm-macros
BuildRequires: vulkan-loader-devel
BuildRequires: pkgconfig(sdl3)
BuildRequires: gcc
Obsoletes: python3-%{pypi_name}-git < %{version}-%{release}
@@ -43,6 +42,7 @@ Requires: python3-pillow-qt
Requires: python3-rapidfuzz
Requires: python3-libarchive-c
Requires: perl-Image-ExifTool
Requires: SDL3
Requires: qt6-qtsvg
Requires: qt6-qtimageformats
Requires: qt6-qtmultimedia
@@ -81,7 +81,7 @@ Obsoletes: %{pypi_name}-steam-compat < %{?epoch:%{epoch}:}%{version}-%{rele
Provides: %{pypi_name}-steam-compat = %{?epoch:%{epoch}:}%{version}-%{release}
%description
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
%{?python_disable_dependency_generator}
@@ -120,6 +120,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
%{_datadir}/portproton/scripts/
%{_datadir}/portproton/conf/
%{_datadir}/portproton/img/
%{bash_completions_dir}/portprotonqt
%{fish_completions_dir}/portprotonqt.fish
%{zsh_completions_dir}/_portprotonqt
+5 -4
View File
@@ -1,12 +1,12 @@
%global pypi_name portprotonqt
%global pypi_version 1.5.0
%global pypi_version 1.3.1
%global oname PortProtonQt
%global _python_no_extras_requires 1
Name: %{pypi_name}
Version: %{pypi_version}
Release: 1%{?dist}
Summary: Game library manager and launcher for Linux
Summary: Modern GUI for managing and launching games from PortProton and Steam
License: GPL-3.0
URL: https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt
@@ -19,7 +19,6 @@ BuildRequires: git
BuildRequires: gettext
BuildRequires: systemd-rpm-macros
BuildRequires: vulkan-loader-devel
BuildRequires: pkgconfig(sdl3)
BuildRequires: gcc
Obsoletes: python3-%{pypi_name} < %{version}-%{release}
@@ -40,6 +39,7 @@ Requires: python3-pillow-qt
Requires: python3-rapidfuzz
Requires: python3-libarchive-c
Requires: perl-Image-ExifTool
Requires: SDL3
Requires: qt6-qtsvg
Requires: qt6-qtimageformats
Requires: qt6-qtmultimedia
@@ -78,7 +78,7 @@ Obsoletes: %{pypi_name}-steam-compat < %{?epoch:%{epoch}:}%{version}-%{rele
Provides: %{pypi_name}-steam-compat = %{?epoch:%{epoch}:}%{version}-%{release}
%description
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
%{?python_disable_dependency_generator}
@@ -118,6 +118,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
%{_datadir}/portproton/scripts/
%{_datadir}/portproton/conf/
%{_datadir}/portproton/img/
%{bash_completions_dir}/portprotonqt
%{fish_completions_dir}/portprotonqt.fish
%{zsh_completions_dir}/_portprotonqt
Binary file not shown.
@@ -2,13 +2,13 @@
Name=PortProtonQt
Exec=portprotonqt %u
Type=Application
Comment=Game library manager and launcher for Linux
Comment[ru]=Приложение для управления игровой библиотекой и запуска игр на Linux
Comment[es]=Gestor de biblioteca de juegos y lanzador para Linux
Comment[pt]=Gerenciador de biblioteca de jogos e inicializador para Linux
Comment[de]=Spielebibliotheksverwaltung und Spielestarter für Linux
Comment=A modern GUI for PortProton project
Comment[ru]=Современный графический интерфейс для проекта PortProton
Comment[es]=Una interfaz gráfica moderna para el proyecto PortProton
Comment[pt]=Uma interface gráfica moderna para o projeto PortProton
Comment[de]=Eine moderne grafische Benutzeroberfläche für das PortProton-Projekt
Terminal=false
Icon=ru.linux_gaming.PortProtonQt
StartupWMClass=ru.linux_gaming.PortProtonQt
Categories=Game;Utility;
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds;application/x-nrg;
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-executable;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds-disk;application/x-nrg;
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
StartupWMClass=ru.linux_gaming.PortProtonQt
Categories=Game;
NoDisplay=true
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
StartupNotify=true
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
StartupWMClass=ru.linux_gaming.PortProtonQt
Categories=Game;
NoDisplay=true
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
StartupNotify=true
@@ -5,15 +5,15 @@
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<summary>Game library manager and launcher for Linux</summary>
<summary xml:lang="ru">Приложение для управления игровой библиотекой и запуска игр на Linux</summary>
<summary>Modern GUI for managing and launching games from PortProton and Steam</summary>
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton и Steam</summary>
<description>
<p>
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
</p>
<p xml:lang="ru">
Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.
Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
</p>
</description>
@@ -24,20 +24,8 @@
</developer>
<releases>
<release version="1.5.0" date="2026-10-14">
<description></description>
</release>
<release version="1.4.1" date="2026-08-26">
<description/>
</release>
<release version="1.4.0" date="2026-08-23">
<description/>
</release>
<release version="1.3.2" date="2026-08-10">
<description/>
</release>
<release version="1.3.1" date="2026-07-17">
<description/>
<description></description>
</release>
<release version="1.3.0" date="2026-07-13">
<description/>
@@ -10,16 +10,4 @@
<comment xml:lang="ru">Скрипт автоустановки PortProton</comment>
<glob pattern="*.ppai"/>
</mime-type>
<mime-type type="application/x-portproton-ppdb">
<comment>PortProton configuration</comment>
<comment xml:lang="ru">Конфигурация PortProton</comment>
<sub-class-of type="text/plain"/>
<glob pattern="*.ppdb" weight="100"/>
</mime-type>
<mime-type type="application/x-portproton-user-conf">
<comment>PortProton user configuration</comment>
<comment xml:lang="ru">Пользовательская конфигурация PortProton</comment>
<sub-class-of type="text/plain"/>
<glob pattern="user.conf" weight="100"/>
</mime-type>
</mime-info>
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -20,30 +20,7 @@ python_module () {
local python3_module="$1"
shift
print_info "Running python3 module: $python3_module $@" >&2
"${PORTPROTONQT_PYTHON:-python3}" -m portprotonqt.scripts_utils.$python3_module "$@"
}
pw_alt_i586_dependencies () {
local action="${1:-check}" installed list package
installed="$(rpm -qa --qf '%{NAME}\n')" || return 2
list=""
for package in glibc-nss glibc-gconv-modules libnm libvulkan1 libfreetype \
$(grep '^libnss-' <<< "$installed" | grep -v '^libnss-fallback$') \
$(grep '^xorg-dri-' <<< "$installed"); do
if grep -Fqx "$package" <<< "$installed" \
&& ! grep -Fqx "i586-$package" <<< "$installed" ; then
list+=" i586-$package"
fi
done
if [[ "$action" == "check" ]] ; then
[[ -z "$list" ]]
return $?
fi
[[ -z "$list" ]] && return 0
apt-get update && apt-get install -y $list
python3 -m portprotonqt.scripts_utils.$python3_module "$@"
}
python_term () {
@@ -158,10 +135,8 @@ rm_from_array () {
}
fatal () {
if [[ -z "$*" ]] ; then
set -- "Fatal error"
fi
python_module dbus_tools notify -i error "$@" &>/dev/null
#TODO
pw_notify_send -i error "$@"
print_error "$@"
stop_portproton
exit 1
@@ -177,7 +152,7 @@ pw_enable_idle_inhibit() {
return
fi
PW_INHIBIT_SLR="${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.idle_inhibit"
PW_INHIBIT_SLR="python3 -m portprotonqt.scripts_utils.idle_inhibit"
print_info "Screensaver will be inhibited using D-Bus"
}
@@ -484,26 +459,6 @@ try_download () {
no_mirror=true
esac
local curl_proxy_args=()
local _pp_conf=""
if [[ -n "${XDG_CONFIG_HOME:-}" ]] \
&& [[ -f "$XDG_CONFIG_HOME/PortProtonQt.conf" ]] ; then
_pp_conf="$XDG_CONFIG_HOME/PortProtonQt.conf"
elif [[ -f "$HOME/.config/PortProtonQt.conf" ]] ; then
_pp_conf="$HOME/.config/PortProtonQt.conf"
fi
local _pp_url="" _pp_user="" _pp_pass=""
if [[ -n "$_pp_conf" ]] ; then
_pp_url="$(grep "proxy_url" "$_pp_conf" | awk -F"= " '{print $2}')"
if [[ -n "$_pp_url" ]] ; then
curl_proxy_args=(-x "$_pp_url")
_pp_user="$(grep "proxy_user" "$_pp_conf" | awk -F"= " '{print $2}')"
_pp_pass="$(grep "proxy_password" "$_pp_conf" | awk -F"= " '{print $2}')"
[[ -n "$_pp_user" && -n "$_pp_pass" ]] \
&& curl_proxy_args+=(-u "$_pp_user:$_pp_pass")
fi
fi
local filename
if [[ -n "${PW_AUTOINSTALL_EXE}" ]] \
&& [[ "$no_mirror" == "true" ]]
@@ -527,7 +482,7 @@ try_download () {
if check_gamescope_session ; then
$PW_TERM "echo ; echo ; echo \"Downloading: $filename. Please wait...\" \
; curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\" ${curl_proxy_args[*]}"
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\""
[[ "$?" != 0 ]] && return 1 || return 0
@@ -536,10 +491,10 @@ try_download () {
# Normal download
print_info "Download $filename from ${FIRST_URL[0]}..."
if [[ "$silent" == "true" ]] ; then
curl "${curl_proxy_args[@]}" -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1
else
curl "${curl_proxy_args[@]}" -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1 \
| tr '\r' '\n' | sed -ur 's|[# ]+||g;s|100||g;s|0-||g;s|.*=.*||g'
@@ -588,6 +543,48 @@ var_ld_library_path_update () {
fi
}
# GUI NOTIFY SEND
pw_notify_send () {
local icon title body app timeout OPTIND
app="ru.linux_gaming.PortProtonQt"
timeout="5000"
OPTIND="1"
while getopts a:i:t: opt ; do
case "$opt" in
a)
app=$OPTARG
;;
i)
case "$OPTARG" in
info) local OPTARG="${PORT_IMG_PATH}/notify/info.svg";;
warning) local OPTARG="${PORT_IMG_PATH}/notify/warning.svg";;
error) local OPTARG="${PORT_IMG_PATH}/notify/error.svg";;
esac
icon=$OPTARG
;;
t)
timeout=$OPTARG
;;
*)
echo "usage: $0
[-a] name application (PortProtonQt default)
[-i] info, warning, error (none default)
[-t] timeout in ms (5000 default)" >&2
return 1
;;
esac
done
shift $(( OPTIND - 1 ))
[[ "$1" == "--" ]] && shift
title="$1" ; shift
body="$*"
python_module dbus_tools notify \
-a "$app" -i "$icon" -t "$timeout" -- "$title" "$body" &>/dev/null
return 0
}
export -f pw_notify_send
check_gamescope_session () {
if [[ -n "$GAMESCOPE_IN_USE" ]]
then [[ "$GAMESCOPE_IN_USE" == 1 ]] && return 0 || return 1
@@ -1087,7 +1084,6 @@ EOF
-e '/export PW_SOUND_DRIVER_USE=/d' \
"$USER_CONF"
fi
chmod 644 "${USER_CONF}"
# shellcheck source=/dev/null
source "$USER_CONF"
@@ -1214,7 +1210,7 @@ pw_init_runtime () {
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
if [[ -z "$PW_TERM" ]] ; then
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" python3 -m portprotonqt.scripts_utils.easyterm -e"
fi
export pw_runtime="env"
return 0
@@ -1305,7 +1301,7 @@ ${PW_PV_OVERRIDES}/i386-linux-gnu/aliases:\
for PWRTMRW in "${PW_RT_MOUNT_RW[@]}" ; do
export PRESSURE_VESSEL_FILESYSTEMS_RW="${PRESSURE_VESSEL_FILESYSTEMS_RW:+$PRESSURE_VESSEL_FILESYSTEMS_RW:}${PWRTMRW}"
done
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" python3 -m portprotonqt.scripts_utils.easyterm -e"
print_info "RUNTIME is enabled"
}
@@ -1514,25 +1510,14 @@ kill_portwine () {
bwrap_pids=$(pgrep -f wrap | grep PortProton | head -n 1)
else
wine_pids=$(ls -l /proc/*/exe 2>/dev/null | grep -ie PortProton | grep -E 'wine(64)?-preloader|wineserver' | awk -F/ '{print $3}')
bwrap_pids="$(pgrep -a wrap | grep PortProton | awk '{print $1}')"
bwrap_pids="$(pgrep -a wrap | grep PortProton | head -n 1 | awk '{print $1}')"
fi
else
wine_pids=$(ls -l /proc/*/exe 2>/dev/null | grep -E 'wine(64)?-preloader|wineserver' | awk -F/ '{print $3}')
unset bwrap_pids
fi
# bwrap hosts steam-run's FHS container on NixOS.
# Killing an ancestor would terminate this launch with the container.
local pw_ancestor_pids=" " pw_ancestor_pid=$BASHPID
if [[ -n "${bwrap_pids:-}" ]] ; then
while [[ -n "$pw_ancestor_pid" && "$pw_ancestor_pid" -gt 1 ]] ; do
pw_ancestor_pids+="$pw_ancestor_pid "
pw_ancestor_pid=$(ps -o ppid= -p "$pw_ancestor_pid" 2>/dev/null | tr -d '[:space:]')
done
fi
for pw_kill_pids in ${bwrap_pids} ${wine_pids}; do
[[ "$pw_ancestor_pids" == *" $pw_kill_pids "* ]] && continue
if ps cax | grep "${pw_kill_pids}" ; then
kill -n 9 "${pw_kill_pids}" &>/dev/null
fi
@@ -1651,15 +1636,8 @@ pw_check_and_download_runtime () {
}
pw_check_and_download_dxvk_and_vkd3d () {
case "${PW_VULKAN_USE}" in
1) DXVK_VAR_VER="${DXVK_SAREK_VER}"; VKD3D_VAR_VER="${VKD3D_SAREK_VER}" ;;
2) DXVK_VAR_VER="${DXVK_OLD_VER}"; VKD3D_VAR_VER="${VKD3D_OLD_VER}" ;;
6) DXVK_VAR_VER="${DXVK_NEW_VER}"; VKD3D_VAR_VER="${VKD3D_NEW_VER}" ;;
*) DXVK_VAR_VER=""; VKD3D_VAR_VER="" ;;
esac
# Download DXVK
if [[ -n "${DXVK_VAR_VER}" ]] ; then
for DXVK_VAR_VER in "${DXVK_SAREK_VER}" "${DXVK_OLD_VER}" "${DXVK_NEW_VER}" ; do
if [[ ! -d "${PW_VULKAN_DIR}/dxvk-${DXVK_VAR_VER}" ]] ; then
print_info "Download and install DXVK v.${DXVK_VAR_VER}"
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/dxvk-${DXVK_VAR_VER}/dxvk-${DXVK_VAR_VER}.tar.xz" \
@@ -1686,10 +1664,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
fatal "Failed to download and unpack dxvk"
fi
fi
fi
done
# Download VKD3D
if [[ -n "${VKD3D_VAR_VER}" ]] ; then
for VKD3D_VAR_VER in "${VKD3D_SAREK_VER}" "${VKD3D_OLD_VER}" "${VKD3D_NEW_VER}" ; do
if [[ ! -d "${PW_VULKAN_DIR}/vkd3d-proton-${VKD3D_VAR_VER}" ]] ; then
print_info "Download and install VKD3D-PROTON v.${VKD3D_VAR_VER}"
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/vkd3d-proton-${VKD3D_VAR_VER}/vkd3d-proton-${VKD3D_VAR_VER}.tar.xz" \
@@ -1714,10 +1692,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
fatal "Failed to download and unpack VKD3D"
fi
fi
fi
done
# Download D7VK
if [[ "${PW_USE_D7VK}" == "1" && ! -d "${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}" ]] ; then
if [[ ! -d "${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}" ]] ; then
print_info "Download and install D7VK v.${D7VK_VAR_VER}"
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/d7vk-${D7VK_VAR_VER}/d7vk-${D7VK_VAR_VER}.tar.xz" \
"${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}.tar.xz" ; then
@@ -1861,6 +1839,11 @@ pw_init_db () {
[[ -n "${PW_DEFAULT_PREFIX_NAME:-}" ]] && export PW_PREFIX_NAME="${PW_DEFAULT_PREFIX_NAME}"
[[ -n "${PW_DEFAULT_VULKAN_USE:-}" ]] && export PW_VULKAN_USE="${PW_DEFAULT_VULKAN_USE}"
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
export PW_USE_NVAPI_AND_DLSS="1"
export PW_USE_RAY_TRACING="1"
fi
if [[ -f "${PW_EXE_FILE}" ]] ; then
PORTWINE_DB="$(basename "${PW_EXE_FILE%.[Ee][Xx][Ee]}")"
export PORTWINE_DB
@@ -2018,7 +2001,7 @@ pw_check_vulkan_extensions () {
local vk_gpu_info_cmd
ext_key="supports_$(echo "${ext_name}" | tr '[:upper:]' '[:lower:]')"
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../bin/vk_gpu_info"
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../../dev-scripts/vk_gpu_info"
if [[ -x "${vk_gpu_info_from_repo}" ]] ; then
vk_gpu_info_cmd="${vk_gpu_info_from_repo}"
else
@@ -2211,8 +2194,8 @@ manage_user_conf_value () {
local var_name="$2"
local var_value="$3"
if [[ -z "${action}" ]]; then
print_error "Action must be provided"
if [[ -z "${action}" ]] || [[ -z "${var_name}" ]]; then
print_error "Action and variable name must be provided"
return 1
fi
@@ -2223,11 +2206,6 @@ manage_user_conf_value () {
case "$action" in
get)
if [[ -z "${var_name}" ]]; then
grep -E '^export [A-Za-z_][A-Za-z0-9_]*=' "${USER_CONF}" 2>/dev/null \
| sed -E 's/^export //' || true
return 0
fi
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
# Extract the value, handling both quoted and unquoted values
local value=$(grep "^export ${var_name}=" "${USER_CONF}" | sed -E 's/^export [^=]+=("?)(.*)\1$/\2/')
@@ -2239,10 +2217,6 @@ manage_user_conf_value () {
fi
;;
set)
if [[ -z "${var_name}" ]]; then
print_error "Variable name must be provided"
return 1
fi
if [[ -z "${var_value}" ]]; then
print_error "Variable value must be provided for set action"
return 1
@@ -2253,10 +2227,6 @@ manage_user_conf_value () {
return 0
;;
delete)
if [[ -z "${var_name}" ]]; then
print_error "Variable name must be provided"
return 1
fi
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
sed -i "/^export ${var_name}=/d" "${USER_CONF}"
print_info "Removed ${var_name} from USER.conf"
@@ -2341,7 +2311,7 @@ pw_create_gui_png () {
pw_create_unique_exe () {
BASEDIR_GAME="$(dirname "$PW_EXE_FILE")"
if [[ -d "$BASEDIR_GAME" ]] ; then
pushd "$BASEDIR_GAME" || fatal "Failed to enter game directory: $BASEDIR_GAME"
pushd "$BASEDIR_GAME" || fatal
BASENAME_GAME_EXE="$(basename "$PW_EXE_FILE")"
if [[ -n "$1" ]] ; then
BASENAME_GAME="$(basename "$1" .exe).exe"
@@ -2354,7 +2324,7 @@ pw_create_unique_exe () {
print_warning "There are no arguments for creating a symbolic link! Skip it..."
fi
try_remove_file "${PW_EXE_FILE}.ppdb"
popd || fatal "Failed to return from game directory: $BASEDIR_GAME"
popd || fatal
fi
}
@@ -2428,9 +2398,8 @@ pw_specialk_set_ini_value () {
[[ -f "$ini_file" ]] && sed -i '/./,$!d' "$ini_file"
if grep -q "^\[$section\]" "$ini_file" ; then
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" \
| grep -q "^[[:space:]]*$key[[:space:]]*=" ; then
sed -i "/^\[$section\]/,/^\[/{s/^[[:space:]]*$key[[:space:]]*=.*/$key=$value/}" "$ini_file"
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" | grep -q "^$key=" ; then
sed -i "/^\[$section\]/,/^\[/{s/^$key=.*/$key=$value/}" "$ini_file"
else
sed -i "/^\[$section\]/a $key=$value" "$ini_file"
fi
@@ -2523,171 +2492,6 @@ pw_specialk_sync_files () {
}
pw_reshade_sync_files () {
local root="${PW_PLUGINS_PATH}/reshade_windows" runtime detect arch dll highest ogl vulkan source
local manifest_reg package installed installed_arch installed_version extract_dir reshade_exe reshade_font font_win screenshot_path backup
local PATH_TO_GAME="${PATH_TO_GAME:-}"
runtime="$root/reshade/${PW_RESHADE_VER}_Addon"
reshade_exe="${PW_RESHADE_EXE:-$PW_EXE_FILE}"
if [[ ! -f "$reshade_exe" ]] ; then
print_warning "ReShade executable not found: $reshade_exe. Using $PW_EXE_FILE."
reshade_exe="$PW_EXE_FILE"
fi
if [[ "$PW_USE_RESHADE" == 1 ]] ; then
detect=$(python_module graphics_detector "$reshade_exe")
file "$reshade_exe" | grep -qE 'x86-64|PE32\+' && arch=64 || arch=32
highest=$(echo "$detect" | grep '^HIGHEST_DX=' | cut -d= -f2)
ogl=$(echo "$detect" | grep '^USES_OGL=' | cut -d= -f2)
vulkan=$(echo "$detect" | grep '^USES_VULKAN=' | cut -d= -f2)
source=$(echo "$detect" | grep '^SOURCE=' | cut -d= -f2-)
[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] && vulkan=false
fi
if [[ -n "$PW_RESHADE_EXE" ]] ; then
PATH_TO_GAME="$(dirname "$(realpath "$reshade_exe")")"
fi
if [[ "$PW_USE_RESHADE" == 1 ]] \
&& [[ ! -f "$runtime/ReShade32.dll" || ! -f "$runtime/ReShade32.json" \
|| ! -f "$runtime/ReShade64.dll" || ! -f "$runtime/ReShade64.json" ]] ; then
try_download "https://reshade.me/downloads/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
"${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
create_new_dir "$runtime"
unpack "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
"$runtime" silent || fatal "Failed to extract ReShade."
try_remove_file "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
fi
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3dcompiler_47.dll" ]] ; then
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/d3dcompiler_47.dll" "$root/d3dcompiler_47.dll"
fi
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3d8.dll" ]] ; then
try_download "https://github.com/crosire/d3d8to9/releases/download/v${PW_D3D8TO9_VER}/d3d8.dll" "$root/d3d8.dll"
fi
if [[ "$PW_USE_RESHADE" == 1 && ! -d "$root/shaders/Shaders" ]] ; then
try_remove_dir "$root/shaders"
create_new_dir "$root/shaders/Shaders"
create_new_dir "$root/shaders/Textures"
for package in reshade_shaders sweetfx_shaders immerse_shaders astrayfx_shaders prod80_shaders retroarch_shaders ; do
extract_dir="${PORT_DATA_PATH}/data/tmp/$package"
if [[ "$package" == immerse_shaders ]] ; then
try_download "https://github.com/martymcmodding/iMMERSE/archive/refs/heads/main.tar.gz" \
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
else
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/${package}.tar.gz" \
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
fi
try_remove_dir "$extract_dir"
create_new_dir "$extract_dir"
unpack "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" "$extract_dir" \
silent || fatal "Failed to extract $package."
find "$extract_dir" -type d -iname Shaders -exec cp -rf '{}/.' "$root/shaders/Shaders/" \;
find "$extract_dir" -type d -iname Textures -exec cp -rf '{}/.' "$root/shaders/Textures/" \;
try_remove_dir "$extract_dir"
try_remove_file "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
done
fi
if [[ "$PW_USE_RESHADE" == 1 && "$vulkan" == true ]] ; then
dll="Vulkan${arch}"
elif [[ "$PW_USE_RESHADE" == 1 ]] ; then
case "$highest" in
"DirectX 10"|"DirectX 11"|"DirectX 12") dll=dxgi.dll ;;
"DirectX 8"|"DirectX 9") dll=d3d9.dll ;;
"DirectDraw") return 0 ;;
*) [[ "$ogl" == true ]] && dll=opengl32.dll || dll=dxgi.dll ;;
esac
fi
case "$PW_RESHADE_API" in
vulkan) vulkan=true; dll="Vulkan${arch}" ;;
dxgi.dll|d3d9.dll|opengl32.dll) vulkan=false; dll="$PW_RESHADE_API" ;;
esac
if [[ -f "$PATH_TO_GAME/reshade_version" ]] \
&& { [[ "$PW_USE_RESHADE" != 1 ]] \
|| [[ "$(<"$PATH_TO_GAME/reshade_version")" != "${PW_RESHADE_VER}_Addon"$'\n'"$dll" ]]; } ; then
installed_version=$(sed -n '1p' "$PATH_TO_GAME/reshade_version")
installed=$(sed -n '2p' "$PATH_TO_GAME/reshade_version")
if [[ "$installed" == Vulkan32 || "$installed" == Vulkan64 ]] ; then
installed_arch=${installed#Vulkan}
manifest_reg="Z:${root//\//\\}\\reshade\\${installed_version}\\ReShade${installed_arch}.json"
manifest_reg=${manifest_reg//\\/\\\\\\\\}
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
"$manifest_reg" "" "" user
if [[ "$installed_arch" == 32 ]] ; then
get_and_set_reg_file --delete 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' \
"$manifest_reg" "" "" system
else
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
"$manifest_reg" "" "" system
fi
fi
try_remove_file "$PATH_TO_GAME/$installed"
if [[ -L "$PATH_TO_GAME/${installed}.b" \
&& $(readlink -f "$PATH_TO_GAME/${installed}.b") == "$root/reshade/"* ]] ; then
unlink "$PATH_TO_GAME/${installed}.b"
fi
[[ -e "$PATH_TO_GAME/${installed}.b" ]] \
&& mv -f "$PATH_TO_GAME/${installed}.b" "$PATH_TO_GAME/$installed"
[[ -L "$PATH_TO_GAME/d3dcompiler_47.dll" ]] && unlink "$PATH_TO_GAME/d3dcompiler_47.dll"
[[ -L "$PATH_TO_GAME/d3d8.dll" ]] && unlink "$PATH_TO_GAME/d3d8.dll"
[[ -e "$PATH_TO_GAME/d3d8.dll.b" ]] \
&& mv -f "$PATH_TO_GAME/d3d8.dll.b" "$PATH_TO_GAME/d3d8.dll"
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
try_remove_file "$PATH_TO_GAME/ReShade.ini"
try_remove_file "$PATH_TO_GAME/ReShadePreset.ini"
try_remove_file "$PATH_TO_GAME/ReShade.log"
try_remove_file "$PATH_TO_GAME/reshade_version"
fi
[[ "$PW_USE_RESHADE" != 1 ]] && return 0
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
try_force_link_dir "$root/shaders" "$PATH_TO_GAME/ReShade_shaders"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "EffectSearchPaths" '.\\ReShade_shaders\\Shaders'
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "TextureSearchPaths" '.\\ReShade_shaders\\Textures'
if [[ -n "$PW_RESHADE_FONT" ]] ; then
reshade_font=$(fc-match --format='%{file}' "$PW_RESHADE_FONT")
else
reshade_font=/usr/share/fonts/ttf/dejavu/DejaVuSans.ttf
fi
if [[ -f "$reshade_font" ]] ; then
font_win="Z:${reshade_font//\//\\\\}"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "Font" "$font_win"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "EditorFont" "$font_win"
elif [[ -n "$PW_RESHADE_FONT" ]] ; then
print_warning "ReShade font not found: $PW_RESHADE_FONT"
fi
screenshot_path='.\\'
if [[ -n "$PW_RESHADE_SCREENSHOT_PATH" ]] ; then
create_new_dir "$PW_RESHADE_SCREENSHOT_PATH"
screenshot_path="Z:${PW_RESHADE_SCREENSHOT_PATH//\//\\\\}"
fi
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyScreenshot" "$PW_RESHADE_SCREENSHOT_KEY"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyOverlay" "$PW_RESHADE_OVERLAY_KEY"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "SCREENSHOT" "SavePath" "$screenshot_path"
if [[ "$vulkan" == true ]] ; then
manifest_reg="Z:${runtime//\//\\}\\ReShade${arch}.json"
manifest_reg=${manifest_reg//\\/\\\\\\\\}
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 user
if [[ "$arch" == 32 ]] ; then
get_and_set_reg_file --add 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
else
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
fi
var_winedlloverride_update "vulkan-1=n,b"
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
return 0
fi
[[ ! -f "$PATH_TO_GAME/reshade_version" \
&& -e "$PATH_TO_GAME/$dll" && ! -e "$PATH_TO_GAME/${dll}.b" \
&& ( ! -L "$PATH_TO_GAME/$dll" \
|| $(readlink -f "$PATH_TO_GAME/$dll") != "$root/reshade/"* ) ]] \
&& mv -f "$PATH_TO_GAME/$dll" "$PATH_TO_GAME/${dll}.b"
try_force_link_file "$runtime/ReShade${arch}.dll" "$PATH_TO_GAME/$dll"
try_force_link_file "$root/d3dcompiler_47.dll" "$PATH_TO_GAME/d3dcompiler_47.dll"
var_winedlloverride_update "d3dcompiler_47=n;${dll%.*}=n,b"
if [[ "$highest" == "DirectX 8" && "$dll" == d3d9.dll ]] ; then
[[ -e "$PATH_TO_GAME/d3d8.dll" && ! -e "$PATH_TO_GAME/d3d8.dll.b" ]] && mv -f "$PATH_TO_GAME/d3d8.dll" "$PATH_TO_GAME/d3d8.dll.b"
try_force_link_file "$root/d3d8.dll" "$PATH_TO_GAME/d3d8.dll"
var_winedlloverride_update "d3d8=n,b"
fi
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
}
pw_prepare_gamescope_launch () {
[[ "$PW_USE_NATIVE_WAYLAND" == "1" ]] && export PW_GAMESCOPE="0"
@@ -2785,16 +2589,21 @@ pw_enable_gamemode_basic () {
if check_flatpak ; then
export GAMEMODERUN=1
PW_GAMEMODERUN_SLR="gamemoderun"
elif command -v gamemoderun &>/dev/null ; then
export GAMEMODERUN=1
PW_GAMEMODERUN_SLR="gamemoderun"
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
gamemoded &>/dev/null &
sleep 0.1
fi
else
export GAMEMODERUN=1
local GAMEMODEAUTO_NAME="libgamemodeauto.so.0"
export PW_LD_PRELOAD="${GAMEMODEAUTO_NAME}${PW_LD_PRELOAD:+:$PW_LD_PRELOAD}"
if command -v gamemoderun &>/dev/null ; then
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
gamemoded &>/dev/null &
sleep 0.1
fi
elif ! pw_is_process_active gamemoded ; then
if [[ -n "${PW_LD_PRELOAD}" ]]; then
export PW_LD_PRELOAD="${PW_LD_PRELOAD}:libgamemodeauto.so.0"
else
export PW_LD_PRELOAD="libgamemodeauto.so.0"
fi
if ! pw_is_process_active gamemoded ; then
env LD_LIBRARY_PATH="${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32" \
"${PW_PLUGINS_PATH}/portable/bin/gamemoded" &>/dev/null &
sleep 0.1
@@ -2817,10 +2626,6 @@ start_portproton () {
pw_check_and_download_dxvk_and_vkd3d
pw_skip_get_info
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
[[ -z "${PW_USE_NVAPI_AND_DLSS:-}" ]] && export PW_USE_NVAPI_AND_DLSS="1"
[[ -z "${PW_USE_RAY_TRACING:-}" ]] && export PW_USE_RAY_TRACING="1"
fi
if [[ "${PW_LOCALE_SELECT}" != "disabled" ]] && [[ -n "${PW_LOCALE_SELECT}" ]] ; then
export LC_ALL="${PW_LOCALE_SELECT}"
if [[ "${PW_USE_RUNTIME}" == "1" ]] && [[ "${HOST_LC_ALL}" != "${LC_ALL}" ]] ; then
@@ -3090,9 +2895,7 @@ start_portproton () {
# export WINE_HIDE_AMD_GPU="1"
export DXVK_NVAPI_DRIVER_VERSION="99999"
if [[ $(check_vendor_gpu) != "nvidia" ]] ; then
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
fi
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
export PW_USE_NVAPI_AND_DLSS="1"
export PW_USE_RAY_TRACING="1"
;;
@@ -3123,7 +2926,6 @@ start_portproton () {
pw_optiscaler_sync_files
pw_specialk_sync_files
pw_reshade_sync_files
if [[ "${PW_USE_RAY_TRACING}" == "1" ]] ; then
var_vkd3d_config_update dxr
@@ -3212,7 +3014,7 @@ start_portproton () {
elif [[ "$PW_USE_GAMEMODE" = "1" ]] \
&& [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]
then
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn minazuki ; then
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn ; then
export GAMEMODERUN=0
export PW_GAMEMODERUN_SLR=""
local current_power_profile
@@ -3424,7 +3226,7 @@ start_portproton () {
# DGVOODOO2 enable:
if [[ "${PW_DGVOODOO2}" == "1" ]] \
&& [[ "${PW_USE_SPECIALK}" != "1" ]] \
&& [[ "${PW_USE_D7VK}" != "1" ]]
&& [[ "${PW_USE_D7VK}" != "1" ]] \
then
if [[ ! -f "${PW_VULKAN_DIR}/dgvoodoo2-${DGV2_VER}/dgVoodoo.conf" ]] ; then
print_info "Download and install DGVOODOO2 v.${DGV2_VER}"
@@ -3660,17 +3462,10 @@ start_portproton () {
if [[ "${PW_USE_D7VK}" == "1" ]] ; then
PATH_TO_D7VK_FILES="${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}"
D7VK_FILE="${PATH_TO_D7VK_FILES}/x32/ddraw.dll"
if [[ "$PW_USE_SUPPLIED_DXVK_VKD3D" == "2" ]] \
&& [[ -f "${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll" ]] ; then
D7VK_FILE="${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll"
print_info "Bundled D7VK in use"
else
print_info "D7VK ${D7VK_VAR_VER} in use"
fi
print_info "D7VK ${D7VK_VAR_VER} in use"
print_info "Try create symlink D7VK files..."
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw_.dll"
try_force_link_file "${D7VK_FILE}" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
try_force_link_file "${PATH_TO_D7VK_FILES}/x32/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
var_winedlloverride_update "ddraw=n,b"
else
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
@@ -3959,21 +3754,16 @@ start_portproton () {
*) get_and_set_reg_file --delete 'Software\Wine\Drivers' 'Audio' ;;
esac
if [[ -n "${PW_NETWORK_BLOCKLIST:-}" ]] ; then
export PW_LD_PRELOAD="${PW_LD_PRELOAD:+${PW_LD_PRELOAD}:}libppqt-network-filter.so"
fi
add_in_start_portproton
local steam_emu_language=""
case "${PW_LOCALE_SELECT}" in
ru_*) steam_emu_language="russian" ;;
en_*) steam_emu_language="english" ;;
zh_*) steam_emu_language="schinese" ;;
ja_*) steam_emu_language="japanese" ;;
ko_*) steam_emu_language="koreana" ;;
disabled|"") steam_emu_language="$FULL_LN" ;;
esac
if [[ -n "$steam_emu_language" ]] ; then
if [[ "$FULL_LN" == russian ]] ; then
for chk_lang_and_uname in "steam_emu.ini" "steam_api.ini" "steam_api64.ini" "SteamConfig.ini" ; do
if [[ -f "${PATH_TO_GAME}/${chk_lang_and_uname}" ]] ; then
sed -i "/^Language/c\Language=${steam_emu_language}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
sed -i "/^Language/c\Language=${FULL_LN}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
sed -i "/^UserName/c\UserName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
sed -i "/^PlayerName/c\PlayerName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
fi
@@ -4010,7 +3800,6 @@ start_portproton () {
}
pw_run () {
local pw_game_exit_code
if [[ "$PW_USE_TERMINAL" == 1 ]] \
&& [[ "${PRESSURE_VESSEL_TERMINAL:-}" != "tty" ]]
then
@@ -4077,7 +3866,6 @@ pw_run () {
${PW_GAMEMODERUN_SLR} \
${PW_ADD_VAR_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
else
if [[ "$PW_USE_TERMINAL" == 1 ]] ; then
pw_init_runtime
@@ -4098,7 +3886,6 @@ pw_run () {
${PW_GAMEMODERUN_SLR} \
${PW_ADD_VAR_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
print_info "Update prefix log:"
[[ -f "${PW_TMPFS_PATH}/update_pfx_log" ]] && cat "${PW_TMPFS_PATH}/update_pfx_log"
echo
@@ -4128,7 +3915,6 @@ pw_run () {
${PW_INHIBIT_SLR} \
${PW_TASKSET_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
wait_wineserver
else
print_info "Update prefix log:"
@@ -4149,12 +3935,9 @@ pw_run () {
${PW_INHIBIT_SLR} \
${PW_TASKSET_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters}
pw_game_exit_code=$?
wait_wineserver
fi
fi
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$pw_game_exit_code"
return "$pw_game_exit_code"
}
export -f pw_run
@@ -4219,6 +4002,7 @@ steamplay_launch () {
pw_optiscaler_sync_files
pw_specialk_sync_files
PORT_WINE_PREFIX="$STEAM_COMPAT_DATA_PATH/pfx"
pw_prepare_gamescope_launch
@@ -4284,8 +4068,6 @@ steamplay_launch () {
fi
fi
pw_reshade_sync_files
# Winetricks
if [[ -n "${PW_DLL_INSTALL:-}" ]] ; then
update_winetricks
@@ -4311,7 +4093,6 @@ steamplay_launch () {
fi
print_info "$proton_runner in use."
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
pw_launch_wrapper \
${PW_RUN_GAMESCOPE} \
env \
@@ -4321,7 +4102,6 @@ steamplay_launch () {
${PW_GAMEMODERUN_SLR} \
${PW_TASKSET_SLR} \
"${proton_runner}" waitforexitandrun "${PW_EXE_FILE}" "$@"
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$?"
if [[ $PW_LOG != 1 ]] && [[ -n $START_PW_TIME_IN_GAME ]] ; then
debug_timer --end -s "PW_TIME_IN_GAME"
PW_TIME_IN_GAME=$(( PW_TIME_IN_GAME / 1000 ))
@@ -4338,7 +4118,6 @@ portwine_launch () {
fi
[[ $PW_LOG != 1 ]] && debug_timer --start -s "PW_TIME_IN_GAME"
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
if [[ -n "${LAUNCH_URI}" ]] ; then
export WINEFSYNC="1"
pw_run start /high /b "${LAUNCH_URI}" &
@@ -4359,11 +4138,12 @@ portwine_launch () {
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]] \
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
then
local wait_delay wait_delay_int run_after_bat
local run_after_dir run_after_name
local pw_exe_file_win run_after_exe_win
local pw_exe_file_win
local wine_drive_c
wait_delay="${PW_RUN_AFTER_DELAY:-3}"
wait_delay_int="${wait_delay%%.*}"
@@ -4382,27 +4162,16 @@ portwine_launch () {
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
fi
pw_exe_file_win="${pw_exe_file_win//\//\\}"
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
if [[ -z "${run_after_exe_win}" ]] ; then
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
run_after_exe_win="c:\\${PW_RUN_AFTER_EXE#"${wine_drive_c}/"}"
else
run_after_exe_win="z:${PW_RUN_AFTER_EXE//\//\\}"
fi
fi
run_after_exe_win="${run_after_exe_win//\//\\}"
cat > "${run_after_bat}" <<EOF
@echo off
chcp 65001 >nul
start "" "%PW_RUN_AFTER_MAIN_PATH%" ${LAUNCH_PARAMETERS}
start "" "${pw_exe_file_win}" ${LAUNCH_PARAMETERS}
timeout /t ${wait_delay_int} /nobreak
start "" "%PW_RUN_AFTER_SECOND_PATH%"
start "" /unix "${PW_RUN_AFTER_EXE}"
EOF
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
try_remove_file "${run_after_bat}"
else
@@ -4448,13 +4217,8 @@ gui_proton_downloader () {
return 0
fi
METADATA_CLOUD_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
METADATA_GITHUB_URL="https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt-Wine-Metadata/main/wine_metadata_all.json"
if [[ "${MIRROR}" == CLOUD ]] ; then
METADATA=$(curl -fL -A 'PortProton' -s "$METADATA_CLOUD_URL")
else
METADATA=$(curl -fL -s "$METADATA_GITHUB_URL")
fi
METADATA_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
METADATA=$(curl -A 'PortProton' -s "$METADATA_URL")
if [[ -z "$METADATA" ]] ; then
fatal "failed to fetch metadata"
fi
@@ -4499,9 +4263,7 @@ gui_edit_db () {
KEY_EDIT_DB_GUI=$RANDOM
DISABLE_EDIT_DB_LIST=""
PW_EDIT_DB_LIST=(PW_MANGOHUD PW_MANGOHUD_USER_CONF PW_VKBASALT PW_VKBASALT_USER_CONF
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK PW_USE_RESHADE
PW_RESHADE_EXE PW_RESHADE_API PW_RESHADE_FONT PW_RESHADE_SCREENSHOT_PATH
PW_RESHADE_SCREENSHOT_KEY PW_RESHADE_OVERLAY_KEY
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK
PW_USE_ESYNC PW_USE_FSYNC PW_USE_NTSYNC PW_USE_RAY_TRACING PW_USE_NVAPI_AND_DLSS PW_USE_OPTISCALER
PW_USE_LS_FRAME_GEN PW_WINE_FULLSCREEN_FSR PW_HIDE_NVIDIA_GPU PW_VIRTUAL_DESKTOP PW_USE_TERMINAL
PW_USE_GAMEMODE PW_USE_INHIBIT_SLEEP PW_USE_D3D_EXTRAS PW_FIX_VIDEO_IN_GAME PW_REDUCE_PULSE_LATENCY
@@ -4535,10 +4297,6 @@ gui_edit_db () {
DISABLE_EDIT_DB_LIST+=" PW_VKBASALT PW_VKBASALT_USER_CONF PW_USE_RAY_TRACING PW_USE_OBS_VKCAPTURE PW_USE_GALLIUM_ZINK PW_USE_WINED3D_VULKAN"
fi
[[ "${PW_USE_SPECIALK}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_DGVOODOO2"
[[ "${PW_USE_RESHADE}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"
if [[ "${PW_USE_OPTISCALER}" == "1" || "${PW_USE_SPECIALK}" == "1" ]] ; then
DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"
fi
[[ ! -e "/dev/ntsync" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_NTSYNC"
@@ -4605,9 +4363,7 @@ gui_edit_db () {
edit_db_from_gui "${PW_EDIT_DB_FINAL_LIST[@]}"
if [[ "${PW_MANGOHUD}" == "1" ]] \
&& [[ "${PW_MANGOHUD_USER_CONF}" != "1" ]] \
&& [[ -z "$MANGOHUD_CONFIG" ]] ; then
if [[ -z "$MANGOHUD_CONFIG" ]] ; then
MONITOR_HEIGHT="$(echo "$PW_SCREEN_RESOLUTION" | awk -F'x' '{print $2}')"
MH_FONT_SIZE="font_size=$(( MONITOR_HEIGHT / 45 ))"
@@ -5151,7 +4907,7 @@ pw_create_prefix_backup_cli () {
PW_PREFIX_NAME="$1"
PW_PREFIX_TO_BACKUP="$2"
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
backup "${PW_PREFIX_NAME}" "${PW_PREFIX_TO_BACKUP}"
return $?
}
@@ -5205,7 +4961,7 @@ pw_unpack_prefix () {
return $?
fi
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
restore "${PW_BACKUP_FILE}"
return $?
}
View File
File mode changed.
View File
File mode changed.
+16 -22
View File
@@ -6,19 +6,6 @@ export url_git="https://git.linux-gaming.ru/CastroFidel/PortWINE"
########################################################################
$PW_DEBUG
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
else
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
fi
if [[ "${1:-}" == "cli" ]] \
&& [[ "${2:-}" == "--alt-i586-dependencies" ]] ; then
source "$PORT_SCRIPTS_PATH/functions_helper"
pw_alt_i586_dependencies "${3:-}"
exit $?
fi
if [[ $(id -u) = 0 ]] \
&& [[ ! -e "/userdata/system/batocera.conf" ]]
then
@@ -26,6 +13,13 @@ then
exit 1
fi
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
else
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
fi
PORT_IMG_PATH="$(dirname "$PORT_SCRIPTS_PATH")/img"
PORT_CONF_PATH="$(dirname "$PORT_SCRIPTS_PATH")/conf"
if [[ -z "$PORT_DATA_PATH" ]] ; then
@@ -61,7 +55,7 @@ else
export PW_FIND_PPDB=1
fi
export PORT_SCRIPTS_PATH PORT_DATA_PATH PORT_CONF_PATH
export PORT_SCRIPTS_PATH PORT_IMG_PATH PORT_DATA_PATH PORT_CONF_PATH
export PW_LOG_FILE="${PORT_DATA_PATH}/PortProton.log"
# shellcheck source=/dev/null
@@ -154,7 +148,7 @@ create_new_dir "${PORT_DATA_PATH}/data/prefixes/DEFAULT"
create_new_dir "${PORT_DATA_PATH}/data/prefixes/DOTNET"
try_force_link_dir "${PORT_DATA_PATH}/data/prefixes" "${PORT_DATA_PATH}"
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal "Failed to enter prefixes directory"
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal
for pfx_dir in ./* ; do
[[ -d "$pfx_dir" ]] || continue
pfx_dir_new="${pfx_dir//[[:blank:]]/_}"
@@ -162,7 +156,7 @@ for pfx_dir in ./* ; do
mv -- "${PORT_DATA_PATH}/data/prefixes/$pfx_dir" "${PORT_DATA_PATH}/data/prefixes/${pfx_dir_new^^}"
fi
done
popd 1>/dev/null || fatal "Failed to return from prefixes directory"
popd 1>/dev/null || fatal
create_new_dir "${PORT_WINE_TMP_PATH}"/gecko
create_new_dir "${PORT_WINE_TMP_PATH}"/mono
@@ -170,12 +164,12 @@ create_new_dir "${PORT_WINE_TMP_PATH}"/mono
export PW_VULKAN_DIR="${PORT_WINE_TMP_PATH}/VULKAN"
create_new_dir "${PW_VULKAN_DIR}"
cd "${PORT_SCRIPTS_PATH}" || fatal "Scripts directory not found: ${PORT_SCRIPTS_PATH}"
cd "${PORT_SCRIPTS_PATH}" || fatal
# shellcheck source=/dev/null
source "${PORT_SCRIPTS_PATH}/var"
# HACK: Avoid inheriting a system scripts cwd in pressure-vessel.
cd "${XDG_DATA_HOME:-$HOME}" || fatal "Data directory not found: ${XDG_DATA_HOME:-$HOME}"
cd "${XDG_DATA_HOME:-$HOME}" || fatal
[[ ! -f "$VKBASALT_CONFIG_FILE" ]] && cp -f "${PORT_CONF_PATH}/vkBasalt.conf" "$VKBASALT_CONFIG_FILE"
[[ ! -f "$DXVK_CONFIG_FILE" ]] && cp -f "${PORT_CONF_PATH}/dxvk.conf" "$DXVK_CONFIG_FILE"
@@ -222,8 +216,8 @@ fi
print_info "The first mirror in used: $MIRROR"
if check_gamescope_session
then PW_TERM="env LANG=C ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm --fullscreen -e"
else PW_TERM="env LANG=C ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
then PW_TERM="env LANG=C python3 -m portprotonqt.scripts_utils.easyterm --fullscreen -e"
else PW_TERM="env LANG=C python3 -m portprotonqt.scripts_utils.easyterm -e"
fi
pw_cleanup () {
@@ -273,7 +267,7 @@ case "$1" in
--launch Launches the application immediately, requires the path to the .exe file
--edit-db After the variable, the path to the .exe file is required and then the variables.
(List their variables and values for example PW_MANGOHUD=1 PW_VKBASALT=0, etc.)
--get-user-conf Get one or all values from user.conf file
--get-user-conf Get a value from user.conf file, requires variable name
--set-user-conf Set a value in user.conf file, requires variable name and value
--del-user-conf Delete a value from user.conf file, requires variable name
--list-db List all available database variables
@@ -478,7 +472,7 @@ Usage examples:
--winecmd)
get_wine_and_pfx "$2" "$3"
start_portproton
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found"
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal
PW_USE_TERMINAL=1 pw_run cmd
stop_portproton
;;
+2 -11
View File
@@ -16,8 +16,6 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
export PW_SPECIALK_VER="SK_26_5_15"
export PW_RESHADE_VER="6.8.0"
export PW_D3D8TO9_VER="1.15.1"
export PW_GALLIUM_NINE_VER="0.10"
export DOTPFX_VER="48v9"
export DEFPFX_VER="v1"
@@ -35,20 +33,13 @@ check_variables PROTON_USE_XALIA "0"
check_variables PW_USE_RUNTIME "1"
check_variables PW_USE_D7VK "0"
check_variables PW_USE_SPECIALK "0"
check_variables PW_USE_RESHADE "0"
check_variables PW_RESHADE_API "auto"
check_variables PW_RESHADE_EXE ""
check_variables PW_RESHADE_FONT ""
check_variables PW_RESHADE_SCREENSHOT_PATH ""
check_variables PW_RESHADE_SCREENSHOT_KEY "44,0,0,0"
check_variables PW_RESHADE_OVERLAY_KEY "36,0,0,0"
check_variables PW_USE_ESYNC "0"
check_variables PW_USE_FSYNC "0"
check_variables PW_USE_NTSYNC "0"
check_variables PW_USE_GAMEMODE "0"
check_variables PW_USE_INHIBIT_SLEEP "1"
check_variables PW_USE_TERMINAL "0"
check_variables PW_WINDOWS_VER "11"
check_variables PW_WINDOWS_VER "10"
check_variables PW_USE_GSTREAMER "1"
check_variables PW_USE_D3D_EXTRAS "1"
check_variables PW_PREFIX_NAME "DEFAULT"
@@ -71,7 +62,7 @@ check_variables PW_USE_GALLIUM_NINE "0"
check_variables PW_USE_GALLIUM_ZINK "0"
check_variables PW_USE_WINED3D_VULKAN "0"
check_variables PW_USE_SUPPLIED_DXVK_VKD3D "1"
check_variables PW_WINE_FULLSCREEN_FSR "0"
check_variables PW_WINE_FULLSCREEN_FSR "1"
check_variables WINE_FULLSCREEN_FSR_MODE "0"
###DXVK_AND_VKD3D###
-228
View File
@@ -1,228 +0,0 @@
#include <SDL3/SDL.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#define AXIS_ACTIVATION_THRESHOLD 16000
typedef struct {
SDL_Gamepad *gamepad;
SDL_JoystickID instance_id;
int sdl_type;
bool buttons[SDL_GAMEPAD_BUTTON_COUNT];
bool axes_active[SDL_GAMEPAD_AXIS_COUNT];
} GamepadEntry;
typedef struct {
GamepadEntry *entries;
int count;
SDL_JoystickID active_instance_id;
} PortProtonGamepad;
static bool has_gamepad(const PortProtonGamepad *gamepads,
SDL_JoystickID instance_id)
{
for (int index = 0; index < gamepads->count; index++) {
if (gamepads->entries[index].instance_id == instance_id) {
return true;
}
}
return false;
}
static bool add_gamepad(PortProtonGamepad *gamepads,
SDL_JoystickID instance_id)
{
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
if (gamepad == NULL) {
return false;
}
GamepadEntry *entries = realloc(
gamepads->entries, sizeof(*entries) * (gamepads->count + 1));
if (entries == NULL) {
SDL_CloseGamepad(gamepad);
return SDL_SetError("Failed to allocate gamepad handle");
}
gamepads->entries = entries;
GamepadEntry entry = {0};
entry.gamepad = gamepad;
entry.instance_id = instance_id;
entry.sdl_type = SDL_GetGamepadTypeForID(instance_id);
entries[gamepads->count] = entry;
gamepads->count++;
return true;
}
static void remove_disconnected(PortProtonGamepad *gamepads)
{
int active_count = 0;
for (int index = 0; index < gamepads->count; index++) {
GamepadEntry entry = gamepads->entries[index];
if (SDL_GamepadConnected(entry.gamepad)) {
gamepads->entries[active_count++] = entry;
} else {
if (gamepads->active_instance_id == entry.instance_id) {
gamepads->active_instance_id = 0;
}
SDL_CloseGamepad(entry.gamepad);
}
}
gamepads->count = active_count;
}
static void update_gamepads(PortProtonGamepad *gamepads)
{
SDL_PumpEvents();
SDL_UpdateGamepads();
remove_disconnected(gamepads);
int count = 0;
SDL_JoystickID *instance_ids = SDL_GetGamepads(&count);
if (instance_ids == NULL) {
return;
}
for (int index = 0; index < count; index++) {
if (!has_gamepad(gamepads, instance_ids[index])) {
add_gamepad(gamepads, instance_ids[index]);
}
}
SDL_free(instance_ids);
}
static bool update_entry_activity(GamepadEntry *entry)
{
bool activated = false;
for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; button++) {
bool pressed = SDL_GetGamepadButton(entry->gamepad, button);
activated |= pressed && !entry->buttons[button];
entry->buttons[button] = pressed;
}
for (int axis = 0; axis < SDL_GAMEPAD_AXIS_COUNT; axis++) {
int value = SDL_GetGamepadAxis(entry->gamepad, axis);
bool active = abs(value) >= AXIS_ACTIVATION_THRESHOLD;
activated |= active && !entry->axes_active[axis];
entry->axes_active[axis] = active;
}
return activated;
}
static const GamepadEntry *get_active_gamepad(const PortProtonGamepad *gamepads)
{
if (gamepads == NULL) {
return NULL;
}
for (int index = 0; index < gamepads->count; index++) {
if (gamepads->entries[index].instance_id == gamepads->active_instance_id) {
return &gamepads->entries[index];
}
}
return NULL;
}
PortProtonGamepad *portproton_gamepad_find(void)
{
SDL_ClearError();
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
return NULL;
}
PortProtonGamepad *result = calloc(1, sizeof(*result));
if (result == NULL) {
SDL_SetError("Failed to allocate gamepad collection");
return NULL;
}
update_gamepads(result);
if (result->count == 0) {
free(result);
SDL_ClearError();
return NULL;
}
return result;
}
const char *portproton_gamepad_get_error(void)
{
const char *error = SDL_GetError();
return error != NULL ? error : "";
}
void portproton_gamepad_close(PortProtonGamepad *gamepad)
{
if (gamepad == NULL) {
return;
}
for (int index = 0; index < gamepad->count; index++) {
SDL_CloseGamepad(gamepad->entries[index].gamepad);
}
free(gamepad->entries);
free(gamepad);
}
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
{
return gamepad != NULL && gamepad->count > 0;
}
void portproton_gamepad_update(PortProtonGamepad *gamepad)
{
if (gamepad != NULL) {
update_gamepads(gamepad);
for (int index = 0; index < gamepad->count; index++) {
if (update_entry_activity(&gamepad->entries[index])) {
gamepad->active_instance_id = gamepad->entries[index].instance_id;
}
}
}
}
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
{
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active == NULL) {
return 0;
}
return SDL_GetGamepadButton(active->gamepad, button);
}
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
{
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active == NULL) {
return 0;
}
return SDL_GetGamepadAxis(active->gamepad, axis);
}
uint32_t portproton_gamepad_get_active_instance_id(
const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->active_instance_id : 0;
}
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
{
if (gamepad == NULL || gamepad->count == 0) {
return "";
}
const char *name = SDL_GetGamepadName(gamepad->entries[0].gamepad);
return name != NULL ? name : "";
}
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
{
return gamepad != NULL && gamepad->count > 0
? gamepad->entries[0].instance_id : 0;
}
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
{
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active != NULL) {
return active->sdl_type;
}
return gamepad != NULL && gamepad->count > 0
? gamepad->entries[0].sdl_type : 0;
}
void portproton_gamepad_shutdown(void)
{
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
}
File renamed without changes.
+2 -7
View File
@@ -1899,11 +1899,6 @@
"status": "Broken",
"slug": "tekken-7-for-esl"
},
{
"normalized_name": "rocket league",
"status": "Supported",
"slug": "rocket-league"
},
{
"normalized_name": "rocket league (faceit)",
"status": "Broken",
@@ -3346,7 +3341,7 @@
},
{
"normalized_name": "the crew motorfest",
"status": "Supported",
"status": "Broken",
"slug": "the-crew-motorfest"
},
{
@@ -3781,7 +3776,7 @@
},
{
"normalized_name": "exoborne",
"status": "Denied",
"status": "Broken",
"slug": "exoborne"
},
{
Binary file not shown.
+2690 -67506
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
+17 -1007
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
-24
View File
@@ -1,27 +1,3 @@
portprotonqt (1.5.0) unstable; urgency=medium
* New upstream release.
-- Ivan Mazhukin <vanyamajukin@outlook.com> Wed, 14 Oct 2026 21:30:22 +0500
portprotonqt (1.4.1) unstable; urgency=medium
* New upstream release.
-- Ivan Mazhukin <vanyamajukin@outlook.com> Wed, 26 Aug 2026 13:03:46 +0500
portprotonqt (1.4.0) unstable; urgency=medium
* New upstream release.
-- Ivan Mazhukin <vanyamajukin@outlook.com> Sun, 23 Aug 2026 14:32:36 +0500
portprotonqt (1.3.2) unstable; urgency=medium
* New upstream release.
-- Ivan Mazhukin <vanyamajukin@outlook.com> Mon, 10 Aug 2026 22:02:11 +0500
portprotonqt (1.3.1) unstable; urgency=medium
* New upstream release.
-1
View File
@@ -5,7 +5,6 @@ Maintainer: Ivan Mazhukin <vanyamajukin@outlook.com>
Build-Depends:
debhelper-compat (= 13),
gettext,
libsdl3-dev,
libvulkan-dev,
meson,
ninja-build,
-12
View File
@@ -19,18 +19,6 @@ for file in "$@"; do
echo "$file: bash -n syntax check failed"
status=1
fi
# Bash 5.1 requires a separator before then after a conditional command.
if ! awk '
/\]\][ \t]+then([ \t;]|$)/ || (continued && /^[ \t]*then([ \t;]|$)/) {
printf "%s:%d: Bash 5.1 requires a newline or semicolon before then\n", FILENAME, NR
failed = 1
}
{ continued = /\]\][ \t]*\\$/ }
END { exit failed }
' "$file"; then
status=1
fi
done
exit "$status"
+19 -26
View File
@@ -1,65 +1,58 @@
#!/bin/bash
set -euo pipefail
GITHUB_REPOS=("linux-gaming-ru/PortProtonQt")
GITHUB_REPO="Boria138/PortProtonQt"
GITEA_REPO="Linux-Gaming/PortProtonQt"
GITEA_URL="https://git.linux-gaming.ru"
jq_format='def format_downloads: if . < 1000 then tostring else ((. / 100 | round) / 10 | tostring) + "k" end;'
jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | .download_count] | add // 0'
echo "=== PortProtonQt Download Stats ==="
echo
echo "--- GitHub (${GITHUB_REPOS[*]}) ---"
GH_RAW=$(for repo in "${GITHUB_REPOS[@]}"; do
curl -sfL "https://api.github.com/repos/${repo}/releases?per_page=100"
done | jq -s 'add')
echo "--- GitHub (${GITHUB_REPO}) ---"
GH_RAW=$(curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=100")
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
echo "Releases: $GH_RELEASES"
GH_DISPLAY=$(jq -nr --argjson count "$GH_COUNT" "$jq_format"'$count | format_downloads')
echo "Total downloads: $GH_DISPLAY"
echo "Total downloads: $GH_COUNT"
echo
echo "Per release:"
echo "$GH_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
echo "$GH_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
echo
echo "--- Gitea (${GITEA_REPO}) ---"
GIT_RAW=$(curl -sfL "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
GIT_RAW=$(curl -sf "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
GIT_COUNT=$(echo "$GIT_RAW" | jq "$jq_total")
GIT_RELEASES=$(echo "$GIT_RAW" | jq 'length')
echo "Releases: $GIT_RELEASES"
GIT_DISPLAY=$(jq -nr --argjson count "$GIT_COUNT" "$jq_format"'$count | format_downloads')
echo "Total downloads: $GIT_DISPLAY"
echo "Total downloads: $GIT_COUNT"
echo
echo "Per release:"
echo "$GIT_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
echo "$GIT_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
echo
TOTAL=$((GH_COUNT + GIT_COUNT))
BADGE_TOTAL=$(jq -nr --argjson total "$TOTAL" "$jq_format"'$total | format_downloads')
echo "========================"
echo "GitHub total: $GH_DISPLAY"
echo "Gitea total: $GIT_DISPLAY"
echo "Grand total: $BADGE_TOTAL"
echo "GitHub total: $GH_COUNT"
echo "Gitea total: $GIT_COUNT"
echo "Grand total: $TOTAL"
echo
echo "--- Top downloads (by file) ---"
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total | format_downloads)\t\(.name)"'
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total)\t\(.name)"'
echo "GitHub:"
echo "$GH_RAW" | jq -r "$jq_format $jq_filter_top"
echo "$GH_RAW" | jq -r "$jq_filter_top"
echo
echo "Gitea:"
echo "$GIT_RAW" | jq -r "$jq_format $jq_filter_top"
echo "$GIT_RAW" | jq -r "$jq_filter_top"
echo
echo "--- Top downloads (by type) ---"
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total | format_downloads)"'
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total)"'
echo "GitHub:"
echo "$GH_RAW" | jq -r "$jq_format $jq_type"
echo "$GH_RAW" | jq -r "$jq_type"
echo
echo "Gitea:"
echo "$GIT_RAW" | jq -r "$jq_format $jq_type"
echo "$GIT_RAW" | jq -r "$jq_type"
if [[ "${CI:-}" != "true" ]]; then
echo
@@ -67,9 +60,9 @@ if [[ "${CI:-}" != "true" ]]; then
exit 0
fi
sed -i -E "s|-[0-9]+(\.[0-9]+)?k?-green\?style=flat-square|-$BADGE_TOTAL-green?style=flat-square|g" README.md README.ru.md
sed -i "s|-[0-9]*-green?style=flat-square|-$TOTAL-green?style=flat-square|g" README.md README.ru.md
echo
echo "Badge updated: $BADGE_TOTAL"
echo "Badge updated: $TOTAL"
git diff --quiet README.md README.ru.md && { echo "No changes to commit"; exit 0; }
+6 -6
View File
@@ -17,13 +17,13 @@ if ! command -v uv &> /dev/null; then
exit 1
fi
# 2. Check if Python 3.11 is already installed via uv
# We look for cpython-3.11 and ensure it doesn't say "<download available>"
if uv python list | grep "cpython-3.11" | grep -qv "<download available>"; then
echo "✅ Python 3.11 is already available."
# 2. Check if Python 3.10 is already installed via uv
# We look for cpython-3.10 and ensure it doesn't say "<download available>"
if uv python list | grep "cpython-3.10" | grep -qv "<download available>"; then
echo "✅ Python 3.10 is already available."
else
echo "🐍 Installing Python 3.11..."
uv python install 3.11
echo "🐍 Installing Python 3.10..."
uv python install 3.10
fi
# 3. Sync dependencies
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Run PortProtonQt against a fake AppImage update server."""
import argparse
import http.server
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
APPIMAGE_MAGIC_OFFSET = 8
APPIMAGE_TYPE_2_MAGIC = b"AI\x02"
TEST_APPIMAGE_SIZE_BYTES = 128 * 1024 * 1024
HTTP_COPY_BUFFER_SIZE = 1024 * 1024
OLD_APPIMAGE_FILLER = b"OLD-APPIMAGE-BLOCK\n"
UPDATED_APPIMAGE_FILLER = b"UPDATED-APPIMAGE-BLOCK\n"
class UpdateHandler(http.server.BaseHTTPRequestHandler):
"""Serve fake update metadata and the new AppImage."""
test_dir = Path()
server_url = ""
old_version = "0.1.12"
new_version = "1.2.0"
new_appimage_name = "PortProtonQt-1.2.0-x86_64.AppImage"
def log_message(self, fmt, *args):
print(f"[server] {fmt % args}")
def do_GET(self):
if self.path.endswith(".zsync"):
self._send_zsync()
elif self.path.startswith("/download/"):
self._send_download()
else:
self.send_error(404)
def _send_download(self) -> None:
filename = self.path.split("/download/", 1)[1]
path = self.test_dir / filename
if not path.exists():
self.send_error(404, f"Not found: {filename}")
return
self._send_file(path)
def _send_zsync(self) -> None:
filename = self.path.rsplit("/", 1)[-1]
path = self.test_dir / filename
if not path.exists():
self.send_error(404, f"Not found: {filename}")
return
self._send_file(path)
def _send_file(self, path: Path) -> None:
file_size = path.stat().st_size
byte_range = self._parse_range(file_size)
start = 0
end = file_size - 1
if byte_range is not None:
start, end = byte_range
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
else:
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(end - start + 1))
self.end_headers()
with open(path, "rb") as appimage_file:
appimage_file.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = appimage_file.read(min(HTTP_COPY_BUFFER_SIZE, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def _parse_range(self, file_size: int) -> tuple[int, int] | None:
range_header = self.headers.get("Range", "")
if not range_header.startswith("bytes="):
return None
range_spec = range_header.removeprefix("bytes=").split(",", 1)[0].strip()
start_text, separator, end_text = range_spec.partition("-")
if not separator:
return None
try:
if not start_text:
length = int(end_text)
return max(file_size - length, 0), file_size - 1
start = int(start_text)
end = int(end_text) if end_text else file_size - 1
except ValueError:
return None
if start < 0 or end < start or start >= file_size:
return None
return start, min(end, file_size - 1)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("old_version", nargs="?", default="0.1.12")
parser.add_argument("new_version", nargs="?", default="1.2.0")
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--keep", action="store_true")
parser.add_argument("--version", default=None,
help="Simulate current app version (e.g. --version 0.1.8)")
return parser.parse_args()
def write_appimage_base(path: Path) -> None:
true_path = shutil.which("true") or "/usr/bin/true"
shutil.copyfile(true_path, path)
path.chmod(0o755)
def update_info(server_url: str, new_appimage_name: str) -> str:
return f"zsync|{server_url}/{new_appimage_name}.zsync"
def finalize_appimage(path: Path, test_dir: Path, update_info_text: str, filler: bytes) -> None:
update_info_file = test_dir / f"{path.name}.upd_info"
update_info_file.write_text(update_info_text, encoding="utf-8")
subprocess.run(
["objcopy", "--add-section", f".upd_info={update_info_file}", str(path)],
check=True,
)
with open(path, "r+b") as appimage_file:
appimage_file.seek(APPIMAGE_MAGIC_OFFSET)
appimage_file.write(APPIMAGE_TYPE_2_MAGIC)
appimage_file.seek(0, os.SEEK_END)
while appimage_file.tell() < TEST_APPIMAGE_SIZE_BYTES:
appimage_file.write(
filler[:TEST_APPIMAGE_SIZE_BYTES - appimage_file.tell()]
)
path.chmod(0o755)
def write_zsync(test_dir: Path, server_url: str, new_appimage_name: str) -> None:
new_appimage = test_dir / new_appimage_name
zsync_file = test_dir / f"{new_appimage_name}.zsync"
download_url = f"{server_url}/download/{new_appimage_name}"
subprocess.run(
[
"zsyncmake",
"-e",
"-u",
download_url,
"-o",
str(zsync_file),
"-f",
new_appimage_name,
str(new_appimage),
],
check=True,
)
def start_server(args: argparse.Namespace, test_dir: Path, new_appimage_name: str):
server = http.server.HTTPServer(("127.0.0.1", args.port), UpdateHandler)
server_url = f"http://127.0.0.1:{server.server_port}"
UpdateHandler.test_dir = test_dir
UpdateHandler.server_url = server_url
UpdateHandler.old_version = args.old_version
UpdateHandler.new_version = args.new_version
UpdateHandler.new_appimage_name = new_appimage_name
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, server_url
def python_command() -> str:
venv_python = REPO_ROOT / ".venv" / "bin" / "python"
if venv_python.exists():
return str(venv_python)
return sys.executable
def run_gui(env: dict[str, str]) -> int:
proc = subprocess.Popen(
[python_command(), "-m", "portprotonqt.app"],
cwd=str(REPO_ROOT),
env=env,
)
return proc.wait()
def main() -> None:
args = parse_args()
if args.version:
args.old_version = args.version
test_dir = Path(tempfile.mkdtemp(prefix="ppqt-appimage-update-"))
old_appimage = test_dir / f"PortProtonQt-{args.old_version}-x86_64.AppImage"
new_appimage_name = f"PortProtonQt-{args.new_version}-x86_64.AppImage"
new_appimage = test_dir / new_appimage_name
server = None
try:
write_appimage_base(old_appimage)
write_appimage_base(new_appimage)
server, server_url = start_server(args, test_dir, new_appimage_name)
update_info_text = update_info(server_url, new_appimage_name)
finalize_appimage(
old_appimage,
test_dir,
update_info_text,
OLD_APPIMAGE_FILLER,
)
finalize_appimage(
new_appimage,
test_dir,
update_info_text,
UPDATED_APPIMAGE_FILLER,
)
write_zsync(test_dir, server_url, new_appimage_name)
env = os.environ.copy()
env["APPIMAGE"] = str(old_appimage)
env["PORTPROTONQT_VERSION"] = args.old_version
print("=== AppImage update GUI test ===")
print(f"Test dir : {test_dir}")
print(f"Old AppImage : {old_appimage}")
print(f"New AppImage : {new_appimage}")
print(f"AppImage size : {TEST_APPIMAGE_SIZE_BYTES // 1024 // 1024} MiB")
print(f"HTTP server : {server_url}")
print(f"Update info : {update_info_text}")
print("")
print("In GUI: wait for the update dialog, press Update, watch progress.")
print("After success, old AppImage should be replaced by the new file.")
print("")
raise SystemExit(run_gui(env))
finally:
if server is not None:
server.shutdown()
if args.keep:
print(f"Keeping test dir: {test_dir}")
else:
shutil.rmtree(test_dir, ignore_errors=True)
if __name__ == "__main__":
main()
File renamed without changes.
+2 -2
View File
@@ -44,7 +44,7 @@ Translations submitted online are periodically reviewed and merged into the main
1. Run:
```bash
uv python install 3.11
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --create-new <locale_code>
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
If you’ve added new strings to the code:
```bash
uv python install 3.11
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --update-all
@@ -44,7 +44,7 @@
1. Выполните:
```bash
uv python install 3.11
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --create-new <код_локали>
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
Если вы добавили новые строки в код:
```bash
uv python install 3.11
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --update-all
+2 -123
View File
@@ -14,7 +14,6 @@
- [Detail Page Layout Mode](#detail-page-layout-mode)
- [Detail Page Background Mode](#detail-page-background-mode)
- [Background Configuration](#background-configuration)
- [Library Background](#library-background)
- [Preloader](#preloader)
- [Source Corner (Ribbon)](#source-corner-ribbon)
- [Terminal Color Schemes](#terminal-color-schemes)
@@ -235,36 +234,14 @@ NAV_BUTTON_STYLE = f"""
You can control the game library card layout directly from the theme via `styles.py`:
```python
# "grid", "list", "vertical", "horizontal", or "horizontal_top"
# "grid" (default) or "list"
LIBRARY_LAYOUT_MODE = "grid"
```
- `grid`: multi-column card grid (classic behavior).
- `list`: horizontal row-style cards (launcher-style list).
- `vertical`: a full-width table with game title, last launch, time spent, and
library columns.
- `horizontal`: one horizontally scrolling row containing every game.
- `horizontal_top`: the first `horizontalTopGameLimit` games from the current
filter and sort order, followed by a tile composed from the next games that
opens the complete grid.
The `horizontal_top` preview tile is configured with theme-level constants:
```python
horizontalTopGameLimit = 10 # Games shown before the tile.
fullLibraryTileGameCount = 4 # Cover images shown in the tile.
fullLibraryTileSize = (180, 180) # Tile width and height.
fullLibraryTileColumns = 2 # Cover columns inside the tile.
fullLibraryTileRows = 2 # Cover rows inside the tile.
fullLibraryTileRadius = 10 # Tile corner radius in pixels.
```
`fullLibraryTileColumns * fullLibraryTileRows` should be at least
`fullLibraryTileGameCount`. The tile opens the complete library grid when
activated.
Card geometry is controlled by `GAME_CARD_LIST`, `GAME_CARD_GRID`, and
`GAME_CARD_HORIZONTAL`:
Card geometry is controlled by `GAME_CARD_LIST` and `GAME_CARD_GRID`:
```python
GAME_CARD_LIST = {
@@ -281,7 +258,6 @@ GAME_CARD_LIST = {
}
GAME_CARD_GRID = {
"card_animation_type": "gradient",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 1.5,
@@ -289,48 +265,12 @@ GAME_CARD_GRID = {
"cover_radius": 15,
"border_radius": 18,
}
GAME_CARD_HORIZONTAL = {
"card_orientation": "vertical",
"card_animation_type": "scale",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 0.56,
"card_height_ratio": 0.78,
"cover_radius": 15,
"layout_margins": (20, 20, 20, 20),
"layout_spacing": 20,
}
```
Set `card_orientation` to `"vertical"` to reuse the grid card proportions in
the horizontal strip. Omit it to keep the horizontal block proportions.
Values are logical pixels. `cover_radius` applies to static and animated covers;
`border_radius` applies to the painted focus and hover border. Grid cover width
continues to follow the library card-size setting.
Card animation keys can be overridden independently in `GAME_CARD_LIST`,
`GAME_CARD_GRID`, or `GAME_CARD_HORIZONTAL`: `card_animation_type`, border
widths, pulse settings, gradient angles and colors, fill/stripe/glow colors and
opacity, scale values, animation durations, and easing curves. For example,
`horizontal_top` can define its own `scale` type, scale values, duration, and
easing while the complete grid uses separately configured `gradient` values.
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback;
`card_animation_type` defaults to `gradient`.
```python
GAME_CARD_HORIZONTAL = {
"card_animation_type": "scale",
"default_scale": 1.0,
"hover_scale": 1.055,
"focus_scale": 1.075,
"scale_anim_duration": 135,
"scale_easing_curve": "OutCubic",
"scale_easing_curve_out": "InOutCubic",
}
```
Card shadows use the shared `shadow_blur_radius` and `shadow_offset` theme
constants in both grid and list layouts.
@@ -512,53 +452,6 @@ Existing themes may continue using `DETAIL_PAGE_GRADIENT`,
`DETAIL_PAGE_GRADIENT_FY`, and `DETAIL_PAGE_WAVES`. Legacy values override the
corresponding nested settings.
## Library Background
The library can use the cover of the currently hovered or focused card as its
background. This feature is opt-in: if `LIBRARY_BACKGROUND` is not defined, the
library keeps the theme's regular background unchanged.
`LIBRARY_BACKGROUND` selects the effect and can override detail-page
background settings locally. Its `backgrounds` dictionary is merged with
`DETAIL_PAGE_BACKGROUNDS`, so only the values that should differ need to be
specified:
```python
LIBRARY_BACKGROUND = {
"margins": (0, 0, 0, 0),
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
"backgrounds": {
"animation_interval_ms": 30,
"animation_speed": 0.03,
"blur": {
"render_resolution": (1280, 720),
"radius": 64,
},
"gradient": {
"stops": None,
"type": "linear",
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
},
"waves": {
"layer_count": 4,
"wave_amplitude_ratio": 0.06,
"wave_frequency": 2.0,
"layer_spacing_ratio": 0.04,
"base_opacity": 0.45,
"opacity_decay": 0.85,
"animation_speed": 0.03,
"animation_interval_ms": 30,
},
},
}
```
The `blur` settings control the intermediate image size and blur radius.
`margins` are applied to the background layer layout. Animated modes use the
same effect-specific dictionaries documented in `DETAIL_PAGE_BACKGROUNDS`;
copy the corresponding dictionary there when overriding `aurora`, `leaf`,
`metaballs`, `veins`, or `diagnostics`.
---
The `PRELOADER` dictionary controls the loading indicator style and animation. If not defined, the default spinner is used.
@@ -958,20 +851,6 @@ When no `ICON_COLORS` entry matches an `AutoSizeButton` state, the button falls
---
## Boot animation (optional)
Place `bootanimation.webm` beside the theme's `styles.py`; missing videos are
inherited from parent themes. It plays with audio inside the main window only
in fullscreen, including the automatic transition when a gamepad is connected.
Minimized and windowed launches skip it, including windowed ppqtos.
The “Disable Boot Animation” checkbox in settings disables playback.
A key press, mouse click, or gamepad button skips playback. `bootAnimationTimeoutMs` defaults to
30000; `color_boot_animation` sets the background and letterbox color.
Video specifications: WebM container, VP9 video, optional Opus audio;
1920×1080 or 1280×800, 30 or 60 FPS. Recommended duration: 3–5 seconds;
the default playback timeout is 30 seconds. Aspect ratio is preserved.
## Sound Effects (optional)
Place UI sound effects in the theme's `sounds/` directory. File names must match an event name; do not add numeric suffixes such as `_001`:
+2 -126
View File
@@ -14,7 +14,6 @@
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
- [Настройка фона](#настройка-фона)
- [Фон библиотеки](#фон-библиотеки)
- [Прелоадер](#прелоадер)
- [Уголок источника (Лента)](#уголок-источника-лента)
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
@@ -235,36 +234,14 @@ NAV_BUTTON_STYLE = f"""
Вы можете управлять типом отображения карточек библиотеки прямо из темы через `styles.py`:
```python
# "grid", "list", "vertical", "horizontal" или "horizontal_top"
# "grid" (по умолчанию) или "list"
LIBRARY_LAYOUT_MODE = "grid"
```
- `grid`: многоколоночная сетка карточек (классическое поведение).
- `list`: горизонтальные карточки-строки (формат лаунчера).
- `vertical`: полноширинная таблица с названием игры, последним запуском,
временем в игре и библиотекой.
- `horizontal`: одна горизонтально прокручиваемая лента со всеми играми,
- `horizontal_top`: первые `horizontalTopGameLimit` игр из текущих результатов
фильтрации и сортировки, затем плитка из следующих игр для открытия полной
сетки.
Плитка перехода в режиме `horizontal_top` настраивается константами темы:
```python
horizontalTopGameLimit = 10 # Игр показывается до плитки.
fullLibraryTileGameCount = 4 # Обложек показывается в плитке.
fullLibraryTileSize = (180, 180) # Ширина и высота плитки.
fullLibraryTileColumns = 2 # Столбцов обложек внутри плитки.
fullLibraryTileRows = 2 # Строк обложек внутри плитки.
fullLibraryTileRadius = 10 # Радиус скругления плитки в пикселях.
```
Произведение `fullLibraryTileColumns * fullLibraryTileRows` должно быть не
меньше `fullLibraryTileGameCount`. При активации плитка открывает полную сетку
библиотеки.
Геометрия карточек управляется через `GAME_CARD_LIST`, `GAME_CARD_GRID` и
`GAME_CARD_HORIZONTAL`:
Геометрия карточек управляется через `GAME_CARD_LIST` и `GAME_CARD_GRID`:
```python
GAME_CARD_LIST = {
@@ -281,7 +258,6 @@ GAME_CARD_LIST = {
}
GAME_CARD_GRID = {
"card_animation_type": "gradient",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 1.5,
@@ -289,50 +265,13 @@ GAME_CARD_GRID = {
"cover_radius": 15,
"border_radius": 18,
}
GAME_CARD_HORIZONTAL = {
"card_orientation": "vertical",
"card_animation_type": "scale",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 0.56,
"card_height_ratio": 0.78,
"cover_radius": 15,
"layout_margins": (20, 20, 20, 20),
"layout_spacing": 20,
}
```
Укажите `card_orientation = "vertical"`, чтобы использовать в горизонтальной
ленте вертикальные пропорции карточек из сетки. Без параметра сохраняются
пропорции горизонтальных блоков.
Значения задаются в логических пикселях. `cover_radius` применяется к
статическим и анимированным обложкам, а `border_radius` — к отрисовываемой
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
определяется настройкой размера карточек библиотеки.
Параметры анимации карточек можно отдельно переопределить в
`GAME_CARD_LIST`, `GAME_CARD_GRID` или `GAME_CARD_HORIZONTAL`: тип анимации,
ширину обводки, pulse, углы и цвета градиента, цвета и прозрачность
fill/stripe/glow, масштаб, длительности и easing-кривые. Например,
`horizontal_top` может задавать собственные тип `scale`, значения масштаба,
длительность и easing, а полная сетка — отдельные параметры `gradient`.
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`, а
`card_animation_type` по умолчанию использует `gradient`.
```python
GAME_CARD_HORIZONTAL = {
"card_animation_type": "scale",
"default_scale": 1.0,
"hover_scale": 1.055,
"focus_scale": 1.075,
"scale_anim_duration": 135,
"scale_easing_curve": "OutCubic",
"scale_easing_curve_out": "InOutCubic",
}
```
Тень карточек использует общие константы темы `shadow_blur_radius` и
`shadow_offset` как в сеточном, так и в списочном режиме.
@@ -513,54 +452,6 @@ DETAIL_PAGE_BACKGROUNDS = {
`DETAIL_PAGE_GRADIENT_FY` и `DETAIL_PAGE_WAVES`. Старые значения
переопределяют соответствующие вложенные настройки.
## Фон библиотеки
Библиотека может использовать обложку карточки, на которой находится курсор
или фокус, как фон. Функция включается только при наличии
`LIBRARY_BACKGROUND`; если словарь не определён, обычный фон темы библиотеки
остаётся без изменений.
`LIBRARY_BACKGROUND` выбирает эффект и может локально переопределять настройки
фона детальной страницы. Словарь `backgrounds` объединяется с
`DETAIL_PAGE_BACKGROUNDS`, поэтому достаточно указать только отличающиеся
значения:
```python
LIBRARY_BACKGROUND = {
"margins": (0, 0, 0, 0),
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
"backgrounds": {
"animation_interval_ms": 30,
"animation_speed": 0.03,
"blur": {
"render_resolution": (1280, 720),
"radius": 64,
},
"gradient": {
"stops": None,
"type": "linear",
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
},
"waves": {
"layer_count": 4,
"wave_amplitude_ratio": 0.06,
"wave_frequency": 2.0,
"layer_spacing_ratio": 0.04,
"base_opacity": 0.45,
"opacity_decay": 0.85,
"animation_speed": 0.03,
"animation_interval_ms": 30,
},
},
}
```
Настройки `blur` задают размер промежуточного изображения и радиус размытия.
`margins` применяются к слою фона. Анимированные режимы используют те же
словаря эффектов, что и `DETAIL_PAGE_BACKGROUNDS`; для переопределения
`aurora`, `leaf`, `metaballs`, `veins` или `diagnostics` скопируйте нужный
словарь из описания выше.
---
Словарь `PRELOADER` управляет стилем и анимацией индикатора загрузки. Если не определён, используется стандартный спиннер.
@@ -960,21 +851,6 @@ ICON_COLORS = {
---
## Загрузочная анимация (опционально)
Положите `bootanimation.webm` рядом со `styles.py` темы. При отсутствии файла
используется видео родительской темы. Ролик со звуком показывается внутри
основного окна только в fullscreen, включая автоматический переход при
подключении геймпада. Оконный и свёрнутый запуск пропускают видео, в том числе
оконный ppqtos. Клавиша, щелчок мыши или кнопка геймпада пропускают ролик.
`bootAnimationTimeoutMs` по умолчанию равен 30000; `color_boot_animation`
задаёт цвет фона и свободных краёв.
Галочка «Disable Boot Animation» в настройках отключает воспроизведение.
Требования к видео: контейнер WebM, видеокодек VP9, звук Opus при необходимости;
1920×1080 или 1280×800, 30 или 60 FPS. Рекомендуемая длительность — 3–5 секунд;
по умолчанию воспроизведение ограничено 30 секундами. Пропорции сохраняются.
## Звуковые эффекты (опционально)
Поместите звуковые эффекты интерфейса в директорию `sounds/` темы. Имя файла должно совпадать с именем события; не добавляйте числовые суффиксы наподобие `_001`:
-59
View File
@@ -1,59 +0,0 @@
#!/bin/sh
set -eu
REPOSITORY="Linux-Gaming/PortProtonQt"
SERVER="https://git.linux-gaming.ru"
GITHUB_REPOSITORY="linux-gaming-ru/PortProtonQt"
command -v curl >/dev/null 2>&1 || {
echo "PortProtonQt installer requires curl." >&2
exit 1
}
command -v mktemp >/dev/null 2>&1 || {
echo "PortProtonQt installer requires mktemp." >&2
exit 1
}
ARCH=$(uname -m)
case "$ARCH" in
amd64) ARCH="x86_64" ;;
arm64) ARCH="aarch64" ;;
esac
GITEA_API="${SERVER}/api/v1/repos/${REPOSITORY}/releases/latest"
GITHUB_API="https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest"
APPIMAGE_PATH=$(mktemp "${TMPDIR:-/tmp}/portprotonqt.XXXXXX.AppImage")
DOWNLOADED=false
trap 'rm -f "$APPIMAGE_PATH"' EXIT HUP INT TERM
for RELEASE_API in "$GITEA_API" "$GITHUB_API"; do
APPIMAGE_URL=$(curl -fsSL "$RELEASE_API" |
tr ',' '\n' |
sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\.AppImage\)".*/\1/p' |
grep -- "-${ARCH}\.AppImage$" |
head -n 1 || true)
if [ -z "$APPIMAGE_URL" ]; then
continue
fi
echo "Downloading PortProtonQt AppImage from ${APPIMAGE_URL}..."
if curl -fL --progress-bar "$APPIMAGE_URL" -o "$APPIMAGE_PATH"; then
DOWNLOADED=true
break
fi
done
if [ "$DOWNLOADED" != true ]; then
echo "Failed to download PortProtonQt AppImage for ${ARCH}." >&2
exit 1
fi
chmod 755 "$APPIMAGE_PATH"
if ! PORTPROTONQT_INTEGRATE_APPIMAGE=1 "$APPIMAGE_PATH"; then
echo "Failed to integrate PortProtonQt AppImage." >&2
exit 1
fi
echo "PortProtonQt was installed and integrated."
+22 -8
View File
@@ -1,5 +1,5 @@
project('portprotonqt', 'c',
version: '1.5.0',
version: '1.3.1',
meson_version: '>= 0.61.2',
license: 'GPL-3.0',
)
@@ -13,15 +13,15 @@ conf = configuration_data()
conf.set('PYTHON', python3.full_path())
version_conf = configuration_data()
version_conf.set('APP_VERSION', meson.project_version())
version_conf.set('APP_COMMIT', '')
version_conf.set_quoted('APP_VERSION', meson.project_version())
version_conf.set_quoted('APP_COMMIT', '')
if get_option('nightly')
git = find_program('git', required: false)
if git.found()
git_commit = run_command(git, 'rev-parse', '--short', 'HEAD', check: false)
if git_commit.returncode() == 0
version_conf.set('APP_COMMIT', git_commit.stdout().strip())
version_conf.set_quoted('APP_COMMIT', git_commit.stdout().strip())
endif
endif
endif
@@ -36,10 +36,19 @@ purelibdir_opt = get_option('python_purelibdir')
pythondir = purelibdir_opt != '' ? purelibdir_opt: python3.get_install_dir(pure: true)
pkgdatadir = pythondir / meson.project_name()
# Native C build dependencies and sources
# C compiler for vk_gpu_info
cc = meson.get_compiler('c')
sdl3_dep = dependency('sdl3', required: true)
gamepad_sources = files('build-aux/src/gamepad_backend.c')
configured_version = configure_file(
input: 'portprotonqt/_version.py.in',
output: '_version.py',
configuration: version_conf,
)
install_data(
configured_version,
install_dir: pythondir / meson.project_name(),
)
# Install Python package
subdir('portprotonqt')
@@ -58,7 +67,7 @@ install_data(configured_portprotonqt,
)
# Build and install vk_gpu_info executable
vk_gpu_info_sources = files('build-aux/src/vk_gpu_info.c')
vk_gpu_info_sources = files('build-aux/vk_gpu_info.c')
vk_gpu_info_deps = [dependency('vulkan', required: true)]
executable('vk_gpu_info',
@@ -111,6 +120,11 @@ install_subdir(
install_dir: datadir / 'portproton',
)
install_subdir(
'build-aux/share/portproton/img',
install_dir: datadir / 'portproton',
)
install_subdir(
'build-aux/share/portproton/scripts/portwine_db',
install_dir: datadir / 'portproton' / 'scripts',
+2
View File
@@ -0,0 +1,2 @@
APP_VERSION = @APP_VERSION@
APP_COMMIT = @APP_COMMIT@
+2 -11
View File
@@ -30,16 +30,7 @@ class DetailBackgroundAnimations:
if effect not in self.effects:
self.remove(page)
return False
config = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
animated = not mode.startswith("static_")
if not isinstance(config, dict) or not isinstance(config.get(effect), dict):
self.remove(page)
return False
if animated and not all(
key in config for key in ("animation_interval_ms", "animation_speed")
):
self.remove(page)
return False
config = theme.DETAIL_PAGE_BACKGROUNDS
self.remove(page)
state = {
"effect": effect,
@@ -56,7 +47,7 @@ class DetailBackgroundAnimations:
self._paint(page, state)
page.paintEvent = paint_event
if animated:
if not mode.startswith("static_"):
self._start_timer(page, state)
return True
+34 -80
View File
@@ -1,5 +1,4 @@
from math import radians, sin
from typing import Any
import warnings
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
@@ -23,22 +22,6 @@ class GameCardAnimations:
self.pulse_anim: QPropertyAnimation | None = None
self._isPulseAnimationConnected = False
def _animation_type(self) -> str:
layout_config = getattr(self.game_card, "card_layout_cfg", {})
return layout_config.get("card_animation_type", "gradient")
def _config_value(self, key: str) -> Any:
layout_config = getattr(self.game_card, "card_layout_cfg", {})
if key in layout_config:
return layout_config[key]
return self.theme.GAME_CARD_ANIMATION[key]
def _optional_config_value(self, key: str, default: Any) -> Any:
layout_config = getattr(self.game_card, "card_layout_cfg", {})
if key in layout_config:
return layout_config[key]
return self.theme.GAME_CARD_ANIMATION.get(key, default)
def _disconnect_pulse_animation(self) -> None:
if not self._isPulseAnimationConnected or self.thickness_anim is None:
return
@@ -66,20 +49,20 @@ class GameCardAnimations:
def _restart_gradient_animation(self) -> None:
self._stop_animation("gradient_anim")
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
self.gradient_anim.setStartValue(self._config_value("gradient_start_angle"))
self.gradient_anim.setEndValue(self._config_value("gradient_end_angle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
def _easing_curve(self, easing_key: str) -> QEasingCurve:
easing_type = QEasingCurve.Type[self._config_value(easing_key)]
easing_type = QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION[easing_key]]
return QEasingCurve(easing_type)
def _restart_scale_animation(self, end_value: float, easing_key: str) -> None:
self._stop_animation("scale_anim")
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(self._easing_curve(easing_key))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(end_value)
@@ -106,47 +89,18 @@ class GameCardAnimations:
self._stop_animation("pulse_anim")
self._isPulseAnimationConnected = False
def refresh_theme(self, theme: Any) -> None:
"""Recreate animation state for a live theme change."""
self.cleanup()
self.theme = theme
animation_type = self._animation_type()
self.game_card._gradientAngle = self._config_value("gradient_start_angle")
if self.game_card._hovered:
self.game_card._borderWidth = self._config_value("hover_border_width")
active_scale = self._config_value("hover_scale")
elif self.game_card._focused:
self.game_card._borderWidth = self._config_value("focus_border_width")
active_scale = self._config_value("focus_scale")
else:
self.game_card._borderWidth = self._config_value("default_border_width")
active_scale = self._config_value("default_scale")
self.game_card._scale = (
active_scale
if animation_type in {"scale", "scale_fill"}
else self._config_value("default_scale")
)
self.setup_animations()
if (self.game_card._hovered or self.game_card._focused) and animation_type in {
"gradient", "glow",
}:
self._restart_gradient_animation()
if self.game_card._hovered or self.game_card._focused:
self.start_pulse_animation()
self.game_card.update()
def setup_animations(self):
"""Initialize animation properties based on theme."""
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(self._config_value("thickness_anim_duration"))
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
animation_type = self._animation_type()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type in {"gradient", "glow"}:
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
elif animation_type in {"scale", "scale_fill"}:
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
def start_pulse_animation(self):
"""Start pulse animation for border width when hovered or focused."""
@@ -156,11 +110,11 @@ class GameCardAnimations:
self._stop_animation("pulse_anim")
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.pulse_anim.setDuration(self._config_value("pulse_anim_duration"))
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
self.pulse_anim.setLoopCount(0)
self.pulse_anim.setKeyValueAt(0, self._config_value("pulse_min_border_width"))
self.pulse_anim.setKeyValueAt(0.5, self._config_value("pulse_max_border_width"))
self.pulse_anim.setKeyValueAt(1, self._config_value("pulse_min_border_width"))
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.start()
def handle_enter_event(self):
@@ -172,10 +126,10 @@ class GameCardAnimations:
if not self.thickness_anim:
self.setup_animations()
animation_type = self._animation_type()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
self._start_thickness_animation(
self._config_value("hover_border_width"),
self.theme.GAME_CARD_ANIMATION["hover_border_width"],
"thickness_easing_curve",
True,
)
@@ -183,21 +137,21 @@ class GameCardAnimations:
if animation_type in {"gradient", "glow"}:
self._restart_gradient_animation()
elif animation_type in {"scale", "scale_fill"}:
self._restart_scale_animation(self._config_value("hover_scale"), "scale_easing_curve")
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["hover_scale"], "scale_easing_curve")
def handle_leave_event(self):
"""Handle mouse leave event animations."""
self.game_card._hovered = False
self.game_card.hoverChanged.emit(self.game_card.name, False)
if not self.game_card._focused:
animation_type = self._animation_type()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type in {"gradient", "glow"}:
self._stop_animation("gradient_anim")
elif animation_type in {"scale", "scale_fill"}:
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
self._stop_animation("pulse_anim")
self._start_thickness_animation(
self._config_value("default_border_width"),
self.theme.GAME_CARD_ANIMATION["default_border_width"],
"thickness_easing_curve_out",
False,
)
@@ -211,10 +165,10 @@ class GameCardAnimations:
if not self.thickness_anim:
self.setup_animations()
animation_type = self._animation_type()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
self._start_thickness_animation(
self._config_value("focus_border_width"),
self.theme.GAME_CARD_ANIMATION["focus_border_width"],
"thickness_easing_curve",
True,
)
@@ -222,21 +176,21 @@ class GameCardAnimations:
if animation_type in {"gradient", "glow"}:
self._restart_gradient_animation()
elif animation_type in {"scale", "scale_fill"}:
self._restart_scale_animation(self._config_value("focus_scale"), "scale_easing_curve")
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["focus_scale"], "scale_easing_curve")
def handle_focus_out_event(self):
"""Handle focus out event animations."""
self.game_card._focused = False
self.game_card.focusChanged.emit(self.game_card.name, False)
if not self.game_card._hovered:
animation_type = self._animation_type()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type in {"gradient", "glow"}:
self._stop_animation("gradient_anim")
elif animation_type in {"scale", "scale_fill"}:
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
self._stop_animation("pulse_anim")
self._start_thickness_animation(
self._config_value("default_border_width"),
self.theme.GAME_CARD_ANIMATION["default_border_width"],
"thickness_easing_curve_out",
False,
)
@@ -249,39 +203,39 @@ class GameCardAnimations:
pen = QPen()
pen.setWidth(self.game_card._borderWidth)
fill_brush = QBrush(Qt.BrushStyle.NoBrush)
animation_type = self._animation_type()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
center = self.game_card.rect().center()
gradient = QConicalGradient(center, self.game_card._gradientAngle)
for stop in self._config_value("gradient_colors"):
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
gradient.setColorAt(stop["position"], QColor(stop["color"]))
pen.setBrush(QBrush(gradient))
elif (self.game_card._hovered or self.game_card._focused) and animation_type in {"fill", "scale_fill"}:
fill_color_value = self._optional_config_value(
fill_color_value = self.theme.GAME_CARD_ANIMATION.get(
"fill_color",
getattr(self.theme, "color_accent", self.theme.color_text),
)
fill_alpha = int(self._optional_config_value("fill_alpha", 90))
fill_alpha = int(self.theme.GAME_CARD_ANIMATION.get("fill_alpha", 90))
fill_color = QColor(fill_color_value)
fill_color.setAlpha(max(0, min(255, fill_alpha)))
fill_brush = QBrush(fill_color)
pen.setColor(QColor(0, 0, 0, 0))
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "stripe":
stripe_color_value = self._optional_config_value(
stripe_color_value = self.theme.GAME_CARD_ANIMATION.get(
"stripe_color",
getattr(self.theme, "color_accent", self.theme.color_text),
)
stripe_alpha = int(self._optional_config_value("stripe_alpha", 255))
stripe_alpha = int(self.theme.GAME_CARD_ANIMATION.get("stripe_alpha", 255))
stripe_color = QColor(stripe_color_value)
stripe_color.setAlpha(max(0, min(255, stripe_alpha)))
pen.setColor(stripe_color)
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "glow":
glow_color_value = self._optional_config_value(
glow_color_value = self.theme.GAME_CARD_ANIMATION.get(
"stripe_color",
getattr(self.theme, "color_accent", self.theme.color_text),
)
glow_base_alpha = int(self._optional_config_value("glow_base_alpha", 120))
glow_pulse_alpha = int(self._optional_config_value("glow_pulse_alpha", 80))
glow_base_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_base_alpha", 120))
glow_pulse_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_pulse_alpha", 80))
glow_wave = 0.5 + 0.5 * sin(radians(self.game_card._gradientAngle))
glow_alpha = max(0, min(255, glow_base_alpha + int(glow_pulse_alpha * glow_wave)))
glow_color = QColor(glow_color_value)
+46 -211
View File
@@ -1,19 +1,27 @@
import argparse
import sys
import os
import importlib
import subprocess
import signal
import threading
import urllib.error
import urllib.request
from urllib.parse import quote, unquote
from logging import Logger
import psutil
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
try:
version_module = importlib.import_module("portprotonqt._version")
APP_COMMIT = version_module.APP_COMMIT
APP_VERSION = version_module.APP_VERSION
except ImportError:
APP_COMMIT = ""
APP_VERSION = "1.3.1"
__app_version__ = os.environ.get("PORTPROTONQT_VERSION", APP_VERSION)
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (
QApplication,
@@ -52,20 +60,6 @@ from portprotonqt.cli import (
)
from portprotonqt.localization import _, get_steam_language
APP_VERSION = "@APP_VERSION@"
APP_COMMIT = "@APP_COMMIT@"
if APP_VERSION == "@APP" "_VERSION@":
APP_VERSION = "1.5.0"
try:
APP_COMMIT = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
stderr=subprocess.DEVNULL,
).decode("utf-8").strip()
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
APP_COMMIT = ""
__app_version__ = APP_VERSION
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
def get_version():
@@ -105,7 +99,7 @@ def get_portproton_tray_icon() -> QIcon:
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
"""Launch a game with minimal tray game controls."""
"""Launch a game with a minimal tray stop action."""
logger = get_logger(__name__)
app.setQuitOnLastWindowClosed(False)
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
@@ -135,39 +129,6 @@ def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> No
if process.poll() is not None:
end_silent_run()
def update_pause_action() -> list[psutil.Process]:
try:
children = psutil.Process(process.pid).children(recursive=True)
paused = bool(children) and all(
child.status() == psutil.STATUS_STOPPED for child in children
)
except (psutil.NoSuchProcess, psutil.AccessDenied):
children = []
paused = False
pause_action.setEnabled(bool(children))
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
return children
def toggle_pause() -> None:
children = update_pause_action()
if not children:
return
try:
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
for child in children:
try:
child.send_signal(pause_signal)
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
logger.warning("Failed to inspect game pause state: %s", error)
update_pause_action()
pause_action = QAction(_("Pause Game"), tray_menu)
pause_action.triggered.connect(toggle_pause)
tray_menu.addAction(pause_action)
tray_menu.aboutToShow.connect(update_pause_action)
stop_action = QAction(_("Stop Game"), tray_menu)
stop_action.triggered.connect(stop_game)
tray_menu.addAction(stop_action)
@@ -236,25 +197,6 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
def main():
parsed_args = parse_args()
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
from portprotonqt.appimage_integration import integrate_appimage
setup_logger(parsed_args.debug_level)
try:
destination = integrate_appimage()
restart_env = os.environ.copy()
restart_env.pop("PORTPROTONQT_INTEGRATE_APPIMAGE", None)
subprocess.Popen(
[str(destination), *sys.argv[1:]],
env=restart_env,
start_new_session=True,
)
except (OSError, subprocess.SubprocessError, KeyError) as error:
get_logger(__name__).error("Failed to integrate AppImage: %s", error)
return 1
return 0
# Handle --reinstall-steam-compat-tool flag
if parsed_args.reinstall_steam_compat_tool:
@@ -349,7 +291,6 @@ def main():
autoinstall_path = None
theme_store_id = None
gog_launch_uri = None
egs_launch_uri = None
resolution_from_args = None
if args.resolution:
resolution_from_args = parse_resolution(args.resolution)
@@ -363,11 +304,14 @@ def main():
prefix_name, backup_dir = args.create_backup
backup_request = (prefix_name, os.path.abspath(os.path.expanduser(backup_dir)))
ipc_message = "backup:{}:{}".format(quote(prefix_name, safe=""), quote(backup_request[1], safe=""))
elif is_silent_launch and not args.log and args.file_or_url and is_launch_file(args.file_or_url):
run_silent_tray(app, start_sh, normalize_launch_path(args.file_or_url))
sys.exit(app.exec())
elif is_restore_prefix_request(args):
restore_prefix_path = normalize_launch_path(args.file_or_url)
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
elif args.file_or_url and is_launch_file(args.file_or_url):
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
launch_action = "log" if args.log else "open"
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
elif args.file_or_url and is_autoinstall_file(args.file_or_url):
autoinstall_path = normalize_launch_path(args.file_or_url)
@@ -376,18 +320,7 @@ def main():
app_id = args.file_or_url.removeprefix("gog://launch/")
if app_id.isdigit():
gog_launch_uri = args.file_or_url
if args.log:
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
else:
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"gog:{app_id}"
elif args.file_or_url and args.file_or_url.startswith("egs://launch/"):
app_id = args.file_or_url.removeprefix("egs://launch/")
if app_id and "/" not in app_id:
egs_launch_uri = args.file_or_url
if args.log:
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
else:
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"egs:{app_id}"
ipc_message = f"gog:{app_id}"
elif args.file_or_url:
theme_store_id = parse_portprotonqt_theme_url(args.file_or_url)
if theme_store_id is not None:
@@ -395,13 +328,11 @@ def main():
from PySide6.QtCore import QThread, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from requests import RequestException
from portprotonqt.appimage_updater import (
APPIMAGE_UPDATE_START_DELAY_MS,
AppImageUpdateWorker,
)
from portprotonqt.main_window import MainWindow
from portprotonqt.gog_api import GOGAPI
from portprotonqt.port_data_path_selector import ask_portdata_path, is_portdata_path_read_write
from portprotonqt.portproton_api import (
PortProtonAPI,
@@ -456,7 +387,7 @@ def main():
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
# Check if we have a portproton:// URL or launch file to handle
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
if args.file_or_url and not restore_prefix_path and not gog_launch_uri:
if is_portproton_url(args.file_or_url):
# Parse the portproton:// URL to get the full download URL
download_url = parse_portproton_url(args.file_or_url)
@@ -540,61 +471,13 @@ def main():
logger.warning(f"Invalid resolution format: {args.resolution}, expected WIDTHxHEIGHT (e.g. 1920x1080)")
launch_path = exe_path or autoinstall_path
silent_game_request = is_silent_launch and not args.log and bool(
exe_path or gog_launch_uri or egs_launch_uri
)
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
window.silent_launch_mode = silent_game_request
if silent_game_request:
window._initialize_tray(__app_name__)
assert window.tray_manager is not None
if exe_path:
window.tray_manager.tray_icon.hide()
else:
window.tray_manager.set_minimal_mode()
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
exec_line = f"{source}://launch/{app_id}"
if log_mode:
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
def open_card(games: list[tuple]) -> None:
game = next((item for item in games if item[5] == exec_line), None)
if game is None:
return
window.openGameDetailPage({
"name": game[0], "description": game[1], "cover_path": game[2],
"appid": game[3], "controller_support": game[4],
"exec_line": game[5], "last_launch": game[6],
"formatted_playtime": game[7], "protondb_tier": game[8],
"anticheat_status": game[9], "playtime_seconds": game[11],
"game_source": game[12], "anticheat_slug": game[13],
"ppdb_id": game[14], "ppdb_rating": game[15],
"protondb_appid": game[16],
})
loader = getattr(window, f"_load_{source}_games_async")
loader(open_card)
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
if is_launch_file(target):
path = normalize_launch_path(target)
if silent:
run_silent_tray(app, start_sh, path)
return
window.handle_launch_exe(path, log_mode=log_mode)
return
source, app_id = target.split("://launch/", 1)
if silent:
window.toggleGame(target)
return
open_store_game_card(source, app_id, log_mode)
# Handle launch file if provided
if exe_path:
# Defer the call until after the window is shown
def handle_launch_exe() -> None:
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
def handle_launch_exe():
window.handle_launch_exe(exe_path, log_mode=args.log)
QTimer.singleShot(0, handle_launch_exe)
elif autoinstall_path:
def handle_autoinstall():
@@ -609,13 +492,9 @@ def main():
window._perform_restore(restore_prefix_path)
QTimer.singleShot(0, handle_restore_prefix)
elif gog_launch_uri:
def handle_gog_launch() -> None:
handle_game_request(gog_launch_uri, is_silent_launch, args.log)
def handle_gog_launch():
window.toggleGame(gog_launch_uri)
QTimer.singleShot(0, handle_gog_launch)
elif egs_launch_uri:
def handle_egs_launch() -> None:
handle_game_request(egs_launch_uri, is_silent_launch, args.log)
QTimer.singleShot(0, handle_egs_launch)
# --- Handle incoming connections ---
def handle_new_connection():
@@ -634,46 +513,37 @@ def main():
msg.startswith("show")
or msg.startswith("open:")
or msg.startswith("log:")
or msg.startswith("silent:")
or msg.startswith("restore:")
or msg.startswith("backup:")
or msg.startswith("theme:")
or msg.startswith("autoinstall:")
or msg.startswith("gog:")
or msg.startswith("egs:")
):
if not msg.startswith("silent:"):
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
window.show()
window.raise_()
window.activateWindow()
# Ensure the window is visible and not minimized
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
window.show()
window.raise_()
window.activateWindow()
# Ensure active state for strict focus policies
window.setWindowState(
window.windowState() | Qt.WindowState.WindowActive
)
# Ensure window is in active state for systems with strict focus policies
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
if ":fullscreen" in msg:
logger.info("Switching to fullscreen via IPC")
display_config.set_fullscreen(True)
window.input_manager.handle_fullscreen_slot(True)
window.showFullScreen()
else:
if msg.startswith("show"):
logger.info("Switching to normal window via IPC")
display_config.set_fullscreen(False)
window.input_manager.handle_fullscreen_slot(False)
window.showNormal()
if msg.startswith("silent:"):
target = unquote(msg[7:].strip())
handle_game_request(target, True)
elif msg.startswith("open:") or msg.startswith("log:"):
if msg.startswith("open:") or msg.startswith("log:"):
log_mode = msg.startswith("log:")
launch_path = unquote(msg.split(":", 1)[1].strip())
launch_path = msg.split(":", 1)[1].strip()
if launch_path and is_launch_file(launch_path):
logger.info("Opening launch file via IPC: %s", launch_path)
handle_game_request(launch_path, False, log_mode)
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
handle_game_request(unquote(launch_path), False, True)
window.handle_launch_exe(launch_path, log_mode=log_mode)
else:
logger.warning("Invalid launch file via IPC: %s", launch_path)
elif msg.startswith("restore:"):
@@ -686,15 +556,9 @@ def main():
elif msg.startswith("gog:"):
app_id = msg[4:].strip()
if app_id.isdigit():
handle_game_request(f"gog://launch/{app_id}", False)
window.toggleGame(f"gog://launch/{app_id}")
else:
logger.warning("Invalid GOG app id via IPC: %s", app_id)
elif msg.startswith("egs:"):
app_id = msg[4:].strip()
if app_id and "/" not in app_id:
handle_game_request(f"egs://launch/{app_id}", False)
else:
logger.warning("Invalid Epic app id via IPC: %s", app_id)
elif msg.startswith("backup:"):
parts = msg.split(":", 2)
if len(parts) == 3:
@@ -737,20 +601,16 @@ def main():
and getattr(window.input_manager, "gamepad", None) is not None
)
launch_minimized = (
silent_game_request
or (
display_config.get_start_minimized()
and not args.fullscreen
and not launch_auto_fullscreen
and window_resolution is None
and exe_path is None
and backup_request is None
and restore_prefix_path is None
and autoinstall_path is None
and theme_store_id is None
and gog_launch_uri is None
and egs_launch_uri is None
)
display_config.get_start_minimized()
and not args.fullscreen
and not launch_auto_fullscreen
and window_resolution is None
and exe_path is None
and backup_request is None
and restore_prefix_path is None
and autoinstall_path is None
and theme_store_id is None
and gog_launch_uri is None
)
if launch_minimized:
logger.info("Launching in tray")
@@ -760,7 +620,7 @@ def main():
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
)
display_config.set_fullscreen(True)
window.input_manager.handle_fullscreen_slot(True)
window.showFullScreen()
elif launch_auto_fullscreen:
logger.info("Launching in fullscreen mode (gamepad)")
window.input_manager.handle_fullscreen_slot(True)
@@ -815,27 +675,6 @@ def main():
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
class GOGDLUpdateWorker(QThread):
"""Update gogdl without blocking the UI thread."""
def __init__(self, api: GOGAPI) -> None:
super().__init__()
self.api = api
def run(self) -> None:
try:
self.api.update_gogdl()
except (OSError, RequestException):
logger.exception("Failed to update gogdl")
gogdl_update_worker = GOGDLUpdateWorker(window.gog_api)
window.gogdlUpdateWorker = gogdl_update_worker
gogdl_update_worker.finished.connect(
lambda: setattr(window, "gogdlUpdateWorker", None)
)
gogdl_update_worker.finished.connect(gogdl_update_worker.deleteLater)
gogdl_update_worker.start()
# Execute the initial PortProton command after the UI is set up
class InitialCommandWorker(QThread):
"""Worker thread to run initial PortProton command without blocking UI."""
@@ -902,10 +741,6 @@ def main():
if start_sh:
worker = InitialCommandWorker(start_sh)
window.initialCommandWorker = worker
worker.finished.connect(
lambda: setattr(window, "initialCommandWorker", None)
)
worker.start()
else:
logger.warning("PortProton start command not available, skipping initial command")
+24 -53
View File
@@ -6,21 +6,19 @@ import shutil
import subprocess
from pathlib import Path
from PySide6.QtCore import QStandardPaths, QThread, Signal
from PySide6.QtCore import QThread, Signal
from portprotonqt.config import get_portproton_location, migrate_legacy_shortcut
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
APP_ID = "ru.linux_gaming.PortProtonQt"
MIME_PACKAGE_NAME = f"{APP_ID}.xml"
DESKTOP_DATABASE_TIMEOUT = 10
EXECUTABLE_MODE = 0o755
WINDOWS_MIME_TYPES = (
"application/x-ms-dos-executable;application/x-msdos-program;"
"application/x-ms-dos-exec;"
"application/x-ms-dos-exec;application/x-executable;"
"application/x-dosexec;application/vnd.microsoft.portable-executable;"
)
INTEGRATION_MODES = ("log", "silent")
@@ -55,10 +53,10 @@ def _remove_existing_integration(applications_dir: Path, icon_dir: Path) -> None
(icon_dir / f"{APP_ID}{suffix}").unlink(missing_ok=True)
def _rewrite_main_desktop(content: str, appimage: Path) -> str:
def _rewrite_main_desktop(content: str, appimage: Path, icon: Path) -> str:
exec_line = f"Exec={shlex.join([str(appimage), '%u'])}"
content = re.sub(r"^Exec=.*$", exec_line, content, count=1, flags=re.MULTILINE)
content = re.sub(r"^Icon=.*$", f"Icon={APP_ID}", content, flags=re.MULTILINE)
content = re.sub(r"^Icon=.*$", f"Icon={icon}", content, flags=re.MULTILINE)
if re.search(r"^TryExec=", content, flags=re.MULTILINE):
return re.sub(
r"^TryExec=.*$",
@@ -70,7 +68,7 @@ def _rewrite_main_desktop(content: str, appimage: Path) -> str:
return content.replace("Type=Application\n", f"Type=Application\nTryExec={appimage}\n", 1)
def _mode_desktop(mode: str, appimage: Path) -> str:
def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
action = _("Run in logging mode") if mode == "log" else _("Run in silent mode")
exec_line = shlex.join([str(appimage), f"--{mode}", "%u"])
return (
@@ -81,7 +79,7 @@ def _mode_desktop(mode: str, appimage: Path) -> str:
"Type=Application\n"
f"Comment={action}\n"
"Terminal=false\n"
f"Icon={APP_ID}\n"
f"Icon={icon}\n"
f"StartupWMClass={APP_ID}\n"
"Categories=Game;\n"
"NoDisplay=true\n"
@@ -94,21 +92,17 @@ def _install_desktop_files(
source: Path,
applications_dir: Path,
appimage: Path,
icon: Path,
) -> None:
content = source.read_text(encoding="utf-8")
rewritten_content = _rewrite_main_desktop(content, appimage)
main_path = applications_dir / f"{APP_ID}.desktop"
main_path.write_text(rewritten_content, encoding="utf-8")
desktop_dir = Path(
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
main_path.write_text(
_rewrite_main_desktop(content, appimage, icon),
encoding="utf-8",
)
desktop_dir.mkdir(parents=True, exist_ok=True)
desktop_path = desktop_dir / f"{APP_ID}.desktop"
desktop_path.write_text(rewritten_content, encoding="utf-8")
desktop_path.chmod(EXECUTABLE_MODE)
for mode in INTEGRATION_MODES:
path = applications_dir / f"{APP_ID}.{mode}.desktop"
path.write_text(_mode_desktop(mode, appimage), encoding="utf-8")
path.write_text(_mode_desktop(mode, appimage, icon), encoding="utf-8")
def _set_default_mime_handlers() -> None:
@@ -136,31 +130,19 @@ def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
f"share/applications/{APP_ID}.desktop",
),
)
icon = appdir / f"{APP_ID}.svg"
if not desktop or not icon.is_file():
raise FileNotFoundError("AppImage desktop metadata is incomplete")
return desktop, icon
def _install_mime_package(appdir: Path, data_home: Path) -> None:
source = _find_appimage_asset(
icon = _find_appimage_asset(
appdir,
(
f"share/mime/packages/{MIME_PACKAGE_NAME}",
f"usr/share/mime/packages/{MIME_PACKAGE_NAME}",
".DirIcon",
f"{APP_ID}.svg",
f"{APP_ID}.png",
f"usr/share/icons/hicolor/scalable/apps/{APP_ID}.svg",
f"share/icons/hicolor/scalable/apps/{APP_ID}.svg",
),
)
if not source:
raise FileNotFoundError("AppImage MIME metadata is incomplete")
mime_dir = data_home / "mime"
packages_dir = mime_dir / "packages"
packages_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, packages_dir / MIME_PACKAGE_NAME)
subprocess.run(
["update-mime-database", str(mime_dir)],
check=True,
timeout=DESKTOP_DATABASE_TIMEOUT,
)
if not desktop or not icon:
raise FileNotFoundError("AppImage desktop metadata is incomplete")
return desktop, icon
def integrate_appimage() -> Path:
@@ -174,27 +156,18 @@ def integrate_appimage() -> Path:
appimages_dir = Path.home() / "AppImages"
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
applications_dir = data_home / "applications"
icon_dir = data_home / "icons"
icon_dir = appimages_dir / ".icons"
appimages_dir.mkdir(parents=True, exist_ok=True)
applications_dir.mkdir(parents=True, exist_ok=True)
icon_dir.mkdir(parents=True, exist_ok=True)
destination = appimages_dir / "portprotonqt.appimage"
resolved_icon = icon_source.resolve()
icon = icon_dir / f"{APP_ID}.svg"
_copy_appimage(source, destination)
# Remove after the legacy AppImage migration period.
legacy_appimage = Path.home() / "Applications/PortProtonQt.AppImage"
if legacy_appimage.resolve() != destination.resolve():
portproton_location = get_portproton_location()
migrate_legacy_shortcut(
portproton_location or "",
launcher_command=[str(destination), "--silent"],
)
icon = icon_dir / f"{APP_ID}{resolved_icon.suffix or '.svg'}"
_remove_existing_integration(applications_dir, icon_dir)
_copy_appimage(source, destination)
shutil.copy2(resolved_icon, icon)
_install_desktop_files(desktop_source, applications_dir, destination)
_install_mime_package(appdir, data_home)
_install_desktop_files(desktop_source, applications_dir, destination, icon)
_set_default_mime_handlers()
try:
subprocess.run(
@@ -204,8 +177,6 @@ def integrate_appimage() -> Path:
)
except (FileNotFoundError, subprocess.SubprocessError) as error:
logger.warning("Failed to update desktop database: %s", error)
if legacy_appimage.resolve() != destination.resolve():
legacy_appimage.unlink(missing_ok=True)
return destination
+31 -28
View File
@@ -14,8 +14,7 @@ import requests
from PySide6.QtCore import QThread, Signal
from portprotonqt.downloader import get_requests_session
from portprotonqt.config import ui_config
from portprotonqt.config.base import XDG_DATA_HOME
from portprotonqt.config import CACHE_DIR, ui_config
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -29,15 +28,15 @@ APPIMAGE_UPDATE_CHECK_TIMEOUT = 120
APPIMAGE_UPDATE_TIMEOUT = 900
APPIMAGE_UPDATE_START_DELAY_MS = 5000
EXECUTABLE_MODE = 0o755
GITHUB_UPDATE_INFO = (
"gh-releases-zsync|linux-gaming-ru|PortProtonQt|latest|*x86_64.AppImage.zsync"
FALLBACK_UPDATE_INFO = (
"gh-releases-zsync|Boria138|PortProtonQt|latest|*x86_64.AppImage.zsync"
)
CHANGELOG_URL = (
"https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/"
"CHANGELOG.md"
)
CHANGELOG_GITHUB_URL = (
"https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/refs/heads/main/"
CHANGELOG_FALLBACK_URL = (
"https://raw.githubusercontent.com/Boria138/PortProtonQt/refs/heads/main/"
"CHANGELOG.md"
)
CHANGELOG_TIMEOUT = 10
@@ -60,7 +59,7 @@ def _get_appimage_arch() -> str:
def _cached_tool_path() -> Path:
return XDG_DATA_HOME / "PortProtonQt" / "bin" / "appimageupdatetool"
return CACHE_DIR / "bin" / "appimageupdatetool"
def _get_appimageupdatetool_url() -> str:
@@ -130,22 +129,16 @@ def _run_appimageupdatetool(
def _download_changelog() -> str:
from portprotonqt.portproton_api import get_user_conf_setting
session = get_requests_session()
url = (
CHANGELOG_URL
if get_user_conf_setting("MIRROR") == "CLOUD"
else CHANGELOG_GITHUB_URL
)
try:
with session.get(url, timeout=CHANGELOG_TIMEOUT) as response:
response.raise_for_status()
data = response.content[:CHANGELOG_MAX_CHARS]
return data.decode("utf-8", errors="replace")
except (OSError, requests.RequestException) as error:
logger.debug("Failed to download changelog from %s: %s", url, error)
return ""
for url in (CHANGELOG_URL, CHANGELOG_FALLBACK_URL):
try:
with session.get(url, timeout=CHANGELOG_TIMEOUT) as response:
response.raise_for_status()
data = response.content[:CHANGELOG_MAX_CHARS]
return data.decode("utf-8", errors="replace")
except (OSError, requests.RequestException) as error:
logger.debug("Failed to download changelog from %s: %s", url, error)
return ""
def _extract_latest_version_changelog(changelog: str, current_version: str = "") -> str:
@@ -313,9 +306,15 @@ class AppImageUpdateWorker(QThread):
self.update_info = previous_info
return result
def run(self) -> None:
from portprotonqt.portproton_api import get_user_conf_setting
def _is_check_failure(self, result: subprocess.CompletedProcess[str] | None) -> bool:
if result is None:
return True
if result.returncode not in (0, 1):
return True
output = f"{result.stdout}\n{result.stderr}"
return " Error:" in output or "Failed to fetch" in output
def run(self) -> None:
appimage_path = _appimage_path()
if not ui_config.get_auto_appimage_updates() or not appimage_path:
return
@@ -331,10 +330,14 @@ class AppImageUpdateWorker(QThread):
self._run_update(tool_path, appimage_path)
return
selected_info = (
"" if get_user_conf_setting("MIRROR") == "CLOUD" else GITHUB_UPDATE_INFO
)
check_code = self._check_for_update(tool_path, appimage_path, selected_info)
update_infos = ["", FALLBACK_UPDATE_INFO]
check_code = None
selected_info = ""
for update_info in update_infos:
check_code = self._check_for_update(tool_path, appimage_path, update_info)
if not self._is_check_failure(check_code):
selected_info = update_info
break
if check_code is None or check_code.returncode != 1:
return
-120
View File
@@ -1,120 +0,0 @@
"""Theme boot video rendered inside the application's startup page."""
import os
from typing import Any, TYPE_CHECKING
from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QImage, QPainter, QPaintEvent
from PySide6.QtWidgets import QApplication, QWidget
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import _find_theme_folder, _read_theme_parent_name
if TYPE_CHECKING:
from PySide6.QtMultimedia import QVideoFrame
logger = get_logger(__name__)
def find_boot_video(theme_name: str) -> str | None:
"""Find the theme's WebM, following its existing inheritance chain."""
visited = set()
while theme_name and theme_name not in visited:
visited.add(theme_name)
folder = _find_theme_folder(theme_name)
if folder is not None:
path = os.path.join(folder, "bootanimation.webm")
if os.path.isfile(path):
if os.path.dirname(os.path.realpath(path)) == os.path.realpath(folder):
return path
logger.warning("Boot video outside theme directory blocked: %s", path)
return None
theme_name = _read_theme_parent_name(theme_name) or ""
return None
class BootAnimation(QWidget):
finished = Signal()
def __init__(self, theme: Any, video_path: str, parent: QWidget) -> None:
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer, QVideoSink
super().__init__(parent)
self._background = QColor(theme.color_boot_animation)
self._video_path = video_path
self._frame = QImage()
self._stopped = False
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.sink = QVideoSink(self)
self.sink.videoFrameChanged.connect(self._receive_frame)
self.audio = QAudioOutput(self)
self.player = QMediaPlayer(self)
self.player.setAudioOutput(self.audio)
self.player.setVideoSink(self.sink)
self.player.mediaStatusChanged.connect(self._media_status_changed)
self.player.errorOccurred.connect(self._playback_error)
self.timeout = QTimer(self)
self.timeout.setSingleShot(True)
self.timeout.setInterval(theme.bootAnimationTimeoutMs)
self.timeout.timeout.connect(self.finish)
def start(self) -> None:
if self._stopped:
return
app = QApplication.instance()
if app is not None:
app.installEventFilter(self)
self.setFocus(Qt.FocusReason.OtherFocusReason)
self.timeout.start()
self.player.setSource(QUrl.fromLocalFile(self._video_path))
if not self._stopped:
self.player.play()
def _receive_frame(self, frame: "QVideoFrame") -> None:
if self._stopped or not frame.isValid():
return
image = frame.toImage()
if not image.isNull():
self._frame = image
self.update()
def paintEvent(self, event: QPaintEvent) -> None:
painter = QPainter(self)
painter.fillRect(self.rect(), self._background)
if not self._frame.isNull():
size = self._frame.size().scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
target = QRect(QPoint(), size)
target.moveCenter(self.rect().center())
painter.drawImage(target, self._frame)
def _media_status_changed(self, status: object) -> None:
from PySide6.QtMultimedia import QMediaPlayer
if status == QMediaPlayer.MediaStatus.EndOfMedia:
self.finish()
def _playback_error(self, error: object, message: str) -> None:
logger.warning("Boot video playback failed: %s", message)
self.finish()
def finish(self) -> None:
if not self._stopped:
self.stop()
self.finished.emit()
def stop(self) -> None:
if self._stopped:
return
self._stopped = True
self.timeout.stop()
self.player.stop()
self.sink.videoFrameChanged.disconnect(self._receive_frame)
self._frame = QImage()
app = QApplication.instance()
if app is not None:
app.removeEventFilter(self)
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self and event.type() in (QEvent.Type.KeyPress, QEvent.Type.MouseButtonPress):
self.finish()
return True
return super().eventFilter(watched, event)
+8 -46
View File
@@ -14,7 +14,6 @@ from typing import Any
import orjson
import pefile
import psutil
from portprotonqt.debug_utils import (
get_portproton_env,
@@ -29,7 +28,7 @@ from portprotonqt.scripts_utils.graphics_detector import analyze_executable
logger = get_logger(__name__)
CRASH_THRESHOLD_SECONDS = 7
CRASH_THRESHOLD_SECONDS = 5
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
PE_MACHINE_AMD64 = 0x8664
COMPATIBILITY_SCAN_BYTES = 4_194_304
@@ -99,46 +98,16 @@ class CompatibilityLaunch:
duration: float
def is_suspected_crash(
duration: float, stopped_by_user: bool, executable: str, exit_code: int | None = None
) -> bool:
"""Prefer Wine's command status; use short runtime only when it is unknown."""
def is_suspected_crash(duration: float, stopped_by_user: bool, executable: str) -> bool:
"""Return whether an executable closed too soon after launch."""
always_report = os.getenv(COMPATIBILITY_ALWAYS_REPORT_ENV) == "1"
return (
(always_report or (not stopped_by_user and (
exit_code != 0 if exit_code is not None else duration < CRASH_THRESHOLD_SECONDS
)))
(always_report or (not stopped_by_user and duration < CRASH_THRESHOLD_SECONDS))
and executable.lower().endswith((".exe", ".msi"))
and os.path.isfile(executable)
)
def get_running_game_processes(executable: str, started: float) -> dict[tuple[int, float], str]:
"""Find new Windows processes in the game's directory, including engine children."""
directory = os.path.dirname(os.path.abspath(executable)).lower() + "/"
running = {}
for process in psutil.process_iter(attrs=["pid", "name", "cmdline", "cwd", "create_time", "status"]):
info = process.info
created = info.get("create_time")
if created is None or created < started or info.get("status") == psutil.STATUS_ZOMBIE:
continue
arguments = info.get("cmdline") or []
command = (arguments[0] if arguments else info.get("name") or "").replace("\\", "/")
if not command.lower().endswith((".exe", ".msi")):
continue
if command.lower().startswith("z:/"):
command = command[2:]
if not command.startswith("/"):
cwd = info.get("cwd")
if not cwd:
continue
command = os.path.join(cwd, os.path.basename(command) if ":" in command else command)
command = os.path.normpath(command)
if command.lower().startswith(directory):
running[(info["pid"], created)] = command
return running
def _read_imports(pe: pefile.PE) -> set[str]:
imports = set()
for directory_name in ("DIRECTORY_ENTRY_IMPORT", "DIRECTORY_ENTRY_DELAY_IMPORT"):
@@ -319,12 +288,9 @@ def _analyze_pe(executable: str) -> tuple[str, dict[str, list[str]]]:
try:
optional_header = getattr(pe, "OPTIONAL_HEADER", None)
data_directories = getattr(optional_header, "DATA_DIRECTORY", [])
descriptor_index = pefile.DIRECTORY_ENTRY[
"IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"
clr = data_directories[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"]
]
if not isinstance(descriptor_index, int):
raise IndexError
clr = data_directories[descriptor_index]
if clr.VirtualAddress and ".NET Framework" not in findings.setdefault("Runtimes", []):
findings["Runtimes"].append(".NET Framework")
except (AttributeError, IndexError):
@@ -454,8 +420,6 @@ def _compatibility_suggestions(
)
if uses_directx_8_11 and environment.get("PW_VULKAN_USE") == "0":
suggestions.append("Switch from WineD3D to DXVK for DirectX 8-11.")
if environment.get("PW_USE_OPTISCALER") == "1":
suggestions.append("Temporarily disable OptiScaler in the game settings and retry to check for a conflict.")
if _uses_integrated_gpu(environment):
suggestions.append("Use the discrete GPU.")
runtimes = findings.get("Runtimes", [])
@@ -464,10 +428,6 @@ def _compatibility_suggestions(
needs_dotnet = needs_dotnet or "WPF" in findings.get("Frameworks", [])
if needs_dotnet and prefix_name != "DOTNET":
suggestions.append("Use the DOTNET prefix.")
installers = findings.get("Installer", [])
wine_version = environment.get("PW_WINE_USE", "")
if installers and not wine_version.startswith("WINE_LG"):
suggestions.append("Use WINE_LG for installers.")
winetricks_log = environment.get("PW_WINETRICKS_LOG", "").lower()
missing_vcrun = [
components[-1]
@@ -540,6 +500,8 @@ def analyze_launch(launch: CompatibilityLaunch, portproton_path: str) -> str:
glibc_32_compatibility, glibc_32_suggestions = _glibc_32_compatibility()
suggestions.extend(glibc_32_suggestions)
lines = [
"Compatibility report",
"",
f"Executable: {launch.executable}",
f"Exit code: {launch.exit_code if launch.exit_code is not None else 'unknown'}",
f"Runtime before exit: {launch.duration:.1f} s",
+1 -22
View File
@@ -1,7 +1,6 @@
"""Base configuration class for PortProtonQt."""
import os
import configparser
import shutil
from pathlib import Path
from portprotonqt.logger import get_logger
from portprotonqt.config.validators import validate_string, validate_int, validate_bool, ValidationError
@@ -25,27 +24,7 @@ THEMES_DIRS = [
]
# Cache paths
CACHE_DIR = XDG_DATA_HOME / "PortProtonQt" / "cache"
LEGACY_CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
# Remove after the legacy cache migration period.
def _migrate_legacy_cache() -> None:
"""Move the legacy XDG cache into persistent application data."""
if not LEGACY_CACHE_DIR.is_dir():
return
try:
if not CACHE_DIR.exists():
CACHE_DIR.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(LEGACY_CACHE_DIR), str(CACHE_DIR))
return
shutil.copytree(LEGACY_CACHE_DIR, CACHE_DIR, dirs_exist_ok=True)
shutil.rmtree(LEGACY_CACHE_DIR)
except OSError as error:
logger.warning("Failed to migrate legacy cache directory: %s", error)
_migrate_legacy_cache()
CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
# Module-level cache storage
_config_cache: dict[str, configparser.ConfigParser] = {}
-18
View File
@@ -33,24 +33,6 @@ class DisplayConfig(BaseConfig):
validate_bool(auto, "auto_fullscreen_gamepad")
self._save_value("auto_fullscreen_gamepad", auto, "bool")
def get_hide_cursor(self) -> bool:
"""Return whether fullscreen cursor hiding is enabled."""
return self._get_bool("hide_cursor", True)
def set_hide_cursor(self, enabled: bool) -> None:
"""Enable or disable fullscreen cursor hiding."""
validate_bool(enabled, "hide_cursor")
self._save_value("hide_cursor", enabled, "bool")
def get_boot_animation_enabled(self) -> bool:
"""Return whether fullscreen boot videos are enabled."""
return self._get_bool("boot_animation_enabled", True)
def set_boot_animation_enabled(self, enabled: bool) -> None:
"""Enable or disable fullscreen boot videos."""
validate_bool(enabled, "boot_animation_enabled")
self._save_value("boot_animation_enabled", enabled, "bool")
def get_minimize_to_tray(self) -> bool:
"""Get minimize-to-tray setting."""
return self._get_bool("minimize_to_tray", False)
+1 -10
View File
@@ -1,6 +1,6 @@
"""Game-related configuration settings."""
from portprotonqt.config.base import BaseConfig
from portprotonqt.config.validators import validate_bool, validate_string
from portprotonqt.config.validators import validate_string
class GameConfig(BaseConfig):
@@ -26,15 +26,6 @@ class GameConfig(BaseConfig):
validate_string(filter_value, "display_filter", min_len=1, max_len=50)
self._save_value("display_filter", filter_value, "str")
def get_only_installed(self) -> bool:
"""Get whether only installed games are displayed."""
return self._get_bool("only_installed", False)
def set_only_installed(self, enabled: bool) -> None:
"""Set whether only installed games are displayed."""
validate_bool(enabled, "only_installed")
self._save_value("only_installed", enabled, "bool")
def get_steam_account_id(self) -> str:
"""Get selected Steam account ID or automatic selection."""
return self._get_str("steam_account_id", "auto")
+9 -66
View File
@@ -164,19 +164,13 @@ def get_portproton_scripts_path() -> str | None:
if appimage_path:
appimage_root = Path(sys.executable).resolve().parent.parent
prefixes.append(("AppImage executable", appimage_root))
if os.getenv("FLATPAK_ID"):
prefixes.append(("Flatpak package", Path("/app")))
prefixes.append(("system package", Path("/usr")))
xdg_scripts_dirs = [
("XDG data", Path(data_dir) / "portproton" / "scripts")
for data_dir in os.getenv(
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
).split(os.pathsep)
if data_dir
]
scripts_dirs = (
("repository", Path.cwd() / "build-aux" / "share" / "portproton" / "scripts"),
*[(source, prefix / "share" / "portproton" / "scripts") for source, prefix in prefixes],
*xdg_scripts_dirs,
)
for source, scripts_dir in scripts_dirs:
if (scripts_dir / "start.sh").is_file():
@@ -229,16 +223,7 @@ def _extract_launcher_tail(parts: list[str]) -> list[str] | None:
return None
command_name = os.path.basename(parts[0])
appimage_name = command_name.casefold()
is_portproton_appimage = re.fullmatch(
r"portproton(?:qt)?(?:[-_.].*)?\.appimage",
appimage_name,
) is not None
if (
is_portproton_appimage
or command_name == "portprotonqt"
or command_name == "start.sh"
):
if parts[0].lower().endswith(".appimage") or command_name == "portprotonqt" or command_name == "start.sh":
tail = parts[1:]
if tail[:1] == ["--silent"]:
tail = tail[1:]
@@ -289,12 +274,7 @@ def _get_desktop_paths(desktop_dir: str | None) -> tuple[str, ...]:
return (os.path.join(os.path.expanduser("~"), "Desktop"),)
# Remove after the legacy shortcut migration period.
def migrate_legacy_shortcut(
portproton_path: str,
desktop_dir: str | None = None,
launcher_command: list[str] | None = None,
) -> int:
def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None) -> int:
"""Migrate legacy PortProton shortcuts in known desktop directories."""
flatpak_id = os.getenv("FLATPAK_ID", "").strip()
user_home = os.path.expanduser("~")
@@ -313,8 +293,7 @@ def migrate_legacy_shortcut(
except OSError as error:
logger.warning("Failed to read legacy PortProton config %s: %s", PORTPROTON_CONFIG_FILE, error)
if launcher_command is None:
launcher_command = _get_current_launcher_command()
launcher_command = _get_current_launcher_command()
desktop_paths = (
portproton_path,
os.path.join(user_home, ".local", "share", "applications"),
@@ -455,42 +434,7 @@ def migrate_legacy_shortcut(
def parse_desktop_entry(file_path: str) -> configparser.SectionProxy | None:
"""Read and parse a .desktop file using configparser."""
cp = configparser.ConfigParser(interpolation=None)
content = ""
try:
content = Path(file_path).read_text(encoding="utf-8")
cp.read_string(content, source=file_path)
except configparser.ParsingError:
lines = []
for line in content.splitlines():
stripped = line.strip()
if stripped and "=" not in line and not stripped.startswith(("[", "#", ";")):
if not lines or "=" not in lines[-1]:
return None
lines[-1] = f"{lines[-1]} {stripped}"
continue
lines.append(line)
repaired_content = "\n".join(lines) + ("\n" if content.endswith("\n") else "")
try:
cp.read_string(repaired_content, source=file_path)
except configparser.Error as error:
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
return None
desktop_path = Path(file_path)
temp_path = desktop_path.with_name(f".{desktop_path.name}.tmp")
try:
temp_path.write_text(repaired_content, encoding="utf-8")
os.chmod(temp_path, desktop_path.stat().st_mode)
os.replace(temp_path, desktop_path)
logger.warning("Repaired malformed desktop file %s", file_path)
except OSError as error:
logger.warning("Failed to save repaired desktop file %s: %s", file_path, error)
try:
temp_path.unlink(missing_ok=True)
except OSError as cleanup_error:
logger.debug("Failed to remove temporary file %s: %s", temp_path, cleanup_error)
except (configparser.Error, OSError, UnicodeError) as error:
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
return None
cp.read(file_path, encoding="utf-8")
if "Desktop Entry" not in cp:
return None
return cp["Desktop Entry"]
@@ -588,8 +532,8 @@ def create_desktop_file(
) -> tuple[str, str, str] | None:
"""Create desktop entry content, destination path and icon path for a game."""
portproton_path = get_portproton_location()
is_store_uri = re.fullmatch(r"(?:gog|egs)://launch/[^/]+", exe_path) is not None
if not is_store_uri and not os.path.isfile(exe_path):
is_gog_uri = re.fullmatch(r"gog://launch/\d+", exe_path) is not None
if not is_gog_uri and not os.path.isfile(exe_path):
logger.error("Executable not found: %s", exe_path)
return None
if not portproton_path:
@@ -598,12 +542,11 @@ def create_desktop_file(
if not game_name:
game_name = os.path.splitext(os.path.basename(exe_path))[0]
game_name = " ".join(game_name.splitlines()).strip()
base_path = os.path.join(portproton_path, "data")
icon_name = _sanitize_icon_name(game_name)
has_icon_source = bool(icon_source and os.path.isfile(icon_source))
icon_path = (
"applications-games" if is_store_uri and not has_icon_source
"applications-games" if is_gog_uri and not has_icon_source
else os.path.join(base_path, "img", f"{icon_name}.png")
)
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
+1 -65
View File
@@ -348,31 +348,6 @@ class UIConfig(BaseConfig):
validate_bool(hide, "hide_autoinstall_tab")
self._save_value("hide_autoinstall_tab", hide, "bool")
def get_hide_control_hints(self) -> bool:
"""Get hide control hints setting."""
return self._get_bool("hide_control_hints", False)
def set_hide_control_hints(self, hide: bool) -> None:
"""Set hide control hints setting."""
validate_bool(hide, "hide_control_hints")
self._save_value("hide_control_hints", hide, "bool")
def get_force_english(self) -> bool:
"""Get forced English language setting."""
cp = self._read_config()
if cp is None:
return False
try:
return cp.getboolean(self._section, "force_english", fallback=False)
except (configparser.Error, ValueError) as e:
logger.warning("Error reading force_english: %s", e)
return False
def set_force_english(self, enabled: bool) -> None:
"""Set forced English language setting."""
validate_bool(enabled, "force_english")
self._save_value("force_english", enabled, "bool")
def get_badge_view_mode(self) -> str:
"""Get badge view mode ('detailed' or 'compact')."""
mode = self._get_str("badge_view_mode", "detailed")
@@ -383,27 +358,6 @@ class UIConfig(BaseConfig):
validate_string(mode, "badge_view_mode", min_len=1, max_len=20)
self._save_value("badge_view_mode", mode, "str")
def get_library_layout_mode(self, theme_mode: str = "grid") -> str:
"""Get the selected library layout or the theme default."""
modes = {"grid", "list", "vertical", "horizontal", "horizontal_top"}
mode = self._get_str("library_layout_mode", "theme")
return mode if mode in modes else theme_mode.lower()
def set_library_layout_mode(self, mode: str) -> None:
"""Set the library layout mode."""
validate_string(mode, "library_layout_mode", min_len=1, max_len=20)
self._save_value("library_layout_mode", mode, "str")
def get_horizontal_card_orientation(self, theme_mode: str) -> str:
"""Get the horizontal card orientation or the theme default."""
mode = self._get_str("horizontal_card_orientation", "theme")
return mode if mode in {"horizontal", "vertical"} else theme_mode
def set_horizontal_card_orientation(self, mode: str) -> None:
"""Set the horizontal card orientation."""
validate_string(mode, "horizontal_card_orientation", min_len=1, max_len=20)
self._save_value("horizontal_card_orientation", mode, "str")
def get_economy_mode(self) -> bool:
"""Get economy mode setting."""
return self._get_download_bool("economy_mode", False)
@@ -453,15 +407,6 @@ class UIConfig(BaseConfig):
validate_bool(enabled, "download_wine_to_steam")
self._save_download_value("download_wine_to_steam", enabled)
def get_disable_steam_proton_scanning(self) -> bool:
"""Get Steam Proton scanning setting."""
return self._get_bool("disable_steam_proton_scanning", False)
def set_disable_steam_proton_scanning(self, enabled: bool) -> None:
"""Set Steam Proton scanning setting."""
validate_bool(enabled, "disable_steam_proton_scanning")
self._save_value("disable_steam_proton_scanning", enabled, "bool")
def get_disable_runtime_download(self) -> bool:
"""Get PortProton runtime download setting."""
default = bool(os.getenv("FLATPAK_ID"))
@@ -476,7 +421,7 @@ class UIConfig(BaseConfig):
def get_auto_download_ppdb(self) -> bool:
"""Get PPDB auto-download setting."""
return self._get_download_bool("auto_download_ppdb", False)
return self._get_download_bool("auto_download_ppdb", True)
def set_auto_download_ppdb(self, enabled: bool) -> None:
"""Set PPDB auto-download setting."""
@@ -519,15 +464,6 @@ class UIConfig(BaseConfig):
validate_bool(enabled, "crash_reports_enabled")
self._save_value("crash_reports_enabled", enabled, "bool")
def get_disable_alt_i586_dependency_check(self) -> bool:
"""Get ALT i586 dependency check setting."""
return self._get_bool("disable_alt_i586_dependency_check", False)
def set_disable_alt_i586_dependency_check(self, enabled: bool) -> None:
"""Set ALT i586 dependency check setting."""
validate_bool(enabled, "disable_alt_i586_dependency_check")
self._save_value("disable_alt_i586_dependency_check", enabled, "bool")
def load_theme_metainfo(theme_name: str) -> dict:
"""Load theme metadata from metainfo.ini."""
+57 -227
View File
@@ -2,7 +2,6 @@ import os
import glob
import shutil
import tempfile
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
@@ -35,9 +34,6 @@ from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
logger = get_logger(__name__)
if TYPE_CHECKING:
from portprotonqt.game_card import GameCard
class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads."""
show_warning_dialog = Signal(str, str)
@@ -460,7 +456,6 @@ class ContextMenuManager:
menu = QMenu(self.parent)
menu.setWindowFlags(menu.windowFlags() | Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
menu.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
# For non-Steam games, check if exe exists
@@ -471,16 +466,16 @@ class ContextMenuManager:
# Show only "Delete from PortProton" if no valid exe
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
menu.popup(game_card.mapToGlobal(pos))
menu.exec(game_card.mapToGlobal(pos))
return
# Normal menu for games with valid exe or from Steam
is_uninstalled_store_game = (
game_card.game_source in ("gog", "egs")
and game_card.exec_line.startswith(("gog://install/", "egs://install/"))
is_uninstalled_gog = (
game_card.game_source == "gog"
and game_card.exec_line.startswith("gog://install/")
)
is_running = self._is_game_running(game_card)
if not is_uninstalled_store_game:
if not is_uninstalled_gog:
action_text = _("Stop Game") if is_running else _("Launch Game")
action_icon = "stop" if is_running else "play"
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
@@ -548,83 +543,6 @@ class ContextMenuManager:
import_action.triggered.connect(
lambda: self.parent._import_gog_game(game)
)
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect(
lambda: self._edit_id_shortcut(
game_card.name,
game_card.cover_path,
("gog", str(game_card.appid)),
)
)
if game_card.game_source == "egs":
app_id = str(game_card.appid)
if game_card.exec_line.startswith("egs://launch/"):
desktop_dir = QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.DesktopLocation
)
desktop_path = self._get_shortcut_path(game_card.name, desktop_dir)
desktop_exists = os.path.exists(desktop_path)
desktop_action = menu.addAction(
self._get_safe_icon("delete" if desktop_exists else "desktop"),
_("Remove from Desktop") if desktop_exists else _("Add to Desktop"),
)
desktop_action.triggered.connect(
lambda: self.remove_from_desktop(game_card.name)
if desktop_exists
else self.add_to_desktop(game_card.name, game_card.exec_line)
)
menu_path = self._get_menu_shortcut_path(game_card.name)
menu_exists = os.path.exists(menu_path)
menu_action = menu.addAction(
self._get_safe_icon("delete" if menu_exists else "menu"),
_("Remove from Menu") if menu_exists else _("Add to Menu"),
)
menu_action.triggered.connect(
lambda: self.remove_from_menu(game_card.name)
if menu_exists
else self.add_to_menu(game_card.name, game_card.exec_line)
)
update_action = menu.addAction(
self._get_safe_icon("update"), _("Update")
)
update_action.triggered.connect(
lambda: self.parent._update_egs_game(app_id)
)
repair_action = menu.addAction(
self._get_safe_icon("update"), _("Repair")
)
repair_action.triggered.connect(
lambda: self.parent._repair_egs_game(app_id)
)
eos_enabled = self.parent._is_egs_overlay_enabled(app_id)
eos_action = menu.addAction(
self._get_safe_icon("update"),
_("Disable EOS Overlay")
if eos_enabled else _("Enable EOS Overlay"),
)
eos_action.triggered.connect(
lambda: self.parent._enable_egs_overlay(app_id)
)
delete_action = menu.addAction(
self._get_safe_icon("delete"), _("Delete")
)
delete_action.triggered.connect(
lambda: self.parent._delete_egs_game(app_id)
)
else:
import_action = menu.addAction(
self._get_safe_icon("folder"), _("Import")
)
import_action.triggered.connect(
lambda: self.parent._import_egs_game(app_id)
)
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect(
lambda: self._edit_id_shortcut(
game_card.name, game_card.cover_path, ("egs", app_id)
)
)
if game_card.game_source == "steam":
desktop_dir = QStandardPaths.writableLocation(
@@ -714,7 +632,7 @@ class ContextMenuManager:
if actions:
menu.setActiveAction(actions[0])
menu.popup(game_card.mapToGlobal(pos))
menu.exec(game_card.mapToGlobal(pos))
def _launch_game(self, game_card):
"""
@@ -979,14 +897,12 @@ class ContextMenuManager:
if not exe_path:
return
from portprotonqt.time_utils import get_statistics_path, remove_last_launch
from portprotonqt.time_utils import get_statistics_path
statistics_file = get_statistics_path()
target_path = os.path.normpath(exe_path)
exe_name = os.path.splitext(os.path.basename(target_path))[0]
remove_last_launch(exe_name)
if not os.path.exists(statistics_file):
return
target_path = os.path.normpath(exe_path)
kept_lines = []
removed = False
@@ -1025,7 +941,7 @@ class ContextMenuManager:
if not resolved_exec_line:
return
shortcut_paths = self._get_installed_shortcut_paths(game_name)
exe_path = extract_exec_target_path(resolved_exec_line)
exe_path = self._parse_exe_path(resolved_exec_line, game_name)
if not self._remove_file(
desktop_path,
_("Failed to delete .desktop file: {error}"),
@@ -1078,15 +994,15 @@ class ContextMenuManager:
"""Add the game desktop entry to the XDG applications directory."""
if not self._check_portproton():
return
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
is_gog_uri = str(exec_line).startswith("gog://launch/")
desktop_path = self._get_desktop_path(game_name)
if not is_store_uri and not os.path.exists(desktop_path):
if not is_gog_uri and not os.path.exists(desktop_path):
self.signals.show_warning_dialog.emit(
_("Error"),
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
)
return
if not is_store_uri:
if not is_gog_uri:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
@@ -1094,9 +1010,9 @@ class ContextMenuManager:
if not exec_line:
return
icon_source = None
if is_store_uri:
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
if is_gog_uri:
app_id = str(exec_line).rsplit("/", 1)[-1]
icon_source = self.parent.gog_api.get_launch_target(app_id)
shortcut = create_desktop_file(exec_line, game_name, icon_source)
if not shortcut:
return
@@ -1149,16 +1065,16 @@ class ContextMenuManager:
if not self._check_portproton():
return
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
is_gog_uri = str(exec_line).startswith("gog://launch/")
desktop_path = self._get_desktop_path(game_name)
if not is_store_uri and not os.path.exists(desktop_path):
if not is_gog_uri and not os.path.exists(desktop_path):
self.signals.show_warning_dialog.emit(
_("Error"),
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
)
return
# Ensure icon exists
if not is_store_uri:
if not is_gog_uri:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
@@ -1166,9 +1082,9 @@ class ContextMenuManager:
if not exec_line:
return
icon_source = None
if is_store_uri:
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
if is_gog_uri:
app_id = str(exec_line).rsplit("/", 1)[-1]
icon_source = self.parent.gog_api.get_launch_target(app_id)
shortcut = create_desktop_file(exec_line, game_name, icon_source)
if not shortcut:
return
@@ -1302,34 +1218,29 @@ class ContextMenuManager:
):
self._update_desktop_database()
def _edit_id_shortcut(self, game_name: str, cover_path: str,
store: tuple[str, str]) -> None:
source, app_id = store
app_id = str(app_id).strip()
invalid_path = app_id in {".", ".."} or os.path.basename(app_id) != app_id
if not app_id or invalid_path or (source == "steam" and not app_id.isdigit()):
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
def _edit_steam_shortcut(self, game_name: str, appid: int | str, cover_path: str) -> None:
appid_str = str(appid).strip()
if not appid_str.isdigit():
logger.warning("Invalid Steam appid for shortcut editing: %s", appid)
return
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
game_name=game_name, cover_path=cover_path, steam_appid=appid_str)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
new_name = dialog.nameEdit.text().strip()
if not new_name:
return
selected_cover = dialog.coverEdit.text().strip()
if (not os.path.isfile(selected_cover) and dialog.last_cover_path
and os.path.isfile(dialog.last_cover_path)):
if not os.path.isfile(selected_cover) and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path):
selected_cover = dialog.last_cover_path
default_data_home = os.path.join(os.path.expanduser("~"), ".local", "share")
xdg_data_home = os.getenv("XDG_DATA_HOME", default_data_home)
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data",
f"{source}-{app_id}")
xdg_data_home = os.getenv(
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
)
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", appid_str)
try:
os.makedirs(game_dir, exist_ok=True)
clean_name = new_name.replace("\r", " ").replace("\n", " ").strip()
metadata_path = os.path.join(game_dir, "metadata.txt")
with open(metadata_path, "w", encoding="utf-8") as metadata_file:
with open(os.path.join(game_dir, "metadata.txt"), "w", encoding="utf-8") as metadata_file:
metadata_file.write(f"name={clean_name}\n")
if selected_cover and os.path.isfile(selected_cover):
extension = os.path.splitext(selected_cover)[1].lower()
@@ -1341,90 +1252,22 @@ class ContextMenuManager:
shutil.copyfile(selected_cover, target_cover)
cover_path = target_cover
except OSError as e:
message = _("Failed to save custom data: {error}").format(error=str(e))
self.signals.show_warning_dialog.emit(_("Error"), message)
self.signals.show_warning_dialog.emit(
_("Error"), _("Failed to save custom data: {error}").format(error=str(e))
)
return
for game in self.game_library_manager.games:
if str(game[3]) != app_id or game[12] != source:
if str(game[3]) != appid_str or game[12] != "steam":
continue
updated_game = list(game)
updated_game[0] = new_name
updated_game[2] = cover_path
self.game_library_manager.replace_game_incremental(game[0], game[5], tuple(updated_game))
self.game_library_manager.replace_game_incremental(
game[0], game[5], tuple(updated_game)
)
QTimer.singleShot(0, self.game_library_manager.load_visible_images)
return
def handle_missing_executable(self, game_card: "GameCard") -> None:
"""Offer removal or shortcut repair when the launch file is missing."""
msg_box = QMessageBox(self.parent)
msg_box.setIcon(QMessageBox.Icon.Question)
msg_box.setWindowTitle(_("Executable not found"))
msg_box.setText(_("Executable not found: {0}").format(game_card.missing_executable_path))
delete_button = msg_box.addButton(_("Delete from PortProton"), QMessageBox.ButtonRole.DestructiveRole)
edit_button = msg_box.addButton(_("Select another executable"), QMessageBox.ButtonRole.ActionRole)
msg_box.addButton(QMessageBox.StandardButton.Cancel)
for button in msg_box.buttons():
button.ensurePolished()
button.setMinimumWidth(button.sizeHint().width())
msg_box.exec()
if msg_box.clickedButton() == delete_button:
self.delete_game(game_card.name, game_card.exec_line)
elif msg_box.clickedButton() == edit_button:
self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
def _relocate_shortcut_data(self, old_exe: str, new_exe: str) -> list[tuple[str, str]]:
"""Move path-specific data, refusing to overwrite another game's data."""
if os.path.abspath(old_exe) == os.path.abspath(new_exe):
return []
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
old_folder = resolve_custom_data_dir(custom_root, old_exe)
new_folder = os.path.join(custom_root, get_custom_data_dir_name(new_exe))
paths = [(old_folder, new_folder), (old_exe + ".ppdb", new_exe + ".ppdb")]
moves = [(source, target) for source, target in paths if os.path.exists(source)]
for _source, target in moves:
if os.path.lexists(target):
raise FileExistsError(_("File already exists: {file_name}").format(file_name=target))
completed = []
try:
for source, target in moves:
shutil.move(source, target)
completed.append((source, target))
except OSError:
for source, target in reversed(completed):
shutil.move(target, source)
raise
return completed
def _relocate_shortcut_statistics(self, old_exe: str, new_exe: str) -> None:
"""Keep playtime and last launch associated with a repaired shortcut."""
from portprotonqt.time_utils import get_last_launch_path, get_statistics_path
old_name = os.path.splitext(os.path.basename(old_exe))[0]
new_name = os.path.splitext(os.path.basename(new_exe))[0]
last_launch_path = get_last_launch_path()
records = (
(get_statistics_path(), os.path.normpath(old_exe).replace(" ", "#@_@#"), new_exe.replace(" ", "#@_@#")),
(last_launch_path, old_name, new_name),
)
for path, old_key, new_key in records:
if not os.path.isfile(path):
continue
try:
with open(path, encoding="utf-8") as stream:
lines = stream.readlines()
updated = []
for line in lines:
parts = line.rsplit(maxsplit=1) if path == last_launch_path else line.split(maxsplit=1)
if len(parts) == 2 and parts[0] == old_key:
line = f"{new_key} {parts[1].rstrip()}\n"
updated.append(line)
if updated != lines:
with open(path, "w", encoding="utf-8") as stream:
stream.writelines(updated)
except OSError as error:
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
"""
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
@@ -1435,7 +1278,7 @@ class ContextMenuManager:
cover_path: The path to the game's cover image.
"""
if appid is not None:
self._edit_id_shortcut(game_name, cover_path, ("steam", str(appid)))
self._edit_steam_shortcut(game_name, appid, cover_path)
return
if not self._check_portproton():
return
@@ -1443,7 +1286,7 @@ class ContextMenuManager:
if not exec_line:
return
old_exec_line = exec_line
exe_path = extract_exec_target_path(exec_line)
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
dialog = AddGameDialog(
@@ -1474,27 +1317,6 @@ class ContextMenuManager:
return
old_path = self._get_desktop_path(game_name)
moved_paths = []
try:
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
except OSError as e:
for source, target in reversed(moved_paths):
shutil.move(target, source)
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to save .desktop file: {error}").format(error=str(e))
)
return
for source, target in moved_paths:
if new_cover_path.startswith(source + os.sep):
new_cover_path = target + new_cover_path[len(source):]
if dialog.last_cover_path and dialog.last_cover_path.startswith(source + os.sep):
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
if exe_path != new_exe_path:
self._relocate_shortcut_statistics(exe_path, new_exe_path)
if game_name != new_name and os.path.exists(old_path):
self._remove_file(
old_path,
@@ -1502,6 +1324,17 @@ class ContextMenuManager:
_("Removed old .desktop file for '{game_name}'"),
game_name
)
try:
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
except OSError as e:
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to save .desktop file: {error}").format(error=str(e))
)
return
saved_entry = parse_desktop_entry(new_desktop_path)
updated_exec_line = new_exe_path
if saved_entry:
@@ -1562,8 +1395,8 @@ class ContextMenuManager:
old_menu_path = self._get_menu_shortcut_path(game_name)
new_menu_path = self._get_menu_shortcut_path(new_name)
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
old_desktop_path = self._get_shortcut_path(game_name, desktop_dir)
new_desktop_path_target = self._get_shortcut_path(new_name, desktop_dir)
old_desktop_path = os.path.join(desktop_dir, f"{game_name}.desktop")
new_desktop_path_target = os.path.join(desktop_dir, f"{new_name}.desktop")
if game_name != new_name:
if os.path.exists(old_menu_path):
@@ -1574,22 +1407,19 @@ class ContextMenuManager:
self.remove_from_steam(game_name, new_exe_path, "portproton")
if dialog.add_to_menu_checkbox.isChecked():
if exe_path != new_exe_path or not os.path.exists(new_menu_path):
if not os.path.exists(new_menu_path):
self.add_to_menu(new_name, updated_exec_line)
elif os.path.exists(new_menu_path):
self.remove_from_menu(new_name)
if dialog.add_to_desktop_checkbox.isChecked():
if exe_path != new_exe_path or not os.path.exists(new_desktop_path_target):
if not os.path.exists(new_desktop_path_target):
self.add_to_desktop(new_name, updated_exec_line)
elif os.path.exists(new_desktop_path_target):
self.remove_from_desktop(new_name)
is_in_steam = is_game_in_steam(new_name)
if dialog.add_to_steam_checkbox.isChecked():
if is_in_steam and exe_path != new_exe_path:
self.remove_from_steam(new_name, old_exec_line, "portproton")
is_in_steam = False
if not is_in_steam:
self.add_to_steam(new_name, updated_exec_line, new_cover_path)
elif is_in_steam:
@@ -1673,7 +1503,7 @@ class ContextMenuManager:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = extract_exec_target_path(exec_line)
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
logger.debug("Removing game '%s' from Steam", game_name)
+13 -152
View File
@@ -1,8 +1,7 @@
import os
from itertools import accumulate
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor
from PySide6.QtSvg import QSvgRenderer
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config import ui_config
@@ -41,7 +40,6 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
result = [[0, 0, 0, 0] for _ in range(N)]
min_margin = 20
available_width = rect_width - 2 * min_margin
width_sums = list(accumulate((size[0] for size in nat_sizes), initial=0))
# Fast search for max items per row
max_items_per_row = 1
@@ -57,7 +55,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
while left <= right:
mid = (left + right) // 2
end_idx = min(i + mid, N)
sum_w = width_sums[end_idx] - width_sums[i]
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
needed_width = sum_w + spacing * (mid - 1)
if needed_width <= available_width:
@@ -67,7 +65,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
right = mid - 1
count = best_count
sum_width = width_sums[i + count] - width_sums[i]
sum_width = sum(nat_sizes[j][0] for j in range(i, i + count))
if count > max_items_per_row:
max_items_per_row = count
@@ -91,7 +89,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
while left <= right:
mid = (left + right) // 2
end_idx = min(i + mid, N)
sum_w = width_sums[end_idx] - width_sums[i]
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
needed_width = sum_w + spacing * (mid - 1)
if needed_width <= available_width:
@@ -137,7 +135,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
y += int(row_max_height * global_scale) + spacing
i = j
return result, y - spacing
return result, y
class FlowLayout(QLayout):
@@ -153,29 +151,22 @@ class FlowLayout(QLayout):
self._cache_width = None
self._cache_visible_hash = None
self._cache_result = None
self._cache_visible_data = None
self._cache_minimum_size = None
def _get_visible_data(self):
"""Return list of visible items and their sizes"""
if self._cache_visible_data is not None:
return self._cache_visible_data
visible_items = []
visible_indices = []
visible_sizes = []
for i, item in enumerate(self.itemList):
widget = item.widget()
if widget and not widget.isHidden():
if widget and widget.isVisible():
visible_items.append(item)
visible_indices.append(i)
s = widget.minimumSize()
if s != widget.maximumSize():
s = item.sizeHint()
s = item.sizeHint()
visible_sizes.append((s.width(), s.height()))
self._cache_visible_data = (visible_items, visible_indices, visible_sizes)
return self._cache_visible_data
return visible_items, visible_indices, visible_sizes
def _make_visible_hash(self, visible_sizes):
"""Create hash for change detection"""
@@ -185,12 +176,6 @@ class FlowLayout(QLayout):
self.itemList.append(item)
self._invalidate_cache()
def insertWidget(self, index: int, widget: QWidget) -> None:
self.addWidget(widget)
item = self.takeAt(self.count() - 1)
self.itemList.insert(index, item)
self.invalidate()
def takeAt(self, index: int) -> QLayoutItem:
if 0 <= index < len(self.itemList):
self._invalidate_cache()
@@ -201,18 +186,11 @@ class FlowLayout(QLayout):
self._cache_width = None
self._cache_visible_hash = None
self._cache_result = None
self._cache_visible_data = None
self._cache_minimum_size = None
def invalidate(self) -> None:
self._invalidate_cache()
super().invalidate()
def setSpacing(self, spacing: int) -> None:
self._spacing = spacing
self._invalidate_cache()
super().setSpacing(spacing)
def count(self) -> int:
return len(self.itemList)
@@ -264,20 +242,12 @@ class FlowLayout(QLayout):
return self.minimumSize()
def minimumSize(self):
if self._cache_minimum_size is not None:
return QSize(self._cache_minimum_size)
size = QSize()
for item in self.itemList:
widget = item.widget()
if (widget is not None and not widget.isHidden()
and widget.minimumSize() == widget.maximumSize()):
size = size.expandedTo(widget.minimumSize())
else:
size = size.expandedTo(item.minimumSize())
size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(),
margins.top() + margins.bottom())
self._cache_minimum_size = QSize(size)
return size
def doLayout(self, rect, testOnly):
@@ -316,18 +286,10 @@ class FlowLayout(QLayout):
if not testOnly:
rx, ry = rect.x(), rect.y()
row_bounds = {}
if self.alignment() & Qt.AlignmentFlag.AlignHCenter:
for x, y, w, _ in geom_array:
left = row_bounds.get(y, (x, x))[0]
row_bounds[y] = (left, x + w)
# Set geometry for visible items
for idx, item in enumerate(visible_items):
x, y, w, h = geom_array[idx]
if y in row_bounds:
left, right = row_bounds[y]
x += max(0, (rect.width() - (right - left)) // 2) - left
item.setGeometry(QRect(x + rx, y + ry, w, h))
# Hide invisible items
@@ -580,8 +542,7 @@ class AutoSizeButton(QPushButton):
self.current_theme_name = selected_theme
self.theme = self.theme_manager.apply_theme(selected_theme)
self._uses_theme_padding = padding is None
if self._uses_theme_padding:
if padding is None:
padding = getattr(self.theme, 'autoSizeButtonPadding', 20)
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = self._normalize_padding(padding)
@@ -608,18 +569,6 @@ class AutoSizeButton(QPushButton):
self.adjustFontSize()
def refresh_theme(self, theme: object) -> None:
"""Refresh sizing values supplied by the active theme."""
self.theme = theme
if not self._uses_theme_padding:
return
padding = getattr(theme, "autoSizeButtonPadding", 20)
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = (
self._normalize_padding(padding)
)
self.adjustFontSize()
self.updateGeometry()
def _extract_icon_name(self, icon_path):
if isinstance(icon_path, str):
base = os.path.basename(icon_path)
@@ -722,7 +671,7 @@ class AutoSizeButton(QPushButton):
if icon_color and self._icon_name:
colored_path = self.theme_manager.get_colored_icon_path(
icon_path,
self._icon_name,
icon_color,
self.current_theme_name
)
@@ -873,8 +822,6 @@ class NavLabel(QLabel):
super().mousePressEvent(event)
class AutoHideScrollArea(QScrollArea):
viewportResized = Signal()
def __init__(
self,
theme,
@@ -890,33 +837,21 @@ class AutoHideScrollArea(QScrollArea):
self.fade_duration_ms = fade_duration_ms
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
self.setStyleSheet("background: transparent;")
self._v_scrollbar = self.verticalScrollBar()
self._h_scrollbar = self.horizontalScrollBar()
self._v_scrollbar.installEventFilter(self)
self._h_scrollbar.installEventFilter(self)
self._opacity_effect = QGraphicsOpacityEffect(self._v_scrollbar)
self._opacity_effect.setOpacity(0.0)
self._v_scrollbar.setGraphicsEffect(self._opacity_effect)
self._h_opacity_effect = QGraphicsOpacityEffect(self._h_scrollbar)
self._h_opacity_effect.setOpacity(0.0)
self._h_scrollbar.setGraphicsEffect(self._h_opacity_effect)
self._fade_animation = QPropertyAnimation(self._opacity_effect, b"opacity")
self._fade_animation.setDuration(self.fade_duration_ms)
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
self._h_fade_animation = QPropertyAnimation(self._h_opacity_effect, b"opacity")
self._h_fade_animation.setDuration(self.fade_duration_ms)
self._h_fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
self._is_visible = False
self._scroll_needed = False
self._h_is_visible = False
self._h_scroll_needed = False
self._apply_visible_style()
@@ -924,12 +859,7 @@ class AutoHideScrollArea(QScrollArea):
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self._start_fade_out)
self._h_hide_timer = QTimer(self)
self._h_hide_timer.setSingleShot(True)
self._h_hide_timer.timeout.connect(self._start_horizontal_fade_out)
self._v_scrollbar.valueChanged.connect(self._on_scroll)
self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
self.viewport().installEventFilter(self)
initial_widget = self.widget()
@@ -940,25 +870,6 @@ class AutoHideScrollArea(QScrollArea):
def _apply_visible_style(self) -> None:
self._v_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
self._h_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
def _start_horizontal_fade_in(self) -> None:
if not self._h_scroll_needed:
return
self._h_fade_animation.stop()
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
self._h_fade_animation.setEndValue(1.0)
self._h_fade_animation.start()
self._h_is_visible = True
def _start_horizontal_fade_out(self) -> None:
if not self._h_scroll_needed:
return
self._h_fade_animation.stop()
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
self._h_fade_animation.setEndValue(0.0)
self._h_fade_animation.start()
self._h_is_visible = False
def _start_fade_in(self) -> None:
if not self._scroll_needed:
@@ -983,28 +894,17 @@ class AutoHideScrollArea(QScrollArea):
self._opacity_effect.setOpacity(opacity)
self._is_visible = opacity == 1.0
def _set_horizontal_opacity_immediately(self, opacity: float) -> None:
self._h_fade_animation.stop()
self._h_opacity_effect.setOpacity(opacity)
self._h_is_visible = opacity == 1.0
def _update_scroll_needed(self) -> None:
widget = self.widget()
if widget is None:
self._scroll_needed = False
self._h_scroll_needed = False
self._set_opacity_immediately(0.0)
self._set_horizontal_opacity_immediately(0.0)
self._hide_timer.stop()
self._h_hide_timer.stop()
return
content_height = widget.sizeHint().height()
content_width = widget.sizeHint().width()
viewport_height = self.viewport().height()
viewport_width = self.viewport().width()
self._scroll_needed = content_height > viewport_height
self._h_scroll_needed = content_width > viewport_width
if not self._scroll_needed:
self._set_opacity_immediately(0.0)
@@ -1013,58 +913,27 @@ class AutoHideScrollArea(QScrollArea):
if not self._is_visible:
self._set_opacity_immediately(0.0)
if not self._h_scroll_needed:
self._set_horizontal_opacity_immediately(0.0)
self._h_hide_timer.stop()
elif not self._h_is_visible:
self._set_horizontal_opacity_immediately(0.0)
def _on_scroll(self, value: int) -> None:
if not self._scroll_needed:
return
self._start_fade_in()
self._hide_timer.start(self.hide_delay_ms)
def _on_horizontal_scroll(self, value: int) -> None:
if not self._h_scroll_needed:
return
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
def enterEvent(self, event):
if self._scroll_needed:
self._start_fade_in()
self._hide_timer.start(self.hide_delay_ms)
if self._h_scroll_needed:
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
def leaveEvent(self, event):
if self._scroll_needed and self._is_visible:
self._hide_timer.start(self.hide_delay_ms)
if self._h_scroll_needed and self._h_is_visible:
self._h_hide_timer.start(self.hide_delay_ms)
def wheelEvent(self, event: QWheelEvent) -> None:
widget = self.widget()
horizontal_library = widget is not None and widget.property(
"library_layout_mode"
) in {"horizontal", "horizontal_top"}
if self._h_scrollbar.maximum() > 0 and (
horizontal_library or self._v_scrollbar.maximum() == 0
):
self._h_scrollbar.event(event)
return
super().wheelEvent(event)
def eventFilter(self, obj, event):
if not hasattr(self, "_v_scrollbar") or not hasattr(self, "_h_scrollbar"):
if not hasattr(self, "_v_scrollbar"):
return super().eventFilter(obj, event)
if event.type() == QEvent.Type.Resize:
self._update_scroll_needed()
if obj == self.viewport():
self.viewportResized.emit()
elif obj == self._v_scrollbar:
if event.type() == QEvent.Type.Enter:
if self._scroll_needed:
@@ -1073,14 +942,6 @@ class AutoHideScrollArea(QScrollArea):
elif event.type() == QEvent.Type.Leave:
if self._scroll_needed and self._is_visible:
self._hide_timer.start(self.hide_delay_ms)
elif obj == self._h_scrollbar:
if event.type() == QEvent.Type.Enter:
if self._h_scroll_needed:
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
elif event.type() == QEvent.Type.Leave:
if self._h_scroll_needed and self._h_is_visible:
self._h_hide_timer.start(self.hide_delay_ms)
return super().eventFilter(obj, event)
def setWidget(self, widget: QWidget | None) -> None:
+4 -5
View File
@@ -19,16 +19,15 @@ def get_cached_vk_gpu_info() -> str:
if _vk_gpu_info_output is None:
try:
vk_gpu_info_path = os.path.join(
dev_scripts_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"build-aux",
"bin",
"dev-scripts",
"vk_gpu_info"
)
if os.path.exists(vk_gpu_info_path):
if os.path.exists(dev_scripts_path):
result = subprocess.run(
[vk_gpu_info_path],
[dev_scripts_path],
capture_output=True,
text=True,
timeout=10,
+1 -1
View File
@@ -322,7 +322,7 @@ def generate_system_info(
lines.append(_("Debug log mode was launched"))
lines.append(
_("To diagnose the problem, copy the ENTIRE log to the site:") +
" https://linux-gaming.ru/forum/help/portprotonqt-pomosch"
" https://linux-gaming.ru/t/opisanie-kategorii-portproton-pomoshh/1642"
)
lines.append("-" * 61)
+15 -127
View File
@@ -86,9 +86,6 @@ class DetailPageManager:
self._gog_size_requests: dict[
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
] = {}
self._egs_size_requests: dict[
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
] = {}
def openGameDetailPage(self, game_data: dict) -> None:
"""Open detailed game information page."""
@@ -313,7 +310,6 @@ class DetailPageManager:
game_info_layout.addLayout(first_row)
self._setup_gog_size_data(game_data, game_info_layout)
self._setup_egs_size_data(game_data, game_info_layout)
hltb_layout = QHBoxLayout()
hltb_layout.setSpacing(10)
@@ -369,75 +365,6 @@ class DetailPageManager:
)
process.start()
def _setup_egs_size_data(
self, game_data: dict, game_info_layout: QVBoxLayout
) -> None:
exec_line = str(game_data.get("exec_line", ""))
if str(game_data.get("game_source", "")).lower() != "egs":
return
if not exec_line.startswith("egs://install/"):
return
row = QHBoxLayout()
values = []
for title_text in (_("Download"), _("Size")):
title = QLabel(title_text.upper())
title.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
value = QLabel("—")
value.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
row.addWidget(title)
row.addWidget(value)
row.addSpacing(30)
values.append(weakref.ref(value))
game_info_layout.addLayout(row)
app_id = exec_line.rsplit("/", 1)[-1]
api = self.main_window.egs_api
cached = api.get_cached_download_sizes(app_id)
if cached is not None:
self._set_gog_size_values(values, cached)
return
request = self._egs_size_requests.get(app_id)
if request is not None:
request[1].append(values)
return
try:
command = api.build_command(["info", app_id, "--json", "--platform", "Windows"])
except (FileNotFoundError, OSError) as error:
logger.warning("Failed to get EGS download size: %s", error)
return
process = QProcess(self.main_window)
process.setProgram(command[0])
process.setArguments(command[1:])
environment = QProcessEnvironment.systemEnvironment()
environment.insert("LEGENDARY_CONFIG_PATH", str(api.config_dir))
process.setProcessEnvironment(environment)
self._egs_size_requests[app_id] = (process, [values])
process.finished.connect(
lambda code, _status: self._finish_egs_size_request(app_id, code)
)
process.start()
def _finish_egs_size_request(self, app_id: str, code: int) -> None:
request = self._egs_size_requests.pop(app_id, None)
if request is None:
return
process, value_groups = request
if code != 0:
logger.warning("legendary info exited with code %s", code)
process.deleteLater()
return
try:
sizes = self.main_window.egs_api.parse_download_sizes(
bytes(process.readAllStandardOutput().data())
)
except (ValueError, TypeError) as error:
logger.warning("Failed to parse EGS download size: %s", error)
process.deleteLater()
return
self.main_window.egs_api.save_download_sizes(app_id, sizes)
for value_refs in value_groups:
self._set_gog_size_values(value_refs, sizes)
process.deleteLater()
def _finish_gog_size_request(self, app_id: str, code: int) -> None:
request = self._gog_size_requests.pop(app_id, None)
if request is None:
@@ -604,32 +531,23 @@ class DetailPageManager:
current_exe = self._get_current_exe(exec_line)
play_button = self._create_play_button(exec_line, current_exe)
source = str(game_source).lower()
install_uri = exec_line.startswith(f"{source}://install/")
if source in {"gog", "egs"} and install_uri:
if str(game_source).lower() == "gog" and exec_line.startswith("gog://install/"):
play_button.setText(_("Install"))
play_button.setIcon(
self.main_window.theme_manager.get_icon("update", as_path=True)
)
buttons_layout.addWidget(play_button)
if source in {"steam", "gog", "egs"} and appid:
if str(game_source).lower() == "steam" and appid:
edit_button = self._make_action_button(
_("Edit Shortcut"),
self.main_window.theme_manager.get_icon("edit", as_path=True),
)
if source == "steam":
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
game_name, exec_line, cover_path, appid
)
)
else:
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager._edit_id_shortcut(
game_name, cover_path, (source, str(appid))
)
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
game_name, exec_line, cover_path, appid
)
)
buttons_layout.addWidget(edit_button)
elif self._has_game_shortcut(game_name):
edit_button = self._make_action_button(
@@ -652,7 +570,7 @@ class DetailPageManager:
)
buttons_layout.addWidget(open_folder_button)
elif (
str(game_source).lower() not in {"gog", "egs"}
str(game_source).lower() != "gog"
and not self._has_game_shortcut(game_name)
):
add_button = self._make_action_button(
@@ -670,26 +588,21 @@ class DetailPageManager:
if str(game_source).lower() == "portproton":
self._add_portproton_buttons(buttons_layout, exec_line)
elif (
source in {"gog", "egs"}
str(game_source).lower() == "gog"
and appid
and exec_line.startswith(f"{source}://launch/")
and exec_line.startswith("gog://launch/")
):
store_api = (
self.main_window.gog_api if source == "gog"
else self.main_window.egs_api
)
store_exe = store_api.get_launch_target(str(appid))
if not store_exe:
gog_exe = self.main_window.gog_api.get_launch_target(str(appid))
if not gog_exe:
return buttons_layout
if source == "gog":
store_api.ensure_launch_parameters(str(appid))
self.main_window.gog_api.ensure_launch_parameters(str(appid))
settings_button = self._make_action_button(
_("Settings"),
self.main_window.theme_manager.get_icon("settings", as_path=True),
)
settings_button.clicked.connect(
lambda: self.main_window.open_exe_settings(
store_exe, game_source=source,
gog_exe, game_source="gog",
)
)
buttons_layout.addWidget(settings_button)
@@ -697,9 +610,7 @@ class DetailPageManager:
_("Create Log"),
self.main_window.theme_manager.get_icon("edit", as_path=True),
)
log_button.clicked.connect(
lambda: self.toggleDebugLog(store_exe, log_button)
)
log_button.clicked.connect(lambda: self.toggleDebugLog(gog_exe, log_button))
buttons_layout.addWidget(log_button)
self._debug_log_button = log_button
folder_button = self._make_action_button(
@@ -707,7 +618,7 @@ class DetailPageManager:
self.main_window.theme_manager.get_icon("search", as_path=True),
)
folder_button.clicked.connect(
lambda: self._open_executable_folder(store_exe)
lambda: self._open_executable_folder(gog_exe)
)
buttons_layout.addWidget(folder_button)
elif str(game_source).lower() == "steam" and appid:
@@ -922,32 +833,17 @@ class DetailPageManager:
return_tab_index = getattr(self, "_return_to_tab_index", 0)
current_page = self._current_detail_page
running_button = self.main_window.current_running_button
debug_log_button = self._debug_log_button
replace_running_button = (
current_page is not None
and running_button is not None
and current_page.isAncestorOf(running_button)
)
replace_debug_log_button = (
current_page is not None
and debug_log_button is not None
and current_page.isAncestorOf(debug_log_button)
)
self._remove_current_detail_page()
if source_kind == "autoinstall":
self.openAutoInstallDetailPage(source_data)
else:
self.openGameDetailPage(source_data)
self._return_to_tab_index = return_tab_index
if (
replace_debug_log_button
and self.debug_log_manager.is_running
and self._debug_log_button is not None
):
self._debug_log_button.setText(_("Stop Log"))
icon = self.main_window.theme_manager.get_icon("stop", as_path=True)
if icon:
self._debug_log_button.setIcon(icon)
if replace_running_button and self._current_detail_page is not None:
play_button = self._find_play_button(self._current_detail_page)
if play_button is not None:
@@ -1023,9 +919,6 @@ class DetailPageManager:
)
)
content_frame_layout.addLayout(page_data["buttons_layout"])
content_frame_layout.addSpacing(
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
)
def _get_compact_content_spacing(self) -> int:
return getattr(
@@ -1201,9 +1094,6 @@ class DetailPageManager:
)
)
content_frame_layout.addLayout(page_data["buttons_layout"])
content_frame_layout.addSpacing(
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
)
main_layout = self._get_main_layout(detail_page)
if main_layout:
@@ -1454,8 +1344,6 @@ class DetailPageManager:
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
if resolved_exe_path is None:
return
if not self.main_window._check_alt_i586_dependencies_before_launch():
return
start_command = get_portproton_start_command()
if not start_command:
+28 -84
View File
@@ -4,9 +4,9 @@ import os
import re
from weakref import WeakKeyDictionary
from collections.abc import Callable
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsBlurEffect, QGraphicsOpacityEffect, QGraphicsPixmapItem, QGraphicsScene
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsOpacityEffect
from PySide6.QtCore import QTimer, Qt, QObject, Signal, QPropertyAnimation, QByteArray
from PySide6.QtGui import QBrush, QImage, QPainter, QPalette, QPixmap
from PySide6.QtGui import QPixmap
from portprotonqt.image_utils import get_animated_cover_pixmap
from portprotonqt.image_utils import load_pixmap_async, round_corners, set_animated_cover
@@ -85,7 +85,7 @@ def _set_animated_cover(
detail_page, image_label = cover_widgets
if not set_animated_cover(image_label, cover_path, cover_size[0], cover_size[1], 10):
return False
setup_cover_background(detail_page, get_animated_cover_pixmap(image_label), main_window)
_setup_palette_stylesheet(detail_page, get_animated_cover_pixmap(image_label), main_window)
_reveal_cover(image_label, main_window.theme)
return True
@@ -99,7 +99,7 @@ def _on_pixmap_ready(pixmap: QPixmap, detail_page: QWidget, image_label: QLabel,
image_label.setPixmap(rounded)
_reveal_cover(image_label, main_window.theme)
logger.debug("Pixmap set for imageLabel")
setup_cover_background(detail_page, pixmap, main_window)
_setup_palette_stylesheet(detail_page, pixmap, main_window)
except RuntimeError:
logger.warning("Detail page already deleted, skipping pixmap update")
@@ -118,16 +118,7 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
effect = QGraphicsOpacityEffect(image_label)
image_label.setGraphicsEffect(effect)
animation_type = theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
duration_key = "detail_page_fade_duration"
default_duration = 350
if animation_type.startswith("slide_"):
duration_key = "detail_page_slide_duration"
default_duration = 500
elif animation_type == "bounce":
duration_key = "detail_page_bounce_duration"
default_duration = 400
duration = theme.GAME_CARD_ANIMATION.get(duration_key, default_duration)
duration = theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
animation = QPropertyAnimation(effect, QByteArray(b"opacity"), image_label)
animation.setDuration(duration)
animation.setStartValue(effect.opacity())
@@ -138,75 +129,25 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
animation.start()
def setup_cover_background(
detail_page: QWidget, pixmap: QPixmap | None, main_window, theme=None
) -> None:
def _setup_palette_stylesheet(detail_page: QWidget, pixmap: QPixmap | None, main_window) -> None:
"""Setup palette-based stylesheet after image load."""
if pixmap is None:
return
active_theme = theme or main_window.theme
if getattr(active_theme, "DETAIL_PAGE_BG_MODE", "gradient") == "blur":
_apply_blurred_cover(detail_page, pixmap, active_theme)
return
def on_palette_ready(palette: list) -> None:
_on_palette_ready(palette, detail_page, main_window, active_theme)
_on_palette_ready(palette, detail_page, main_window)
num_colors = getattr(active_theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
num_colors = getattr(main_window.theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
if num_colors < 2:
num_colors = 2
main_window.getColorPalette_from_pixmap(pixmap, num_colors=num_colors, callback=on_palette_ready)
def remove_cover_background(widget: QWidget) -> None:
"""Remove a shared cover-driven background and stop its animation."""
_remove_wave_background(widget)
_detail_backgrounds.remove(widget)
widget.setStyleSheet("")
widget.setAutoFillBackground(False)
if isinstance(widget, QLabel):
widget.clear()
def _on_palette_ready(
palette: list, detail_page: QWidget, main_window, theme
) -> None:
def _on_palette_ready(palette: list, detail_page: QWidget, main_window) -> None:
"""Handle palette ready callback."""
if not _is_detail_page_valid(detail_page):
return
_apply_palette_stylesheet(detail_page, palette, main_window, theme)
def _apply_blurred_cover(widget: QWidget, pixmap: QPixmap, theme) -> None:
config = theme.DETAIL_PAGE_BACKGROUNDS.get("blur", {})
width, height = config.get("render_resolution", (1280, 720))
scaled = pixmap.scaled(
width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
source = scaled.copy(
(scaled.width() - width) // 2, (scaled.height() - height) // 2,
width, height,
)
item = QGraphicsPixmapItem(source)
effect = QGraphicsBlurEffect()
effect.setBlurRadius(config.get("radius", 64))
item.setGraphicsEffect(effect)
scene = QGraphicsScene()
scene.addItem(item)
image = QImage(source.size(), QImage.Format.Format_ARGB32_Premultiplied)
image.fill(Qt.GlobalColor.transparent)
painter = QPainter(image)
scene.render(painter)
painter.end()
background = QPixmap.fromImage(image).scaled(widget.size())
if isinstance(widget, QLabel):
widget.setPixmap(background)
return
palette = widget.palette()
palette.setBrush(QPalette.ColorRole.Window, QBrush(background))
widget.setPalette(palette)
widget.setAutoFillBackground(True)
_apply_palette_stylesheet(detail_page, palette, main_window)
def _build_palette_stops(dark_palette: list) -> str:
@@ -278,16 +219,14 @@ def _resolve_gradient_config(theme: object) -> dict:
return config
def _apply_palette_stylesheet(
detail_page: QWidget, palette: list, main_window, theme
) -> None:
def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window) -> None:
"""Apply palette-based stylesheet to detail page."""
try:
dark_palette = [
main_window.darkenColor(color, factor=200) for color in palette
]
gradient_config = _resolve_gradient_config(theme)
stops = _resolve_gradient_stops(theme, dark_palette)
gradient_config = _resolve_gradient_config(main_window.theme)
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
gradient_type = gradient_config.get("type", "linear")
if gradient_type == 'radial':
@@ -320,7 +259,7 @@ def _apply_palette_stylesheet(
"""
detail_page.setStyleSheet(stylesheet)
_setup_wave_background(detail_page, dark_palette, theme)
_setup_wave_background(detail_page, dark_palette, main_window.theme)
detail_page.update()
logger.debug("Stylesheet updated with palette")
except RuntimeError:
@@ -339,7 +278,7 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
return
_detail_backgrounds.remove(detail_page)
if bg_mode not in {"waves", "static_waves"}:
if bg_mode == "gradient":
_remove_wave_background(detail_page)
return
@@ -705,6 +644,11 @@ def _check_desktop_file(
return autoinstall_exe.lower() in (exec_name, real_exec_name)
def _get_autoinstall_exe_name(script_name: str) -> str:
autoinstall_exe, _autoinstall_path = _get_autoinstall_exe_target(script_name)
return autoinstall_exe
def _get_autoinstall_exe_target(script_name: str) -> tuple[str, str]:
script_path = script_name
if not os.path.isfile(script_path):
@@ -745,14 +689,14 @@ def _extract_exe_name(line: str) -> str:
def _extract_exe_path(line: str) -> str:
paths = re.findall(r"['\"]([^'\"]+\.exe)['\"]", line.replace("\\", "/"), re.IGNORECASE)
for path in paths:
drive_index = path.lower().find("drive_c/")
if drive_index >= 0:
return path[drive_index:].lower()
if "/" in path and not path.startswith("$"):
return path.lower()
return ""
match = re.search(r"['\"]([^'\"]+\.exe)['\"]", line.replace("\\", "/"), re.IGNORECASE)
if not match:
return ""
path = match.group(1)
drive_index = path.lower().find("drive_c/")
if drive_index < 0:
return ""
return path[drive_index:].lower()
def _path_matches_autoinstall(exec_path: str, autoinstall_path: str) -> bool:
+23 -8
View File
@@ -84,7 +84,6 @@ def create_content_frame(
) -> tuple[QFrame, QBoxLayout]:
"""Create content frame."""
content_frame = QFrame()
content_frame.setProperty("theme_style_name", "DETAIL_CONTENT_FRAME_STYLE")
content_frame.setStyleSheet(theme.DETAIL_CONTENT_FRAME_STYLE)
content_frame_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, content_frame)
content_frame_layout.setContentsMargins(20, 20, 20, 20)
@@ -140,7 +139,7 @@ def _add_source_corner(
) -> None:
"""Add source corner badge to cover frame."""
source = game_source.lower()
if source not in ("steam", "gog", "egs", "portproton") or theme_manager is None:
if source not in ("steam", "gog", "portproton") or theme_manager is None:
return
icon_name = f"badge_{source}"
@@ -183,7 +182,6 @@ def create_compact_detail_header(
header_widget.setFixedHeight(cover_frame.height())
title_frame = QFrame(header_widget)
title_frame.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
title_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
title_frame.setMaximumHeight(cover_frame.height())
title_frame.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
@@ -194,7 +192,6 @@ def create_compact_detail_header(
title_layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
title_label = QLabel(title)
title_label.setWordWrap(True)
title_label.setProperty("theme_style_name", "COMPACT_DETAIL_PAGE_TITLE_STYLE")
title_label.setStyleSheet(theme.COMPACT_DETAIL_PAGE_TITLE_STYLE)
title_layout.addWidget(title_label, alignment=Qt.AlignmentFlag.AlignVCenter)
header_layout.addWidget(title_frame, stretch=1)
@@ -204,7 +201,6 @@ def create_compact_detail_header(
def create_compact_layout_panel(parent: QWidget, theme, content_layout: QLayout) -> QWidget:
"""Create compact panel for an existing layout."""
panel = QWidget(parent)
panel.setProperty("theme_style_name", "COMPACT_PLAYTIME_WIDGET_STYLE")
panel.setStyleSheet(theme.COMPACT_PLAYTIME_WIDGET_STYLE)
panel.setLayout(content_layout)
content_layout.setContentsMargins(
@@ -222,7 +218,6 @@ def create_compact_description_panel(
) -> QWidget:
"""Create compact description block."""
desc_widget = QWidget(parent)
desc_widget.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
desc_widget.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
desc_layout = QVBoxLayout(desc_widget)
desc_layout.setContentsMargins(
@@ -328,6 +323,28 @@ def create_protondb_badge(
return badge, True
def create_steam_badge(
parent: QWidget,
appid: str,
main_window,
) -> ClickableLabel:
"""Create Steam badge."""
steam_icon = main_window.theme_manager.get_icon("badge_steam", as_path=True)
badge = ClickableLabel(
"Steam",
icon=steam_icon,
parent=parent,
icon_size=getattr(main_window.theme, "BADGE", {}).get("icon_size", 16),
icon_space=5,
)
badge.setStyleSheet(main_window.theme.STEAM_BADGE_STYLE)
_apply_badge_view_mode(badge, main_window.theme)
badge.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}"))
)
return badge
def create_portproton_badge(
parent: QWidget,
main_window,
@@ -402,7 +419,6 @@ def create_details_widget(
) -> QWidget:
"""Create details widget with title, description, and optional content."""
details_widget = QWidget()
details_widget.setProperty("theme_style_name", "DETAILS_WIDGET_STYLE")
details_widget.setStyleSheet(main_window.theme.DETAILS_WIDGET_STYLE)
details_layout = QVBoxLayout(details_widget)
@@ -445,7 +461,6 @@ def create_details_widget(
def _add_details_header(layout: QVBoxLayout, theme, title: str) -> None:
"""Add title and divider line to details layout."""
title_label = QLabel(title)
title_label.setProperty("theme_style_name", "DETAIL_PAGE_TITLE_STYLE")
title_label.setStyleSheet(theme.DETAIL_PAGE_TITLE_STYLE)
layout.addWidget(title_label)
-1
View File
@@ -79,7 +79,6 @@ class AppImageUpdateDialog(DraggableDialog):
if self.input_manager:
self.input_manager.enable_appimage_update_mode(self)
self.finished.connect(self.input_manager.disable_appimage_update_mode)
def _make_button(self, text: str) -> AutoSizeButton:
button = AutoSizeButton(text)
+13 -68
View File
@@ -8,13 +8,9 @@ from PIL import Image, ImageQt
from PySide6.QtGui import QPixmap, QIcon, QImage, QImageReader
from PySide6.QtWidgets import (
QVBoxLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy, QStyle,
QStyledItemDelegate, QStyleOptionViewItem
)
from PySide6.QtCore import (
Qt, QObject, Signal, QMimeDatabase, QModelIndex, QPersistentModelIndex,
QThreadPool, QRunnable, Slot, QSize, QTimer
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy
)
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QThreadPool, QRunnable, Slot, QSize, QTimer
if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow
@@ -36,25 +32,6 @@ DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
THUMBNAIL_PRELOAD_ROWS = 5
THUMBNAIL_QUEUE_LIMIT = 24
THUMBNAIL_SCROLL_DELAY_MS = 80
THEMED_ICON_ROLE = Qt.ItemDataRole.UserRole.value + 1
class FileIconDelegate(QStyledItemDelegate):
"""Draw themed list icons in their active mode on hover."""
def initStyleOption(
self,
option: QStyleOptionViewItem,
index: QModelIndex | QPersistentModelIndex,
) -> None:
super().initStyleOption(option, index)
if not index.data(THEMED_ICON_ROLE):
return
if not option.state & QStyle.StateFlag.State_MouseOver:
return
pixmap = option.icon.pixmap(option.decorationSize, QIcon.Mode.Active)
if not pixmap.isNull():
option.icon = QIcon(pixmap)
class FileExplorer(DraggableDialog):
@@ -193,12 +170,12 @@ class FileExplorer(DraggableDialog):
self.main_layout.addWidget(self.hints_widget)
if self.input_manager:
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
)
self.input_manager.connect_surface_updates('file_explorer', update_hints)
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def async_load_thumbnails(self, files, mime_db):
@@ -248,41 +225,15 @@ class FileExplorer(DraggableDialog):
self.thumbnail_cache[file_path] = icon
item = self.file_items.get(file_path)
if item:
item.setData(THEMED_ICON_ROLE, None)
item.setIcon(icon)
def _get_file_type_icon(self, file_path: str) -> QIcon | None:
icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(file_path)[1].lower())
if not icon_name:
return None
icon = self._get_list_icon(icon_name)
icon = theme_manager.get_icon(icon_name)
return icon if isinstance(icon, QIcon) and not icon.isNull() else None
def _get_list_icon(self, icon_name: str) -> QIcon:
icon_path = theme_manager.get_icon(icon_name, as_path=True)
if not isinstance(icon_path, str) or not os.path.exists(icon_path):
return QIcon()
icon = QIcon()
icon.addFile(icon_path, QSize(), QIcon.Mode.Normal)
colors = getattr(self.theme, "ICON_COLORS", {})
if not isinstance(colors, dict):
return icon
modes = {
"hover": QIcon.Mode.Active,
"focused": QIcon.Mode.Selected,
"disabled": QIcon.Mode.Disabled,
}
for state, mode in modes.items():
color = colors.get(f"{icon_name}_{state}", colors.get(f"*_{state}"))
if not isinstance(color, str):
continue
colored_path = theme_manager.get_colored_icon_path(icon_name, color)
if colored_path:
icon.addFile(colored_path, QSize(), mode)
return icon
def schedule_thumbnail_loading(self) -> None:
"""Schedule thumbnail loading after scroll events settle."""
self.thumbnail_timer.start(THUMBNAIL_SCROLL_DELAY_MS)
@@ -358,7 +309,6 @@ class FileExplorer(DraggableDialog):
self.main_layout.addWidget(self.path_label)
self.file_list = QListWidget()
self.file_list.setItemDelegate(FileIconDelegate(self.file_list))
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE + self.theme.SCROLL_STYLE)
self.file_list.itemClicked.connect(self.handle_item_click)
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
@@ -580,17 +530,15 @@ class FileExplorer(DraggableDialog):
try:
if self.directory_only:
item = QListWidgetItem("./")
folder_icon = self._get_list_icon("folder")
folder_icon = theme_manager.get_icon("folder")
if isinstance(folder_icon, QIcon):
item.setIcon(folder_icon)
item.setData(THEMED_ICON_ROLE, True)
self.file_list.addItem(item)
if self.current_path != "/":
item = QListWidgetItem("../")
folder_icon = self._get_list_icon("folder")
folder_icon = theme_manager.get_icon("folder")
if isinstance(folder_icon, QIcon):
item.setIcon(folder_icon)
item.setData(THEMED_ICON_ROLE, True)
self.file_list.addItem(item)
items = os.listdir(self.current_path)
@@ -598,10 +546,9 @@ class FileExplorer(DraggableDialog):
for d in sorted(dirs):
item = QListWidgetItem(f"{d}/")
folder_icon = self._get_list_icon("folder")
folder_icon = theme_manager.get_icon("folder")
if isinstance(folder_icon, QIcon):
item.setIcon(folder_icon)
item.setData(THEMED_ICON_ROLE, True)
self.file_list.addItem(item)
if not self.directory_only:
@@ -619,7 +566,6 @@ class FileExplorer(DraggableDialog):
file_type_icon = self._get_file_type_icon(file_path)
if file_type_icon:
item.setIcon(file_type_icon)
item.setData(THEMED_ICON_ROLE, True)
if (
f.lower().endswith(".ppack")
or mime_type.name() == "application/x-portproton-prefix-backup"
@@ -627,10 +573,9 @@ class FileExplorer(DraggableDialog):
or mime_type.name() in ("application/vnd.squashfs", "application/x-squashfs")
or mime_type.inherits("application/vnd.squashfs")
):
ppack_icon = self._get_list_icon("ppack")
ppack_icon = theme_manager.get_icon("ppack")
if isinstance(ppack_icon, QIcon):
item.setIcon(ppack_icon)
item.setData(THEMED_ICON_ROLE, True)
self.file_items[file_path] = item
self.file_list.addItem(item)
+100 -214
View File
@@ -9,23 +9,19 @@ from PySide6.QtWidgets import (
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout,
QWidget, QCheckBox, QHeaderView, QMessageBox, QLabel, QTextEdit,
QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView,
QStackedWidget
QStackedWidget, QPushButton
)
from PySide6.QtCore import QEvent, QMimeData, QObject, Qt, QTimer, QUrl
from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QGuiApplication
from shiboken6 import isValid
from PySide6.QtCore import Qt, QTimer, QEvent, QObject
from PySide6.QtGui import QGuiApplication
from portprotonqt.config import get_portproton_start_command, ui_config
from portprotonqt.dialogs.base import DraggableDialog
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.localization import _
from portprotonqt.version_utils import version_sort_key
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
from portprotonqt.dialogs.wine_loader import (
InstalledWineSizeThread, WineLoadingThread, get_cpu_level,
)
from portprotonqt.dialogs.wine_loader import WineLoadingThread, get_cpu_level
from portprotonqt.dialogs.wine_downloader import DownloadThread
from portprotonqt.dialogs.wine_extractor import ExtractionThread
from portprotonqt.preloader import Preloader
@@ -33,8 +29,6 @@ from portprotonqt.steam_api import get_steam_compatibilitytools_dir, get_steam_h
logger = get_logger(__name__)
theme_manager = ThemeManager()
WINE_TABLE_ROW_BATCH_SIZE = 8
WINE_ARCHIVE_EXTENSIONS = ('.tar.xz', '.tar.gz')
class ProtonManager(DraggableDialog):
@@ -53,8 +47,6 @@ class ProtonManager(DraggableDialog):
self.input_manager = input_manager
self.initial_command_executed = False
self.wine_loading_thread = None
self._installed_size_tables = {}
self.setAcceptDrops(True)
self.main_window = None
parent_widget = self.parent()
@@ -70,48 +62,6 @@ class ProtonManager(DraggableDialog):
if self.input_manager:
self.enable_proton_manager_mode()
@staticmethod
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
if not mime_data.hasUrls():
return []
archive_paths = []
for url in mime_data.urls():
if not url.isLocalFile():
continue
archive_path = url.toLocalFile()
if os.path.isfile(archive_path) and archive_path.lower().endswith(
WINE_ARCHIVE_EXTENSIONS
):
archive_paths.append(archive_path)
return archive_paths
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
if self._get_dropped_wine_archives(event.mimeData()):
event.acceptProposedAction()
return
event.ignore()
def dropEvent(self, event: QDropEvent) -> None:
archive_paths = self._get_dropped_wine_archives(event.mimeData())
if not archive_paths or self.is_downloading:
event.ignore()
return
self.install_local_archives(archive_paths)
event.acceptProposedAction()
def install_local_archives(self, archive_paths: list[str]) -> None:
self.assets_to_download = [
{
'source_name': 'proton_lg',
'asset_name': os.path.basename(path),
'local_path': path,
}
for path in archive_paths
]
self.current_download_index = 0
self.is_downloading = True
self.start_next_download()
def _init_gamepad_tooltip(self) -> None:
self._gamepad_tooltip_map = {}
self.gamepad_tooltip = QLabel()
@@ -240,7 +190,7 @@ class ProtonManager(DraggableDialog):
self.download_progress = QProgressBar()
self.download_progress.setMinimum(0)
self.download_progress.setMaximum(100)
self.cancel_btn = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
self.cancel_btn = QPushButton(_('Cancel'))
self.cancel_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_btn.clicked.connect(self.cancel_current_download)
progress_layout.addWidget(self.download_progress, 4)
@@ -256,52 +206,41 @@ class ProtonManager(DraggableDialog):
layout.addWidget(self.hints_widget)
button_layout = QHBoxLayout()
self.download_btn = AutoSizeButton(_('Download Selected'), icon=theme_manager.get_icon('apply', as_path=True))
self.download_btn = QPushButton(_('Download Selected'))
self.download_btn.clicked.connect(self.download_selected)
self.download_btn.setEnabled(False)
self.download_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.clear_btn = AutoSizeButton(_('Clear All'), icon=theme_manager.get_icon('delete', as_path=True))
self.clear_btn = QPushButton(_('Clear All'))
self.clear_btn.clicked.connect(self.clear_selection)
self.clear_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.open_folder_btn = AutoSizeButton(_('Open Folder'), icon=theme_manager.get_icon('folder', as_path=True))
self.open_folder_btn.clicked.connect(self._open_wine_folder)
self.open_folder_btn.setEnabled(bool(self.portproton_location))
self.open_folder_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_btn_dialog = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
self.cancel_btn_dialog = QPushButton(_('Cancel'))
self.cancel_btn_dialog.clicked.connect(self.reject)
self.cancel_btn_dialog.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button_layout.addWidget(self.download_btn)
button_layout.addWidget(self.clear_btn)
button_layout.addWidget(self.open_folder_btn)
button_layout.addWidget(self.cancel_btn_dialog)
layout.addLayout(button_layout)
self.tab_widget.currentChanged.connect(self.tab_changed)
if self.input_manager and self.main_window:
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
theme_manager, self.current_theme_name
)
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name
)
)
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name
)
def _open_wine_folder(self) -> None:
if not self.portproton_location:
return
wine_folder = os.path.join(self.portproton_location, "data", "dist")
try:
os.makedirs(wine_folder, exist_ok=True)
except OSError as error:
logger.warning("Failed to create Wine folder %s: %s", wine_folder, error)
return
if not QDesktopServices.openUrl(QUrl.fromLocalFile(wine_folder)):
logger.warning("Failed to open Wine folder %s", wine_folder)
def start_loading_wine_data(self):
self.wine_loading_thread = WineLoadingThread()
self.wine_loading_thread.loading_complete.connect(self.on_wine_data_loaded)
@@ -310,47 +249,9 @@ class ProtonManager(DraggableDialog):
def on_wine_data_loaded(self, metadata):
self.wine_metadata = metadata
self._start_incremental_wine_tabs(metadata)
def _start_incremental_wine_tabs(self, metadata: dict) -> None:
self.cpu_level = get_cpu_level()
logger.info("Detected CPU level: %s", self.cpu_level)
tabs = []
for source_name, entries in metadata.items():
filtered_entries = self.filter_entries_by_cpu_level(entries, source_name)
tabs.append((source_name, sorted(filtered_entries, key=version_sort_key)))
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
self.selected_assets.clear()
self.tab_widget.clear()
self._wine_tab_queue = tabs
self._wine_current_tab = None
QTimer.singleShot(0, self._add_next_wine_row_batch)
def _add_next_wine_row_batch(self) -> None:
if self._wine_current_tab is None:
if not self._wine_tab_queue:
self.create_installed_tab()
self.update_selection_display()
self.content_stack.setCurrentIndex(1)
return
source_name, entries = self._wine_tab_queue.pop(0)
table = self._create_empty_wine_tab(source_name, len(entries))
self._wine_current_tab = (source_name, entries, table, 0)
source_name, entries, table, row_index = self._wine_current_tab
batch_end = min(row_index + WINE_TABLE_ROW_BATCH_SIZE, len(entries))
table.setUpdatesEnabled(False)
try:
for index in range(row_index, batch_end):
self.add_asset_row_from_json(table, index, entries[index], source_name)
finally:
table.setUpdatesEnabled(True)
if batch_end == len(entries):
logger.info("Successfully created tab for %s", source_name)
self._wine_current_tab = None
else:
self._wine_current_tab = (source_name, entries, table, batch_end)
QTimer.singleShot(0, self._add_next_wine_row_batch)
self.refresh_wine_tabs()
if hasattr(self, 'content_stack'):
self.content_stack.setCurrentIndex(1)
def on_wine_data_load_error(self, error_msg):
logger.error(f"Wine data loading failed: {error_msg}")
@@ -384,14 +285,15 @@ class ProtonManager(DraggableDialog):
def filter_entries_by_cpu_level(self, entries, source_name):
is_arm = platform.machine().lower().startswith(('arm', 'aarch64'))
filtered_entries = []
for entry in entries:
entry_info = f"{entry.get('name', '')} {entry.get('url', '')}".lower()
entry_is_arm = 'aarch64' in entry_info or '-arm' in entry_info
if entry_is_arm == is_arm:
filtered_entries.append(entry)
entries = filtered_entries
if source_name.lower() not in ('proton_cachyos', 'proton_wineland'):
if not is_arm:
entries = [
entry for entry in entries
if 'aarch64' not in entry.get('name', '').lower()
and '-arm' not in entry.get('name', '').lower()
and 'aarch64' not in entry.get('url', '').lower()
and '-arm' not in entry.get('url', '').lower()
]
if source_name.lower() != 'proton_cachyos':
return entries
if self.cpu_level >= 4:
return entries
@@ -439,6 +341,12 @@ class ProtonManager(DraggableDialog):
def create_tab_from_entries(self, source_name, entries):
try:
tab = QWidget()
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout = QVBoxLayout(tab)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
table = self.create_table_widget()
all_entries = []
for entry in entries:
url = entry.get('url', '')
@@ -449,28 +357,18 @@ class ProtonManager(DraggableDialog):
entry['filename'] = url_filename
all_entries.append(entry)
all_entries.sort(key=version_sort_key)
table = self._create_empty_wine_tab(source_name, len(all_entries))
table.setRowCount(len(all_entries))
for row_index, entry in enumerate(all_entries):
self.add_asset_row_from_json(table, row_index, entry, source_name)
layout.addWidget(table, 1)
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper()
self.tab_widget.addTab(tab, tab_name)
logger.info(f"Successfully created tab for {source_name}")
return True
except Exception as e:
logger.error(f"Error creating tab for {source_name}: {e}")
return False
def _create_empty_wine_tab(self, source_name: str, row_count: int) -> QTableWidget:
tab = QWidget()
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout = QVBoxLayout(tab)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
table = self.create_table_widget()
table.setRowCount(row_count)
layout.addWidget(table, 1)
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper()
self.tab_widget.addTab(tab, tab_name)
return table
def get_short_source_name(self, full_name):
if full_name is None:
return "UNKNOWN"
@@ -608,24 +506,6 @@ class ProtonManager(DraggableDialog):
self.add_installed_row(table, row_index, version_name, version_path)
layout.addWidget(table, 1)
self.tab_widget.addTab(tab, _("Installed"))
size_thread = InstalledWineSizeThread(installed_versions, self)
self._installed_size_tables[size_thread] = table
size_thread.sizes_loaded.connect(self._on_installed_sizes_loaded)
size_thread.finished.connect(self._on_installed_size_thread_finished)
size_thread.finished.connect(size_thread.deleteLater)
size_thread.start()
def _on_installed_sizes_loaded(self, sizes: list[str]) -> None:
table = self._installed_size_tables.get(self.sender())
if table is None or not isValid(table):
return
for row_index, size in enumerate(sizes):
size_item = table.item(row_index, 2)
if size_item is not None and size:
size_item.setText(size)
def _on_installed_size_thread_finished(self) -> None:
self._installed_size_tables.pop(self.sender(), None)
def get_installed_versions(self) -> list[tuple[str, str]]:
portproton_location = self.portproton_location
@@ -638,16 +518,15 @@ class ProtonManager(DraggableDialog):
installed_versions = []
seen_paths = set()
version_dirs = [dist_path]
if not ui_config.get_disable_steam_proton_scanning():
steam_dir = get_steam_compatibilitytools_dir()
if steam_dir is not None:
version_dirs.append(str(steam_dir))
steam_home = get_steam_home()
if steam_home is not None:
for steam_lib in get_steam_libs(steam_home):
common_dir = steam_lib / "steamapps" / "common"
if common_dir.is_dir():
version_dirs.append(str(common_dir))
steam_dir = get_steam_compatibilitytools_dir()
if steam_dir is not None:
version_dirs.append(str(steam_dir))
steam_home = get_steam_home()
if steam_home is not None:
for steam_lib in get_steam_libs(steam_home):
common_dir = steam_lib / "steamapps" / "common"
if common_dir.is_dir():
version_dirs.append(str(common_dir))
for version_dir in version_dirs:
for version_name in os.listdir(version_dir):
@@ -683,7 +562,8 @@ class ProtonManager(DraggableDialog):
version_item = QTableWidgetItem(version_name)
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row_index, 1, version_item)
size_item = QTableWidgetItem(_("Unknown"))
size_str = self.get_directory_size(version_path)
size_item = QTableWidgetItem(size_str)
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row_index, 2, size_item)
for col in range(table.columnCount()):
@@ -697,6 +577,30 @@ class ProtonManager(DraggableDialog):
def on_installed_version_toggled(self, state):
self.update_selection_display()
def get_directory_size(self, path):
try:
total_size = 0
for dirpath, _dirnames, filenames in os.walk(path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
if total_size == 0:
return "0 B"
elif total_size < 1024:
return f"{total_size}.0 B"
elif total_size < 1024 * 1024:
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
elif total_size < 1024 * 1024 * 1024:
return f"{int(total_size / (1024 * 1024))}.{int((total_size / (1024 * 1024) * 10) % 10)} MiB"
elif total_size < 1024 * 1024 * 1024 * 1024:
return f"{int(total_size / (1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024) * 10) % 10)} GiB"
else:
return f"{int(total_size / (1024 * 1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024 * 1024) * 10) % 10)} TiB"
except Exception as e:
logger.debug("Failed to format size: %s", e)
return _("Unknown")
def convert_size_to_bytes(self, size_str):
if not size_str or size_str == _("Unknown"):
return 0
@@ -1048,20 +952,10 @@ class ProtonManager(DraggableDialog):
QMessageBox.information(self, _("Downloading Complete"), _("All selected WINE/Proton have been downloaded!"))
return
asset_data = self.assets_to_download[self.current_download_index]
local_path = asset_data.get('local_path')
if local_path:
self.download_progress.setValue(0)
self.download_frame.setVisible(True)
self.download_frame.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
self.download_btn.setEnabled(False)
self.clear_btn.setEnabled(False)
self.start_extraction_for_asset(asset_data, local_path)
return
self.download_asset(asset_data)
def download_asset(self, asset_data):
temp_dir = tempfile.mkdtemp(prefix="portproton_wine_")
asset_data['cleanup_dir'] = temp_dir
filename = os.path.join(temp_dir, asset_data['asset_name'])
download_url = asset_data['download_url']
download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']}"
@@ -1132,13 +1026,12 @@ class ProtonManager(DraggableDialog):
else:
logger.error(f"Failed to extract: {archive_path}")
QMessageBox.critical(self, _("Extraction Error"), _("Failed to extract archive: {0}").format(archive_path))
cleanup_dir = asset_data.get('cleanup_dir')
if cleanup_dir:
try:
shutil.rmtree(cleanup_dir)
logger.debug(f"Cleaned up temporary directory: {cleanup_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
temp_dir = os.path.dirname(filepath)
try:
shutil.rmtree(temp_dir)
logger.debug(f"Cleaned up temporary directory: {temp_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
logger.debug("Waiting for extraction thread to finish...")
if not self.current_extraction_thread.wait(500):
@@ -1148,13 +1041,12 @@ class ProtonManager(DraggableDialog):
def extraction_error(error_msg):
logger.error(f"Extraction error: {error_msg}")
QMessageBox.critical(self, "Extraction Error", f"Failed to extract archive: {error_msg}")
cleanup_dir = asset_data.get('cleanup_dir')
if cleanup_dir:
try:
shutil.rmtree(cleanup_dir)
logger.debug(f"Cleaned up temporary directory after error: {cleanup_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
temp_dir = os.path.dirname(filepath)
try:
shutil.rmtree(temp_dir)
logger.debug(f"Cleaned up temporary directory after error: {temp_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
logger.debug("Waiting for extraction thread to finish after error...")
if not self.current_extraction_thread.wait(500):
@@ -1168,25 +1060,23 @@ class ProtonManager(DraggableDialog):
self.current_extraction_thread.error.connect(extraction_error)
self.current_extraction_thread.start()
except Exception as e:
cleanup_dir = asset_data.get('cleanup_dir')
if cleanup_dir:
try:
shutil.rmtree(cleanup_dir)
logger.debug(f"Cleaned up temporary directory after exception: {cleanup_dir}")
except Exception as cleanup_error:
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {cleanup_error}")
temp_dir = os.path.dirname(filepath)
try:
shutil.rmtree(temp_dir)
logger.debug(f"Cleaned up temporary directory after exception: {temp_dir}")
except Exception as cleanup_error:
logger.warning(f"Could not clean up temporary directory {temp_dir}: {cleanup_error}")
logger.error(f"Error starting extraction thread for {filepath}: {e}")
QMessageBox.critical(self, "Extraction Error", f"Failed to start extraction: {e}")
self.current_download_index += 1
QTimer.singleShot(100, self.start_next_download)
else:
cleanup_dir = asset_data.get('cleanup_dir')
if cleanup_dir:
try:
shutil.rmtree(cleanup_dir)
logger.debug(f"Cleaned up temporary directory: {cleanup_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
temp_dir = os.path.dirname(filepath)
try:
shutil.rmtree(temp_dir)
logger.debug(f"Cleaned up temporary directory: {temp_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
logger.warning("PortProton location not provided, skipping extraction")
self.current_download_index += 1
QTimer.singleShot(100, self.start_next_download)
@@ -1264,11 +1154,7 @@ class ProtonManager(DraggableDialog):
super().reject()
def show_proton_manager(
parent=None, portproton_location=None, input_manager=None, local_archives=None
):
def show_proton_manager(parent=None, portproton_location=None, input_manager=None):
dialog = ProtonManager(parent, portproton_location, input_manager=input_manager)
dialog.show()
if local_archives:
dialog.install_local_archives(local_archives)
return dialog
File diff suppressed because it is too large. Load diff
+6 -64
View File
@@ -267,7 +267,6 @@ class GamescopeSettingsMixin:
self.gamescope_toggle_widgets = {}
self.gamescope_toggle_widget_keys = {}
self.gamescope_category_groups = {}
self.gamescope_search_widget = None
self.gamescope_resolution_widgets = {}
self.gamescope_actions_group = None
self.gamescope_toggle_group = None
@@ -613,7 +612,7 @@ class GamescopeSettingsMixin:
selector_layout.addWidget(self.gamescope_category_combo)
self.gamescope_category_stack = QStackedWidget()
self.gamescope_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
self.gamescope_category_stack.setStyleSheet("background: transparent;")
selector_layout.addWidget(self.gamescope_category_stack)
toggle_lookup = dict(GAMESCOPE_TOGGLE_SPECS)
@@ -1027,38 +1026,7 @@ class GamescopeSettingsMixin:
def _filter_gamescope_settings(self, search_text):
"""Filter Gamescope groups based on search text."""
if not self.gamescope_category_groups:
return
gamescope_enabled = self.current_settings.get('PW_GAMESCOPE') == '1'
for checkbox in self.gamescope_toggle_widgets.values():
layout = checkbox.parentWidget().layout()
if layout is not None:
layout.removeWidget(checkbox)
self._remove_gamescope_search_page()
matching = []
for key, checkbox in self.gamescope_toggle_widgets.items():
description = GAMESCOPE_TOGGLE_DESCRIPTIONS.get(key, '')
matches = not search_text or search_text in f"{checkbox.text()} {description}".lower()
checkbox.setVisible(matches)
if matches:
matching.append((key, checkbox))
if search_text:
result_widget, result_layout = self._create_gamescope_search_page()
for index, (_key, checkbox) in enumerate(matching):
result_layout.addWidget(checkbox, index // 2, (index % 2) * 2 + 1)
self.gamescope_category_combo.setVisible(False)
self.gamescope_category_stack.setCurrentWidget(result_widget)
else:
category_positions = {}
for key, checkbox in matching:
category = next((name for name, keys in GAMESCOPE_TOGGLE_CATEGORIES.items() if key in keys), _("Other"))
widget = self.gamescope_category_groups[category]
index = category_positions.get(category, 0)
cast(QGridLayout, widget.layout()).addWidget(checkbox, index // 2, (index % 2) * 2 + 1)
category_positions[category] = index + 1
self.gamescope_category_combo.setVisible(True)
self.on_gamescope_category_changed(self.gamescope_category_combo.currentText())
self._update_gamescope_category_stack_height()
for group_box in self.gamescope_tab.findChildren(QGroupBox):
if not gamescope_enabled:
group_box.setVisible(group_box is self.gamescope_actions_group)
@@ -1066,34 +1034,8 @@ class GamescopeSettingsMixin:
if not search_text:
group_box.setVisible(True)
continue
content_widgets = group_box.findChildren(QLabel) + group_box.findChildren(QCheckBox)
content_match = any(search_text in widget.text().lower() for widget in content_widgets)
group_box.setVisible(
search_text in group_box.title().lower() or content_match
or group_box is self.gamescope_toggle_group and bool(matching)
)
def _create_gamescope_search_page(self) -> tuple[QWidget, QGridLayout]:
source_widget = next(iter(self.gamescope_category_groups.values()))
source_layout = cast(QGridLayout, source_widget.layout())
widget = QWidget()
layout = QGridLayout(widget)
margins = source_layout.contentsMargins()
layout.setContentsMargins(margins.left(), margins.top(), margins.right(), margins.bottom())
layout.setHorizontalSpacing(source_layout.horizontalSpacing())
layout.setVerticalSpacing(source_layout.verticalSpacing())
for column in range(source_layout.columnCount()):
layout.setColumnStretch(column, source_layout.columnStretch(column))
self.gamescope_category_stack.addWidget(widget)
self.gamescope_search_widget = widget
return widget, layout
def _remove_gamescope_search_page(self) -> None:
widget = getattr(self, 'gamescope_search_widget', None)
if widget is None:
return
for checkbox in widget.findChildren(QCheckBox):
checkbox.setParent(self.gamescope_toggle_group)
self.gamescope_category_stack.removeWidget(widget)
widget.deleteLater()
self.gamescope_search_widget = None
group_text = group_box.title().lower()
label_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QLabel))
checkbox_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QCheckBox))
content_text = f"{label_text} {checkbox_text}"
group_box.setVisible(search_text in group_text or search_text in content_text)
+13 -148
View File
@@ -3,7 +3,6 @@
import configparser
import os
import re
import subprocess
from pathlib import Path
from typing import Any, cast
@@ -152,14 +151,8 @@ MANGOHUD_VALUE_SPECS = [
'options': [f'{i / 10:.1f}' for i in range(11)]},
{'key': 'round_corners', 'label': _("Round corners (px)"), 'type': 'combo',
'options': [str(i) for i in range(16)]},
{'key': 'font_scale', 'label': _("Font scale"), 'type': 'combo',
'options': ['', '0.5', '0.75', '1.0', '1.25', '1.5', '2.0']},
{'key': 'font_size', 'label': _("Font size"), 'type': 'line'},
{'key': 'font_file', 'label': _("Font"), 'type': 'combo', 'options': []},
]
MANGOHUD_FONT_KEYS = {'font_scale', 'font_size', 'font_file'}
MANGOHUD_VALUE_OPTION_TRANSLATIONS = {
'position': {
'top-left': _("Top-left"),
@@ -392,13 +385,11 @@ class MangoHudSettingsMixin:
self.mangohud_toggle_widget_keys = {}
self.mangohud_fps_widgets = {}
self.mangohud_category_groups = {}
self.mangohud_search_widget = None
self.mangohud_gpu_options = []
self.mangohud_actions_group = None
self.mangohud_presets_group = None
self.mangohud_toggle_group = None
self.mangohud_values_group = None
self.mangohud_fonts_group = None
self.mangohud_fps_group = None
self.mangohud_extra_group = None
self.mangohud_action_buttons = []
@@ -420,7 +411,6 @@ class MangoHudSettingsMixin:
self._add_mangohud_presets_group(layout)
self._add_mangohud_toggle_group(layout)
self._add_mangohud_values_group(layout)
self._add_mangohud_fonts_group(layout)
self._add_mangohud_fps_group(layout)
self._add_mangohud_extra_group(layout)
layout.addStretch()
@@ -442,8 +432,6 @@ class MangoHudSettingsMixin:
gpu_count = sum(1 for _text, value in self.mangohud_gpu_options if value.isdigit())
for spec in MANGOHUD_VALUE_SPECS:
if spec['key'] in MANGOHUD_FONT_KEYS:
continue
if spec['key'] == 'fps_limit_method':
continue
if spec['key'] == 'gpu_list' and gpu_count < 2:
@@ -452,32 +440,8 @@ class MangoHudSettingsMixin:
parent_layout.addWidget(group)
def _add_mangohud_fonts_group(self, parent_layout):
"""Add MangoHud font controls."""
group = QGroupBox(_("Fonts"))
group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
form = QFormLayout(group)
form.setVerticalSpacing(self.theme.exeSettingsGroupBoxElementVerticalSpacing)
form.setHorizontalSpacing(self.theme.exeSettingsGroupBoxElementHorizontalSpacing)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.mangohud_fonts_group = group
for spec in MANGOHUD_VALUE_SPECS:
if spec['key'] in MANGOHUD_FONT_KEYS:
form.addRow(spec['label'], self._create_mangohud_value_widget(spec))
parent_layout.addWidget(group)
def _create_mangohud_value_widget(self, spec):
"""Create a MangoHud value widget."""
if spec['type'] == 'line':
widget = QLineEdit()
widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
widget.installEventFilter(cast(QWidget, self))
widget.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.mangohud_widgets[spec['key']] = widget
return widget
widget = CustomComboBox(theme=self.theme)
widget.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
@@ -493,10 +457,6 @@ class MangoHudSettingsMixin:
gpu_options = self.mangohud_gpu_options or self._get_mangohud_gpu_options()
for text, value in gpu_options:
widget.addItem(text, value)
elif spec['key'] == 'font_file':
widget.addItem(placeholder_text, '')
for text, value in self._get_mangohud_font_options():
widget.addItem(text, value)
else:
value_translations = MANGOHUD_VALUE_OPTION_TRANSLATIONS.get(spec['key'], {})
for option in options:
@@ -514,30 +474,6 @@ class MangoHudSettingsMixin:
self.mangohud_widgets[spec['key']] = widget
return widget
def _get_mangohud_font_options(self) -> list[tuple[str, str]]:
"""Get installed font names and files from fontconfig."""
try:
result = subprocess.run(
['fc-list', '--format=%{family[0]} %{style[0]}\t%{file}\n'],
capture_output=True,
text=True,
check=False,
timeout=3,
)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("Failed to list system fonts: %s", exc)
return []
if result.returncode != 0:
logger.warning("fc-list failed with exit code %s", result.returncode)
return []
fonts = set()
for line in result.stdout.splitlines():
name, separator, path = line.partition('\t')
if separator and path.lower().endswith(('.ttf', '.otf')):
fonts.add((name.strip(), path.strip()))
return sorted(fonts, key=lambda font: font[0].lower())
def _add_mangohud_presets_group(self, parent_layout):
"""Add preset buttons for common MangoHud layouts."""
presets_group = QGroupBox(_("Presets"))
@@ -631,7 +567,7 @@ class MangoHudSettingsMixin:
selector_layout.addWidget(self.mangohud_category_combo)
self.mangohud_category_stack = QStackedWidget()
self.mangohud_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
self.mangohud_category_stack.setStyleSheet("background: transparent;")
selector_layout.addWidget(self.mangohud_category_stack)
toggle_lookup = dict(MANGOHUD_TOGGLE_SPECS)
@@ -929,8 +865,6 @@ class MangoHudSettingsMixin:
self.mangohud_presets_group.setVisible(config_visible)
if self.mangohud_values_group is not None:
self.mangohud_values_group.setVisible(config_visible)
if self.mangohud_fonts_group is not None:
self.mangohud_fonts_group.setVisible(config_visible)
if self.mangohud_fps_group is not None:
self.mangohud_fps_group.setVisible(config_visible)
if self.mangohud_extra_group is not None:
@@ -1130,9 +1064,6 @@ class MangoHudSettingsMixin:
if widget is None:
return
text = value if isinstance(value, str) else ''
if isinstance(widget, QLineEdit):
widget.setText(text)
return
index = widget.findData(text)
if text and index < 0:
widget.addItem(text, text)
@@ -1247,13 +1178,10 @@ class MangoHudSettingsMixin:
if widget is None:
return ''
if isinstance(widget, QLineEdit):
value = widget.text().strip()
else:
current_data = widget.currentData()
value = '' if current_data is None else str(current_data).strip()
if not value and current_data is None:
value = widget.currentText().strip()
current_data = widget.currentData()
value = '' if current_data is None else str(current_data).strip()
if not value and current_data is None:
value = widget.currentText().strip()
if not value:
return ''
parsed_original, _raw_tokens = self._parse_mangohud_config(
@@ -1291,9 +1219,6 @@ class MangoHudSettingsMixin:
if new_user_conf != orig_user_conf:
changes.append(f"PW_MANGOHUD_USER_CONF={new_user_conf}")
if not mangohud_enabled:
return changes
config_value = self._build_mangohud_config()
if config_value != self.mangohud_original_values.get('MANGOHUD_CONFIG', ''):
changes.append(f"MANGOHUD_CONFIG={config_value}")
@@ -1306,42 +1231,9 @@ class MangoHudSettingsMixin:
def _filter_mangohud_settings(self, search_text):
"""Filter MangoHud groups based on search text."""
if not self.mangohud_category_groups:
return
config_visible = self.current_settings.get('PW_MANGOHUD') == '1' \
and self.current_settings.get('PW_MANGOHUD_USER_CONF') != '1'
for checkbox in self.mangohud_toggle_widgets.values():
layout = checkbox.parentWidget().layout()
if layout is not None:
layout.removeWidget(checkbox)
self._remove_mangohud_search_page()
matching = []
for key, checkbox in self.mangohud_toggle_widgets.items():
description = MANGOHUD_TOGGLE_DESCRIPTIONS.get(key, '')
matches = not search_text or search_text in f"{checkbox.text()} {description}".lower()
checkbox.setVisible(matches)
if matches:
matching.append((key, checkbox))
columns = self.theme.mangoHudSwitchesColumns
if search_text:
result_widget, result_layout = self._create_mangohud_search_page()
for index, (_key, checkbox) in enumerate(matching):
result_layout.addWidget(checkbox, index // columns, (index % columns) * 2 + 1)
self.mangohud_category_combo.setVisible(False)
self.mangohud_category_stack.setCurrentWidget(result_widget)
else:
category_positions = {}
for key, checkbox in matching:
category = next((name for name, keys in MANGOHUD_TOGGLE_CATEGORIES.items() if key in keys), _("Other"))
widget = self.mangohud_category_groups[category]
index = category_positions.get(category, 0)
cast(QGridLayout, widget.layout()).addWidget(
checkbox, index // columns, (index % columns) * 2 + 1
)
category_positions[category] = index + 1
self.mangohud_category_combo.setVisible(True)
self.on_mangohud_category_changed(self.mangohud_category_combo.currentText())
self._update_mangohud_category_stack_height()
mangohud_enabled = self.current_settings.get('PW_MANGOHUD') == '1'
mangohud_user_conf_enabled = self.current_settings.get('PW_MANGOHUD_USER_CONF') == '1'
config_visible = mangohud_enabled and not mangohud_user_conf_enabled
for group_box in self.mangohud_tab.findChildren(QGroupBox):
if not config_visible:
group_box.setVisible(group_box is self.mangohud_actions_group)
@@ -1349,38 +1241,11 @@ class MangoHudSettingsMixin:
if not search_text:
group_box.setVisible(True)
continue
content_widgets = group_box.findChildren(QLabel) + group_box.findChildren(QCheckBox)
content_match = any(search_text in widget.text().lower() for widget in content_widgets)
group_box.setVisible(
search_text in group_box.title().lower() or content_match
or group_box is self.mangohud_toggle_group and bool(matching)
)
def _create_mangohud_search_page(self) -> tuple[QWidget, QGridLayout]:
source_widget = next(iter(self.mangohud_category_groups.values()))
source_layout = cast(QGridLayout, source_widget.layout())
widget = QWidget()
layout = QGridLayout(widget)
margins = source_layout.contentsMargins()
layout.setContentsMargins(margins.left(), margins.top(), margins.right(), margins.bottom())
layout.setHorizontalSpacing(source_layout.horizontalSpacing())
layout.setVerticalSpacing(source_layout.verticalSpacing())
columns = self.theme.mangoHudSwitchesColumns
for column in range(columns * 2 + 1):
layout.setColumnStretch(column, source_layout.columnStretch(column))
self.mangohud_category_stack.addWidget(widget)
self.mangohud_search_widget = widget
return widget, layout
def _remove_mangohud_search_page(self) -> None:
widget = getattr(self, 'mangohud_search_widget', None)
if widget is None:
return
for checkbox in widget.findChildren(QCheckBox):
checkbox.setParent(self.mangohud_toggle_group)
self.mangohud_category_stack.removeWidget(widget)
widget.deleteLater()
self.mangohud_search_widget = None
group_text = group_box.title().lower()
label_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QLabel))
checkbox_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QCheckBox))
content_text = f"{label_text} {checkbox_text}"
group_box.setVisible(search_text in group_text or search_text in content_text)
def _get_default_mangohud_config(self) -> str | None:
"""Read DEFAULT_MANGOHUD_CONFIG from scripts/var."""
-446
View File
@@ -1,446 +0,0 @@
"""ReShade settings support for executable settings dialog."""
import os
from typing import Any, cast
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QKeyEvent, QKeySequence
from PySide6.QtWidgets import (
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
from portprotonqt.localization import _
from portprotonqt.theme_manager import ThemeManager
RESHADE_ENV_KEYS = [
'PW_RESHADE_EXE',
'PW_RESHADE_API',
'PW_RESHADE_FONT',
'PW_RESHADE_SCREENSHOT_PATH',
'PW_RESHADE_SCREENSHOT_KEY',
'PW_RESHADE_OVERLAY_KEY',
]
RESHADE_API_OPTIONS = {
"Auto": "auto",
"DXGI": "dxgi.dll",
"DirectX 8/9": "d3d9.dll",
"OpenGL": "opengl32.dll",
"Vulkan": "vulkan",
}
RESHADE_SPECIAL_KEYS = {
Qt.Key.Key_Cancel: 3,
Qt.Key.Key_Backspace: 8,
Qt.Key.Key_Tab: 9,
Qt.Key.Key_Clear: 12,
Qt.Key.Key_Return: 13,
Qt.Key.Key_Enter: 13,
Qt.Key.Key_Pause: 19,
Qt.Key.Key_CapsLock: 20,
Qt.Key.Key_Escape: 27,
Qt.Key.Key_Space: 32,
Qt.Key.Key_PageUp: 33,
Qt.Key.Key_PageDown: 34,
Qt.Key.Key_End: 35,
Qt.Key.Key_Home: 36,
Qt.Key.Key_Left: 37,
Qt.Key.Key_Up: 38,
Qt.Key.Key_Right: 39,
Qt.Key.Key_Down: 40,
Qt.Key.Key_Print: 44,
Qt.Key.Key_Insert: 45,
Qt.Key.Key_Delete: 46,
Qt.Key.Key_Help: 47,
Qt.Key.Key_Meta: 91,
Qt.Key.Key_Menu: 93,
Qt.Key.Key_Sleep: 95,
Qt.Key.Key_NumLock: 144,
Qt.Key.Key_ScrollLock: 145,
Qt.Key.Key_Semicolon: 186,
Qt.Key.Key_Equal: 187,
Qt.Key.Key_Comma: 188,
Qt.Key.Key_Minus: 189,
Qt.Key.Key_Period: 190,
Qt.Key.Key_Slash: 191,
Qt.Key.Key_QuoteLeft: 192,
Qt.Key.Key_BracketLeft: 219,
Qt.Key.Key_Backslash: 220,
Qt.Key.Key_BracketRight: 221,
Qt.Key.Key_Apostrophe: 222,
Qt.Key.Key_Back: 166,
Qt.Key.Key_Forward: 167,
Qt.Key.Key_Refresh: 168,
Qt.Key.Key_Stop: 169,
Qt.Key.Key_Search: 170,
Qt.Key.Key_Favorites: 171,
Qt.Key.Key_HomePage: 172,
Qt.Key.Key_VolumeMute: 173,
Qt.Key.Key_VolumeDown: 174,
Qt.Key.Key_VolumeUp: 175,
Qt.Key.Key_MediaNext: 176,
Qt.Key.Key_MediaPrevious: 177,
Qt.Key.Key_MediaStop: 178,
Qt.Key.Key_MediaPlay: 179,
Qt.Key.Key_LaunchMail: 180,
Qt.Key.Key_LaunchMedia: 181,
Qt.Key.Key_Launch0: 182,
Qt.Key.Key_Launch1: 183,
}
RESHADE_KEYPAD_KEYS = {
Qt.Key.Key_Asterisk: 106,
Qt.Key.Key_Plus: 107,
Qt.Key.Key_Comma: 108,
Qt.Key.Key_Minus: 109,
Qt.Key.Key_Period: 110,
Qt.Key.Key_Slash: 111,
}
RESHADE_FIRST_FUNCTION_KEY = 112
RESHADE_LAST_FUNCTION_KEY = 135
RESHADE_FIRST_NUMPAD_KEY = 96
class ReShadeSettingsMixin:
"""Mixin with ReShade settings UI and serialization logic."""
theme: Any
exe_path: str | None
current_settings: dict[str, str]
original_values: dict[str, str]
reshade_tab: QWidget
reshade_tab_layout: QVBoxLayout
def init_reshade_state(self) -> None:
self.reshade_original_values = {}
self.reshade_key_target = None
def setup_reshade_tab(self) -> None:
"""Create ReShade tab widgets."""
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
scroll.setStyleSheet(self.theme.SCROLL_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
container = QWidget()
layout = QVBoxLayout(container)
layout.setSpacing(self.theme.exeSettingsGroupBoxBlockSpacing)
actions = QGroupBox(_("Actions"))
actions.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
actions_layout = QGridLayout(actions)
self.reshade_enable_button = self._create_reshade_button("")
self.reshade_enable_button.clicked.connect(self.toggle_reshade_enable)
actions_layout.addWidget(self.reshade_enable_button, 0, 0)
layout.addWidget(actions)
self._add_reshade_settings_group(layout)
layout.addStretch()
scroll.setWidget(container)
self.reshade_tab_layout.addWidget(scroll)
def _add_reshade_settings_group(self, parent_layout: QVBoxLayout) -> None:
self.reshade_settings_group = QGroupBox(_("Settings"))
self.reshade_settings_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
settings_layout = QGridLayout(self.reshade_settings_group)
self.reshade_exe_edit = self._add_reshade_file_row(
settings_layout, 0, "EXE", ".exe"
)
self.reshade_api_combo = CustomComboBox(theme=self.theme)
self.reshade_api_combo.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
)
self.reshade_api_combo.view().window().setAttribute(
Qt.WidgetAttribute.WA_TranslucentBackground
)
self.reshade_api_combo.setStyleSheet(
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
)
self.reshade_api_combo.addItems(list(RESHADE_API_OPTIONS))
settings_layout.addWidget(QLabel("API"), 1, 0)
settings_layout.addWidget(self.reshade_api_combo, 1, 1)
self.reshade_font_combo = CustomComboBox(theme=self.theme)
self.reshade_font_combo.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
)
self.reshade_font_combo.view().window().setAttribute(
Qt.WidgetAttribute.WA_TranslucentBackground
)
self.reshade_font_combo.setStyleSheet(
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
)
self.reshade_font_combo.addItem(_("Default"), "")
settings_layout.addWidget(QLabel(_("Font")), 2, 0)
settings_layout.addWidget(self.reshade_font_combo, 2, 1)
self.reshade_screenshot_edit = self._add_reshade_directory_row(
settings_layout, 3, _("Screenshots")
)
self.reshade_screenshot_key_button = self._create_reshade_key_button(
"screenshot"
)
settings_layout.addWidget(QLabel(_("Screenshot key")), 4, 0)
settings_layout.addWidget(self.reshade_screenshot_key_button, 4, 1)
self.reshade_overlay_key_button = self._create_reshade_key_button("overlay")
settings_layout.addWidget(QLabel(_("Overlay key")), 5, 0)
settings_layout.addWidget(self.reshade_overlay_key_button, 5, 1)
parent_layout.addWidget(self.reshade_settings_group)
self._load_reshade_fonts()
def _create_reshade_button(self, label: str) -> QPushButton:
button = QPushButton(label)
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
return button
def _add_reshade_file_row(
self, layout: QGridLayout, row: int, label: str, file_filter: str
) -> QLineEdit:
line_edit = QLineEdit()
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
button = AutoSizeButton(
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
)
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.clicked.connect(
lambda _checked=False: self._select_reshade_file(line_edit, file_filter)
)
field_layout = QHBoxLayout()
field_layout.addWidget(line_edit)
field_layout.addWidget(button)
layout.addWidget(QLabel(label), row, 0)
layout.addLayout(field_layout, row, 1)
return line_edit
def _select_reshade_file(self, line_edit: QLineEdit, file_filter: str) -> None:
from portprotonqt.dialogs.file_explorer import FileExplorer
current_path = line_edit.text().strip()
initial_path = os.path.dirname(current_path) if os.path.isfile(current_path) else None
explorer = FileExplorer(
self, theme=self.theme, file_filter=file_filter, initial_path=initial_path
)
explorer.file_signal.file_selected.connect(line_edit.setText)
explorer.exec()
def _add_reshade_directory_row(
self, layout: QGridLayout, row: int, label: str
) -> QLineEdit:
line_edit = QLineEdit()
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
button = AutoSizeButton(
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
)
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.clicked.connect(
lambda _checked=False: self._select_reshade_directory(line_edit)
)
field_layout = QHBoxLayout()
field_layout.addWidget(line_edit)
field_layout.addWidget(button)
layout.addWidget(QLabel(label), row, 0)
layout.addLayout(field_layout, row, 1)
return line_edit
def _select_reshade_directory(self, line_edit: QLineEdit) -> None:
from portprotonqt.dialogs.file_explorer import FileExplorer
initial_path = line_edit.text().strip() or os.path.dirname(self.exe_path or "")
explorer = FileExplorer(
self, theme=self.theme, initial_path=initial_path, directory_only=True
)
explorer.file_signal.file_selected.connect(line_edit.setText)
explorer.exec()
def _create_reshade_key_button(self, target: str) -> QPushButton:
button = self._create_reshade_button("")
button.clicked.connect(lambda _checked=False: self._capture_reshade_key(target))
button.installEventFilter(cast(QWidget, self))
return button
def _capture_reshade_key(self, target: str) -> None:
self.reshade_key_target = target
button = self._get_reshade_key_button(target)
button.setText(_("Press a button to choose"))
button.grabKeyboard()
def _get_reshade_key_button(self, target: str) -> QPushButton:
if target == "screenshot":
return self.reshade_screenshot_key_button
return self.reshade_overlay_key_button
def _handle_reshade_key_button_event(self, obj: QWidget, event: QEvent) -> bool:
target = self.reshade_key_target
if target is None or obj is not self._get_reshade_key_button(target):
return False
if event.type() != QEvent.Type.KeyPress:
return False
key_event = cast(QKeyEvent, event)
key_value = self._reshade_virtual_key(
key_event.key(), key_event.modifiers()
)
if key_value is None:
return True
modifiers = key_event.modifiers()
value = ",".join(str(item) for item in (
key_value,
int(bool(modifiers & Qt.KeyboardModifier.ControlModifier)),
int(bool(modifiers & Qt.KeyboardModifier.ShiftModifier)),
int(bool(modifiers & Qt.KeyboardModifier.AltModifier)),
))
self.current_settings[f'PW_RESHADE_{target.upper()}_KEY'] = value
button = self._get_reshade_key_button(target)
button.setText(QKeySequence(key_event.keyCombination()).toString())
button.releaseKeyboard()
self.reshade_key_target = None
return True
def _reshade_virtual_key(
self, key: int, modifiers: Qt.KeyboardModifier
) -> int | None:
is_keypad = bool(modifiers & Qt.KeyboardModifier.KeypadModifier)
if is_keypad and Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
return RESHADE_FIRST_NUMPAD_KEY + key - Qt.Key.Key_0.value
if is_keypad:
try:
keypad_key = RESHADE_KEYPAD_KEYS.get(Qt.Key(key))
except ValueError:
keypad_key = None
if keypad_key is not None:
return keypad_key
if Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
return key
if Qt.Key.Key_A.value <= key <= Qt.Key.Key_Z.value:
return key
if Qt.Key.Key_F1.value <= key <= Qt.Key.Key_F24.value:
return RESHADE_FIRST_FUNCTION_KEY + key - Qt.Key.Key_F1.value
try:
return RESHADE_SPECIAL_KEYS.get(Qt.Key(key))
except ValueError:
return None
def _format_reshade_key(self, value: str) -> str:
try:
key, control, shift, alt = (int(item) for item in value.split(","))
except ValueError:
return value
qt_key = next(
(item for item, code in RESHADE_SPECIAL_KEYS.items() if code == key), None
)
if qt_key is not None:
name = QKeySequence(qt_key).toString()
elif RESHADE_FIRST_FUNCTION_KEY <= key <= RESHADE_LAST_FUNCTION_KEY:
name = f"F{key - RESHADE_FIRST_FUNCTION_KEY + 1}"
else:
name = chr(key)
modifiers = [
label for active, label in (
(control, "Ctrl"), (shift, "Shift"), (alt, "Alt")
) if active
]
return "+".join([*modifiers, name])
def _load_reshade_fonts(self) -> None:
selected_family = self.current_settings.get('PW_RESHADE_FONT', '')
font_options = cast(Any, self)._get_mangohud_font_options()
families = {name for name, _path in font_options}
self.reshade_font_combo.clear()
self.reshade_font_combo.addItem(_("Default"), "")
for family in sorted(families, key=str.casefold):
self.reshade_font_combo.addItem(family, family)
self._select_reshade_font(selected_family)
def _select_reshade_font(self, family: str) -> None:
for index in range(self.reshade_font_combo.count()):
if self.reshade_font_combo.itemData(index) == family:
self.reshade_font_combo.setCurrentIndex(index)
return
if family:
self.reshade_font_combo.addItem(family, family)
self.reshade_font_combo.setCurrentIndex(self.reshade_font_combo.count() - 1)
def populate_reshade(self) -> None:
"""Populate ReShade tab from current settings."""
reshade_exe = self.current_settings.get('PW_RESHADE_EXE', '') or self.exe_path or ''
self.reshade_exe_edit.setText(reshade_exe)
api_value = self.current_settings.get('PW_RESHADE_API', 'auto')
api_label = next(
(label for label, value in RESHADE_API_OPTIONS.items() if value == api_value),
"Auto",
)
self.reshade_api_combo.setCurrentText(api_label)
font_family = self.current_settings.get('PW_RESHADE_FONT', '')
self._select_reshade_font(font_family)
screenshot_path = self.current_settings.get('PW_RESHADE_SCREENSHOT_PATH', '')
screenshot_key = self.current_settings.get(
'PW_RESHADE_SCREENSHOT_KEY', '44,0,0,0'
)
overlay_key = self.current_settings.get(
'PW_RESHADE_OVERLAY_KEY', '36,0,0,0'
)
self.reshade_screenshot_edit.setText(screenshot_path)
self.reshade_screenshot_key_button.setText(
self._format_reshade_key(screenshot_key)
)
self.reshade_overlay_key_button.setText(self._format_reshade_key(overlay_key))
self.current_settings['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
self.current_settings['PW_RESHADE_OVERLAY_KEY'] = overlay_key
self.reshade_original_values = {
key: self.current_settings.get(key, '') for key in RESHADE_ENV_KEYS
}
self.reshade_original_values['PW_RESHADE_EXE'] = reshade_exe
self.reshade_original_values['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
self.reshade_original_values['PW_RESHADE_OVERLAY_KEY'] = overlay_key
self._update_reshade_widgets()
def toggle_reshade_enable(self) -> None:
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
self.current_settings['PW_USE_RESHADE'] = '0' if enabled else '1'
self._update_reshade_widgets()
def _update_reshade_widgets(self) -> None:
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
self.reshade_enable_button.setText(
_("Disable {0}").format("ReShade")
if enabled else _("Enable {0}").format("ReShade")
)
self.reshade_enable_button.setStyleSheet(
self.theme.ACTION_BUTTON_ACTIVE_STYLE if enabled else self.theme.ACTION_BUTTON_STYLE
)
self.reshade_settings_group.setVisible(enabled)
def _collect_reshade_changes(self) -> list[str]:
changes = []
enabled = '1' if self.current_settings.get('PW_USE_RESHADE') == '1' else '0'
original_enabled = '1' if self.original_values.get('PW_USE_RESHADE') == '1' else '0'
if enabled != original_enabled:
changes.append(f"PW_USE_RESHADE={enabled}")
values = {
'PW_RESHADE_EXE': self.reshade_exe_edit.text().strip(),
'PW_RESHADE_API': RESHADE_API_OPTIONS[self.reshade_api_combo.currentText()],
'PW_RESHADE_FONT': str(self.reshade_font_combo.currentData() or ''),
'PW_RESHADE_SCREENSHOT_PATH': self.reshade_screenshot_edit.text().strip(),
'PW_RESHADE_SCREENSHOT_KEY': self.current_settings['PW_RESHADE_SCREENSHOT_KEY'],
'PW_RESHADE_OVERLAY_KEY': self.current_settings['PW_RESHADE_OVERLAY_KEY'],
}
for key, value in values.items():
if value != self.reshade_original_values.get(key, ''):
changes.append(f"{key}={value}")
return changes
def _filter_reshade_settings(self, search_text: str) -> None:
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
self.reshade_settings_group.setVisible(
enabled and (
not search_text
or search_text in "exe api font screenshot overlay"
or search_text in _("Settings").lower()
)
)
-22
View File
@@ -356,28 +356,6 @@ class VkBasaltSettingsMixin:
vkbasalt_enabled = self.current_settings.get('PW_VKBASALT') == '1'
user_conf_enabled = self.current_settings.get('PW_VKBASALT_USER_CONF') == '1'
config_visible = vkbasalt_enabled and not user_conf_enabled
if self.vkbasalt_shaders_layout is not None:
shaders_group = self.vkbasalt_shaders_group
group_match = not search_text or (
shaders_group is not None and search_text in shaders_group.title().lower()
)
matching = [
(shader, checkbox)
for shader, checkbox in self.vkbasalt_shader_widgets.items()
if group_match or search_text in shader.lower()
]
matching_widgets = {checkbox for _, checkbox in matching}
for checkbox in self.vkbasalt_shader_widgets.values():
self.vkbasalt_shaders_layout.removeWidget(checkbox)
checkbox.setVisible(group_match or checkbox in matching_widgets)
columns = self.theme.mangoHudSwitchesColumns
for index, (_, checkbox) in enumerate(matching):
self.vkbasalt_shaders_layout.addWidget(
checkbox,
index // columns,
(index % columns) * 2 + 1,
)
self.vkbasalt_shaders_layout.activate()
for group_box in self.vkbasalt_tab.findChildren(QGroupBox):
if not config_visible:
group_box.setVisible(group_box is self.vkbasalt_actions_group)
+18 -28
View File
@@ -31,20 +31,6 @@ class ExtractionThread(QThread):
self._mutex.unlock()
return not running
def _get_entry_path(self, entry_path: str, link_path: str | None = None) -> str:
extract_root = os.path.realpath(self.extract_dir)
target_path = os.path.realpath(os.path.join(extract_root, entry_path))
if os.path.commonpath((extract_root, target_path)) != extract_root:
raise ValueError(f"Archive entry escapes extraction directory: {entry_path}")
if link_path is None:
return target_path
link_target = os.path.realpath(
os.path.join(os.path.dirname(target_path), link_path)
)
if os.path.commonpath((extract_root, link_target)) != extract_root:
raise ValueError(f"Archive link escapes extraction directory: {entry_path}")
return target_path
def run(self):
try:
self.progress.emit(0)
@@ -62,6 +48,10 @@ class ExtractionThread(QThread):
last_progress = -1
last_bytes_read = 0
original_dir = os.getcwd()
old_umask = os.umask(0)
os.chdir(self.extract_dir)
try:
deferred_times = []
@@ -73,25 +63,23 @@ class ExtractionThread(QThread):
entry_path = entry.pathname
if entry_path is None:
continue
link_path = os.fsdecode(entry.linkpath) if entry.issym else None
target_path = self._get_entry_path(entry_path, link_path)
if entry.isdir:
os.makedirs(target_path, exist_ok=True)
os.makedirs(entry_path, exist_ok=True)
if entry.mode:
try:
os.chmod(target_path, entry.mode)
os.chmod(entry_path, entry.mode)
except (OSError, PermissionError):
pass
if entry.mtime:
deferred_times.append((target_path, entry.mtime))
deferred_times.append((entry_path, entry.mtime))
elif entry.isfile:
parent_dir = os.path.dirname(target_path)
parent_dir = os.path.dirname(entry_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(target_path, 'wb') as f:
with open(entry_path, 'wb') as f:
for block in entry.get_blocks():
if self._should_stop():
return
@@ -99,27 +87,28 @@ class ExtractionThread(QThread):
if entry.mode:
try:
os.chmod(target_path, entry.mode)
os.chmod(entry_path, entry.mode)
except (OSError, PermissionError):
pass
if entry.mtime:
try:
os.utime(target_path, (entry.mtime, entry.mtime))
os.utime(entry_path, (entry.mtime, entry.mtime))
except (OSError, PermissionError):
pass
elif entry.issym:
parent_dir = os.path.dirname(target_path)
parent_dir = os.path.dirname(entry_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
if os.path.lexists(target_path):
os.remove(target_path)
if os.path.lexists(entry_path):
os.remove(entry_path)
link_path = entry.linkpath
if link_path is not None:
try:
os.symlink(link_path, target_path)
os.symlink(link_path, entry_path)
except (OSError, NotImplementedError):
pass
@@ -163,7 +152,8 @@ class ExtractionThread(QThread):
self.finished.emit(self.archive_path, True)
finally:
self.eta.emit(0)
os.chdir(original_dir)
os.umask(old_umask)
except Exception as e:
if not self._should_stop():
+1 -53
View File
@@ -13,33 +13,6 @@ from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def get_directory_size(path: str) -> str:
try:
total_size = 0
for dirpath, _dirnames, filenames in os.walk(path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
if total_size == 0:
return "0 B"
if total_size < 1024:
return f"{total_size}.0 B"
if total_size < 1024 * 1024:
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
if total_size < 1024 * 1024 * 1024:
value = total_size / (1024 * 1024)
return f"{int(value)}.{int((value * 10) % 10)} MiB"
if total_size < 1024 * 1024 * 1024 * 1024:
value = total_size / (1024 * 1024 * 1024)
return f"{int(value)}.{int((value * 10) % 10)} GiB"
value = total_size / (1024 * 1024 * 1024 * 1024)
return f"{int(value)}.{int((value * 10) % 10)} TiB"
except Exception as error:
logger.debug("Failed to format size: %s", error)
return ""
def get_cpu_level():
"""
Determine CPU level based on feature flags.
@@ -91,18 +64,7 @@ class WineLoadingThread(QThread):
def run(self):
try:
cloud_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
github_url = (
"https://raw.githubusercontent.com/linux-gaming-ru/"
"PortProtonQt-Wine-Metadata/main/wine_metadata_all.json"
)
from portprotonqt.portproton_api import get_user_conf_setting
json_url = (
cloud_url
if get_user_conf_setting("MIRROR") == "CLOUD"
else github_url
)
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as tmp:
temp_path = tmp.name
@@ -128,17 +90,3 @@ class WineLoadingThread(QThread):
except Exception as e:
logger.error(f"Error loading metadata: {e}")
self.loading_error.emit(str(e))
class InstalledWineSizeThread(QThread):
"""Load installed Wine directory sizes outside the UI thread."""
sizes_loaded = Signal(object)
def __init__(self, versions: list[tuple[str, str]], parent: QWidget | None = None):
super().__init__(parent)
self.versions = versions
def run(self) -> None:
sizes = [get_directory_size(path) for _name, path in self.versions]
self.sizes_loaded.emit(sizes)
+17 -36
View File
@@ -6,7 +6,7 @@ from typing import cast, TYPE_CHECKING
from PySide6.QtWidgets import (
QVBoxLayout, QHBoxLayout, QTextEdit, QTabWidget,
QTableWidget, QHeaderView, QTableWidgetItem, QAbstractItemView,
QStackedWidget, QWidget, QMessageBox, QCheckBox
QStackedWidget, QWidget, QMessageBox
)
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment
@@ -86,12 +86,12 @@ class WinetricksDialog(DraggableDialog):
self.main_layout.addWidget(self.hints_widget)
if self.input_manager:
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
)
self.input_manager.connect_surface_updates('winetricks_dialog', update_hints)
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def setup_ui(self):
@@ -109,7 +109,7 @@ class WinetricksDialog(DraggableDialog):
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE + self.theme.TAB_STYLE)
table_base_style = self.theme.GETWINE_WINDOW_STYLE + self.theme.SCROLL_STYLE + self.theme.CHECKBOX_STYLE
table_base_style = self.theme.WINETRICKS_TABBLE_STYLE + self.theme.SCROLL_STYLE + self.theme.CHECKBOX_STYLE
self.dll_table = QTableWidget()
self.dll_table.setAlternatingRowColors(True)
@@ -120,8 +120,7 @@ class WinetricksDialog(DraggableDialog):
self.dll_table.setColumnCount(3)
self.dll_table.setHorizontalHeaderLabels([_("Set"), _("Libraries"), _("Information")])
self.dll_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.dll_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
self.dll_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
self.dll_table.horizontalHeader().resizeSection(0, 50)
self.dll_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.dll_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.dll_table.setStyleSheet(table_base_style)
@@ -153,8 +152,7 @@ class WinetricksDialog(DraggableDialog):
self.fonts_table.setColumnCount(3)
self.fonts_table.setHorizontalHeaderLabels([_("Set"), _("Fonts"), _("Information")])
self.fonts_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.fonts_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
self.fonts_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
self.fonts_table.horizontalHeader().resizeSection(0, 50)
self.fonts_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.fonts_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.fonts_table.setStyleSheet(table_base_style)
@@ -186,8 +184,7 @@ class WinetricksDialog(DraggableDialog):
self.settings_table.setColumnCount(3)
self.settings_table.setHorizontalHeaderLabels([_("Set"), _("Settings"), _("Information")])
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.settings_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
self.settings_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
self.settings_table.horizontalHeader().resizeSection(0, 50)
self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.settings_table.setStyleSheet(table_base_style)
@@ -210,13 +207,6 @@ class WinetricksDialog(DraggableDialog):
self.settings_container.addWidget(self.settings_table)
self.tab_widget.addTab(self.settings_container, _("Settings"))
for table in (self.dll_table, self.fonts_table, self.settings_table):
table.currentCellChanged.connect(
lambda row, *_previous, table=table: table.setFocusProxy(
cast(QCheckBox, table.cellWidget(row, 0).findChild(QCheckBox))
) if table.cellWidget(row, 0) else None
)
self.containers = {
"dlls": self.dll_container,
"fonts": self.fonts_container,
@@ -369,19 +359,14 @@ class WinetricksDialog(DraggableDialog):
if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'):
continue
checked = name in installed
checked = Qt.CheckState.Checked if name in installed else Qt.CheckState.Unchecked
row = table.rowCount()
table.insertRow(row)
checkbox_widget = QWidget()
checkbox_layout = QHBoxLayout(checkbox_widget)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
checkbox = QCheckBox()
checkbox.setChecked(checked)
checkbox_layout.addWidget(checkbox)
table.setCellWidget(row, 0, checkbox_widget)
checkbox = QTableWidgetItem()
checkbox.setCheckState(checked)
table.setItem(row, 0, checkbox)
name_item = QTableWidgetItem(name)
table.setItem(row, 1, name_item)
@@ -389,17 +374,13 @@ class WinetricksDialog(DraggableDialog):
info_item = QTableWidgetItem(info)
table.setItem(row, 2, info_item)
if table is self.dll_table or table is self.fonts_table:
table.sortItems(1, Qt.SortOrder.AscendingOrder)
def install_selected(self, force=False):
"""Install selected components."""
selected = []
for table in [self.dll_table, self.fonts_table, self.settings_table]:
for row in range(table.rowCount()):
checkbox_widget = table.cellWidget(row, 0)
checkbox = checkbox_widget.findChild(QCheckBox) if checkbox_widget else None
if checkbox is not None and checkbox.isChecked():
checkbox = table.item(row, 0)
if checkbox is not None and checkbox.checkState() == Qt.CheckState.Checked:
name_item = table.item(row, 1)
if name_item is not None:
name = name_item.text()
+7 -27
View File
@@ -1,4 +1,4 @@
from PySide6.QtCore import QObject, Signal, QThread, Qt, QCoreApplication
from PySide6.QtCore import QObject, Signal, QThread
import threading
import os
import time
@@ -70,7 +70,7 @@ class Downloader(QObject):
self._locks = {}
self._active_threads: list[QThread] = []
self._global_lock = threading.Lock()
self.callback_requested.connect(self._run_callback, Qt.ConnectionType.QueuedConnection)
self.callback_requested.connect(self._run_callback)
def _run_callback(self, callback, result):
if callback:
@@ -240,14 +240,11 @@ class Downloader(QObject):
on_result(url, None)
emitted_urls.add(url)
continue
cached_path = self._cache.get(url)
if (cached_path and os.path.exists(cached_path)
and os.path.abspath(cached_path) == os.path.abspath(path)):
if url in self._cache:
if on_result and url not in emitted_urls:
on_result(url, cached_path)
on_result(url, self._cache[url])
emitted_urls.add(url)
continue
self._cache.pop(url, None)
filtered_urls.append(url)
filtered_paths.append(path)
@@ -264,7 +261,7 @@ class Downloader(QObject):
throttle_delay,
workers,
on_result=emit_result,
) if filtered_urls else {}
)
with self._global_lock:
for url, path in results.items():
@@ -273,9 +270,7 @@ class Downloader(QObject):
final_results = {}
with self._global_lock:
for url in urls:
if url in self._last_error:
final_results[url] = None
elif url in self._cache:
if url in self._cache:
final_results[url] = self._cache[url]
else:
final_results[url] = None
@@ -286,18 +281,7 @@ class Downloader(QObject):
return final_results
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread | None:
with self._global_lock:
previous_error = url in self._last_error
cached = not previous_error and os.path.exists(local_path)
if cached:
self._cache[url] = local_path
if previous_error or cached:
result = local_path if cached else None
self.download_completed.emit(url, result or "", cached)
self.callback_requested.emit(callback, result)
return None
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread:
class DownloadThread(QThread):
def __init__(self, downloader: 'Downloader', url: str, local_path: str, timeout: int, parallel: bool):
super().__init__()
@@ -324,11 +308,7 @@ class Downloader(QObject):
self.downloader.callback_requested.emit(callback, None)
thread = DownloadThread(self, url, local_path, timeout, parallel)
thread.moveToThread(self.thread())
thread.finished.connect(thread.deleteLater)
app = QCoreApplication.instance()
if app is not None:
app.aboutToQuit.connect(thread.wait)
# Remove from list after completion
def cleanup():
-482
View File
@@ -1,482 +0,0 @@
"""Epic Games Store integration through Legendary."""
import hashlib
import os
import platform
import shutil
import subprocess
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
import orjson
import requests
from portprotonqt.localization import get_store_content_languages
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
EGS_LOGIN_URL = "https://legendary.gl/epiclogin"
LEGENDARY_RELEASE_URL = (
"https://api.github.com/repos/Heroic-Games-Launcher/legendary/releases/latest"
)
LEGENDARY_TIMEOUT = 120
EGS_METADATA_WORKERS = 4
EGS_METADATA_TIMEOUT = 15
EGS_DESCRIPTION_CACHE_VERSION = 2
LEGENDARY_UPDATE_INTERVAL = 30 * 24 * 60 * 60
EGS_API_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 EpicGamesLauncher"
)
class EGSAPI:
"""Provide EGS authentication, library and installation helpers."""
def __init__(self) -> None:
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
self.data_dir = data_home / "PortProtonQt" / "launcher" / "egs"
self.bin_dir = data_home / "PortProtonQt" / "bin"
# Remove after the legacy launcher storage migration period.
legacy_dir = data_home / "PortProtonQt" / "egs"
if legacy_dir.exists() and not self.data_dir.exists():
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
legacy_dir.rename(self.data_dir)
legacy_bin = self.data_dir / "bin"
if legacy_bin.is_dir():
self.bin_dir.mkdir(parents=True, exist_ok=True)
for legacy_binary in legacy_bin.iterdir():
target = self.bin_dir / legacy_binary.name
if not target.exists():
legacy_binary.rename(target)
if not any(legacy_bin.iterdir()):
legacy_bin.rmdir()
self.config_dir = self.data_dir / "legendary"
self.user_path = self.config_dir / "user.json"
self.library_path = self.data_dir / "library.json"
self.sizes_path = self.data_dir / "sizes.json"
self.version_path = self.bin_dir / "legendary.version"
self.games_dir = Path.home() / "Games"
def get_legendary_path(self) -> str | None:
"""Return an available Legendary executable."""
bundled = self.bin_dir / "legendary"
if bundled.is_file() and os.access(bundled, os.X_OK):
return str(bundled)
return shutil.which("legendary")
def ensure_legendary(self) -> str:
"""Download the current official Heroic Legendary binary if needed."""
existing = self.get_legendary_path()
if existing:
return existing
asset, release_tag = self._get_latest_legendary_release()
return self._download_legendary(asset, release_tag)
def update_legendary(self) -> str:
"""Update the bundled Legendary binary when a newer release exists."""
bundled = self.bin_dir / "legendary"
if bundled.is_file() and os.access(bundled, os.X_OK):
try:
last_check = self.version_path.stat().st_mtime
except OSError:
last_check = 0
if time.time() - last_check < LEGENDARY_UPDATE_INTERVAL:
return str(bundled)
asset, release_tag = self._get_latest_legendary_release()
if (
bundled.is_file()
and self._get_legendary_release_tag() == release_tag
):
self.version_path.touch()
return str(bundled)
return self._download_legendary(asset, release_tag)
def _get_latest_legendary_release(self) -> tuple[dict, str]:
"""Return the matching Legendary asset and release tag."""
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(
platform.machine()
)
if not architecture:
raise OSError(f"Unsupported Legendary architecture: {platform.machine()}")
response = requests.get(LEGENDARY_RELEASE_URL, timeout=15)
response.raise_for_status()
release = response.json()
asset_name = f"legendary_linux_{architecture}"
asset = next(
(item for item in release.get("assets", []) if item.get("name") == asset_name),
None,
)
if not asset:
raise OSError(f"Legendary release asset not found: {asset_name}")
release_tag = str(release.get("tag_name", ""))
if not release_tag:
raise OSError("Legendary release tag not found")
return asset, release_tag
def _get_legendary_release_tag(self) -> str:
try:
return self.version_path.read_text(encoding="utf-8").strip()
except OSError:
return ""
def _download_legendary(self, asset: dict, release_tag: str) -> str:
target = self.bin_dir / "legendary"
temporary = target.with_suffix(".part")
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
try:
with requests.get(asset["browser_download_url"], stream=True, timeout=30) as response:
response.raise_for_status()
with open(temporary, "wb") as output:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
output.write(chunk)
digest.update(chunk)
expected = str(asset.get("digest", "")).removeprefix("sha256:")
if not expected or digest.hexdigest() != expected:
raise OSError("Legendary checksum verification failed")
temporary.chmod(0o755)
temporary.replace(target)
self.version_path.write_text(release_tag, encoding="utf-8")
return str(target)
except Exception:
temporary.unlink(missing_ok=True)
raise
def build_command(self, arguments: list[str]) -> list[str]:
"""Build a Legendary command using private configuration paths."""
executable = self.get_legendary_path()
if not executable:
raise FileNotFoundError("Legendary executable not found")
self.config_dir.mkdir(parents=True, exist_ok=True)
self.config_dir.chmod(0o700)
command_arguments = list(arguments)
if "-y" in command_arguments:
command_arguments.remove("-y")
command_arguments.insert(0, "-y")
return [executable, *command_arguments]
def get_environment(self) -> dict[str, str]:
"""Return the environment required by Legendary."""
environment = os.environ.copy()
environment["LEGENDARY_CONFIG_PATH"] = str(self.config_dir)
return environment
def authenticate(self, code: str) -> tuple[bool, str]:
"""Authenticate an Epic account with an authorization code."""
self.ensure_legendary()
result = subprocess.run(
self.build_command(["auth", "--code", code]),
env=self.get_environment(), capture_output=True, check=False,
timeout=LEGENDARY_TIMEOUT,
)
if result.returncode == 0 and self.user_path.is_file():
self.user_path.chmod(0o600)
return True, ""
error = result.stderr.decode(errors="replace").strip()
logger.error("Legendary authentication failed: %s", error)
return False, error or f"Legendary exited with code {result.returncode}"
@staticmethod
def extract_auth_code(text: str) -> str:
"""Extract an Epic authorization code from copied text or JSON."""
value = text.strip()
try:
data = orjson.loads(value)
except orjson.JSONDecodeError:
return value if "://" not in value else ""
if not isinstance(data, dict):
return ""
return str(data.get("authorizationCode", "")).strip()
def logout(self) -> bool:
"""Remove the Epic login and cached library."""
try:
if self.get_legendary_path() and self.user_path.is_file():
subprocess.run(
self.build_command(["auth", "--delete"]),
env=self.get_environment(), capture_output=True, check=False,
timeout=LEGENDARY_TIMEOUT,
)
self.user_path.unlink(missing_ok=True)
self.library_path.unlink(missing_ok=True)
return True
except (OSError, subprocess.SubprocessError) as error:
logger.error("Failed to disconnect Epic account: %s", error)
return False
def get_account_name(self) -> str:
"""Return the Epic display name from Legendary state."""
user = self._load_json(self.user_path, {})
return str(user.get("displayName", "")) if isinstance(user, dict) else ""
def refresh_library(
self, progress_callback: Callable[[int, int], None] | None = None
) -> list[dict]:
"""Refresh and cache the Epic library through Legendary."""
self.update_legendary()
result = subprocess.run(
self.build_command(["list", "--json", "--third-party"]),
env=self.get_environment(), capture_output=True, check=False,
timeout=LEGENDARY_TIMEOUT,
)
if result.returncode != 0:
error = result.stderr.decode(errors="replace").strip()
raise OSError(error or "Failed to refresh Epic library")
raw_games = orjson.loads(result.stdout)
if not isinstance(raw_games, list):
raise OSError("Legendary returned an invalid Epic library")
games = []
for game in raw_games:
metadata = game.get("metadata", {})
if metadata.get("mainGameItem"):
continue
release_info = metadata.get("releaseInfo", [])
mobile_only = bool(release_info) and all(
info.get("platform")
and all(platform in {"Android", "iOS"}
for platform in info["platform"])
for info in release_info
)
if not mobile_only:
games.append(self._normalize_game(game))
games = [game for game in games if game.get("app_id")]
self._enrich_descriptions(games, progress_callback)
self._save_json(self.library_path, games)
return games
def load_library(self) -> list[dict]:
"""Load the cached Epic library."""
data = self._load_json(self.library_path, [])
return data if isinstance(data, list) else []
def clear_library_cache(self) -> None:
"""Remove cached Epic library metadata."""
try:
self.library_path.unlink(missing_ok=True)
except OSError as error:
logger.warning("Failed to clear Epic library cache: %s", error)
def load_installed(self) -> dict[str, dict]:
"""Load Legendary installed game records."""
data = self._load_json(self.config_dir / "installed.json", {})
return data if isinstance(data, dict) else {}
def is_game_installed(self, app_id: str) -> bool:
"""Return whether Legendary records a usable installation."""
return self.get_launch_target(app_id) is not None
@staticmethod
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
"""Return whether the EOS overlay is enabled in a Wine prefix."""
try:
registry = (prefix_path / "user.reg").read_text(
encoding="utf-8", errors="replace"
)
except OSError:
return False
for section in registry.casefold().split("\n["):
if section.startswith(r"software\\epic games\\eos]"):
if '"overlaypath"=' in section:
return True
return False
def get_launch_target(self, app_id: str) -> str | None:
"""Return the installed game's Windows executable."""
installed = self.load_installed().get(app_id, {})
install_path = Path(str(installed.get("install_path", "")))
executable = str(installed.get("executable", "")).replace("\\", "/")
target = install_path.joinpath(*executable.split("/"))
return str(target) if executable and target.is_file() else None
def get_cached_download_sizes(self, app_id: str) -> tuple[int, int] | None:
"""Return cached EGS download and disk sizes."""
cache = self._load_json(self.sizes_path, {})
value = cache.get(app_id) if isinstance(cache, dict) else None
if not isinstance(value, list) or len(value) != 2:
return None
sizes = int(value[0]), int(value[1])
return None if sizes[0] > 0 and sizes[1] <= 0 else sizes
def save_download_sizes(self, app_id: str, sizes: tuple[int, int]) -> None:
"""Cache EGS download and disk sizes."""
cache = self._load_json(self.sizes_path, {})
if not isinstance(cache, dict):
cache = {}
cache[app_id] = list(sizes)
self._save_json(self.sizes_path, cache)
@staticmethod
def parse_download_sizes(output: bytes) -> tuple[int, int]:
"""Extract download and install sizes from Legendary JSON."""
data = orjson.loads(output)
values: dict[str, int] = {}
def visit(value: Any) -> None:
if isinstance(value, dict):
for key, item in value.items():
if key in {"download_size", "install_size", "disk_size"}:
try:
values[key] = max(values.get(key, 0), int(item))
except (TypeError, ValueError):
continue
visit(item)
elif isinstance(value, list):
for item in value:
visit(item)
visit(data)
install_size = values.get("install_size") or values.get("disk_size", 0)
return values.get("download_size", 0), install_size
def _enrich_descriptions(
self, games: list[dict],
progress_callback: Callable[[int, int], None] | None = None,
) -> None:
cached = {str(game.get("app_id")): game for game in self.load_library()}
pending = []
completed = 0
total = len(games)
preferred_locale = get_store_content_languages()[0]
for game in games:
old_game = cached.get(game["app_id"], {})
old_description = str(old_game.get("description", ""))
if (
old_description
and old_description != game["title"]
and old_game.get("description_preference") == preferred_locale
and old_game.get("description_cache_version")
== EGS_DESCRIPTION_CACHE_VERSION
):
game["description"] = old_description
game["description_locale"] = str(old_game.get("description_locale", ""))
game["description_preference"] = preferred_locale
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
completed += 1
if progress_callback:
progress_callback(completed, total)
else:
pending.append(game)
if not pending:
return
worker_count = min(EGS_METADATA_WORKERS, os.cpu_count() or 1)
with ThreadPoolExecutor(max_workers=worker_count) as executor:
descriptions = executor.map(self._get_store_description, pending)
for game, result in zip(pending, descriptions, strict=True):
description, description_locale = result
if description:
game["description"] = description
game["description_locale"] = description_locale
game["description_preference"] = preferred_locale
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
completed += 1
if progress_callback:
progress_callback(completed, total)
def _get_store_description(self, game: dict) -> tuple[str, str]:
slug = self._get_product_slug(str(game.get("namespace", "")), game["title"])
for language in get_store_content_languages():
try:
response = requests.get(
"https://store-content.ak.epicgames.com/api/"
f"{language}/content/products/{slug}",
headers={"User-Agent": EGS_API_USER_AGENT},
timeout=EGS_METADATA_TIMEOUT,
)
if response.status_code == requests.codes.not_found:
continue
response.raise_for_status()
description = self._parse_store_description(response.json())
if description:
return description, language
except requests.RequestException as error:
logger.debug(
"Epic description unavailable for %s: %s", game["app_id"], error
)
return "", ""
return "", ""
@staticmethod
def _parse_store_description(data: Any) -> str:
if not isinstance(data, dict) or not isinstance(data.get("pages"), list):
return ""
page = next(
(item for item in data["pages"]
if isinstance(item, dict) and item.get("type") == "productHome"),
{},
)
about = page.get("data", {}).get("about", {})
short_description = str(about.get("shortDescription") or "").strip()
if short_description:
return short_description
description = str(about.get("description") or "").strip()
return description.split("\n\n", 1)[0].strip()
def _get_product_slug(self, namespace: str, title: str) -> str:
fallback = "-".join("".join(
character if character.isascii() and character.isalnum() else " "
for character in title.lower()
).split())
if not namespace:
return fallback
query = {
"query": "query ProductSlug($namespace: String!) { Catalog { "
"catalogNs(namespace: $namespace) { mappings(pageType: "
"\"productHome\") { pageSlug pageType } } } }",
"variables": {"namespace": namespace},
}
try:
response = requests.post(
"https://launcher.store.epicgames.com/graphql",
json=query,
headers={
"Content-Type": "application/json",
"User-Agent": EGS_API_USER_AGENT,
},
timeout=EGS_METADATA_TIMEOUT,
)
response.raise_for_status()
mappings = response.json()["data"]["Catalog"]["catalogNs"]["mappings"]
return str(mappings[0].get("pageSlug") or fallback) if mappings else fallback
except (KeyError, IndexError, TypeError, requests.RequestException) as error:
logger.debug("Epic product slug unavailable for %s: %s", namespace, error)
return fallback
@staticmethod
def _normalize_game(game: dict) -> dict:
metadata = game.get("metadata", {})
images = metadata.get("keyImages", []) if isinstance(metadata, dict) else []
cover = next(
(str(image.get("url", "")) for image in images
if image.get("type") in ("DieselGameBoxTall", "OfferImageTall")),
"",
)
return {
"app_id": str(game.get("app_name") or game.get("appName") or ""),
"title": str(game.get("app_title") or metadata.get("title") or ""),
"description": str(metadata.get("description", "")),
"cover": cover,
"namespace": str(metadata.get("namespace", "")),
"folder_name": str(metadata.get("customAttributes", {}).get(
"FolderName", {}
).get("value") or game.get("app_name") or game.get("appName") or ""),
"description_locale": "",
"description_preference": "",
"description_cache_version": 0,
}
@staticmethod
def _load_json(path: Path, default: Any) -> Any:
try:
return orjson.loads(path.read_bytes())
except (OSError, orjson.JSONDecodeError):
return default
@staticmethod
def _save_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(orjson.dumps(data, option=orjson.OPT_INDENT_2))
+159 -389
View File
@@ -1,7 +1,6 @@
import os
import re
import shlex
from typing import Any
from PySide6.QtGui import (
QPainter,
@@ -13,12 +12,8 @@ from PySide6.QtGui import (
QIcon,
QPen,
QLinearGradient,
QEnterEvent,
QFocusEvent,
QMouseEvent,
QPaintEvent,
)
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer, QSize
from PySide6.QtWidgets import (
QApplication,
QFrame,
@@ -29,7 +24,6 @@ from PySide6.QtWidgets import (
QWidget,
QStackedLayout,
QLabel,
QSizePolicy,
)
from portprotonqt.image_utils import (
cleanup_animated_cover,
@@ -205,132 +199,11 @@ class SourceCorner(QWidget):
self._visible = visible
super().setVisible(visible)
def refresh_source_theme(self, config: dict, icon: str | QIcon | None) -> None:
"""Refresh source-corner visuals for a live theme change."""
self._cfg = config
self._icon = icon
self._color = QColor(config.get("ribbon_color", "#3f424d"))
self._fold_color = QColor(config.get("ribbon_fold_color", "#00000096"))
self.update()
class AnimatedCard(QFrame):
animation_base_size: tuple[int, int]
class GameCard(QFrame):
borderWidthChanged = Signal()
gradientAngleChanged = Signal()
scaleChanged = Signal()
hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool)
clicked = Signal()
def setup_card_animations(self, theme: Any, layout_config: dict) -> None:
self.theme = theme
self.card_layout_cfg = layout_config
self._borderWidth = layout_config.get(
"default_border_width", theme.GAME_CARD_ANIMATION["default_border_width"]
)
self._gradientAngle = layout_config.get(
"gradient_start_angle", theme.GAME_CARD_ANIMATION["gradient_start_angle"]
)
self._scale = layout_config.get(
"default_scale", theme.GAME_CARD_ANIMATION["default_scale"]
)
self._hovered = False
self._focused = False
self._hover_sound_pending = False
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setMouseTracking(True)
self.animations = GameCardAnimations(self, theme)
self.animations.setup_animations()
def update_scale(self) -> None:
width, height = self.animation_base_size
self.setFixedSize(int(width * self._scale), int(height * self._scale))
def getBorderWidth(self) -> int:
return self._borderWidth
def setBorderWidth(self, value: int) -> None:
self._borderWidth = value
self.borderWidthChanged.emit()
self.update()
def getGradientAngle(self) -> float:
return self._gradientAngle
def setGradientAngle(self, value: float) -> None:
self._gradientAngle = value
self.gradientAngleChanged.emit()
self.update()
def getScale(self) -> float:
return self._scale
def setScale(self, value: float) -> None:
self._scale = value
self.update_scale()
self.scaleChanged.emit()
borderWidth = Property(int, getBorderWidth, setBorderWidth, notify=borderWidthChanged)
gradientAngle = Property(float, getGradientAngle, setGradientAngle, notify=gradientAngleChanged)
scale = Property(float, getScale, setScale, notify=scaleChanged)
def paintEvent(self, event: QPaintEvent) -> None:
super().paintEvent(event)
painter = QPainter(self)
parent = self.parentWidget()
layout = parent.layout() if parent is not None else None
if getattr(self, "list_layout", False) and isinstance(layout, QVBoxLayout):
row_pitch = self.height() + layout.spacing()
row = (self.y() - layout.contentsMargins().top()) // max(1, row_pitch)
colors = (self.theme.color_bg, self.theme.color_surface_elevated)
painter.fillRect(self.rect(), QColor(colors[row % len(colors)]))
self.animations.paint_border(painter)
def enterEvent(self, event: QEnterEvent) -> None:
self.raise_()
self._hover_sound_pending = True
self.animations.handle_enter_event()
super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
self._hover_sound_pending = False
self.animations.handle_leave_event()
super().leaveEvent(event)
def mouseMoveEvent(self, event: QMouseEvent) -> None:
if self._hover_sound_pending:
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self._hover_sound_pending = False
super().mouseMoveEvent(event)
def focusInEvent(self, event: QFocusEvent) -> None:
self.raise_()
if QApplication.activeWindow() is not None and event.reason() not in (
Qt.FocusReason.ActiveWindowFocusReason,
Qt.FocusReason.MouseFocusReason,
):
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self.animations.handle_focus_in_event()
super().focusInEvent(event)
def focusOutEvent(self, event: QFocusEvent) -> None:
self.animations.handle_focus_out_event()
super().focusOutEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.position().toPoint()):
self.clicked.emit()
super().mouseReleaseEvent(event)
def click(self) -> None:
self.clicked.emit()
class GameCard(AnimatedCard):
coverReady = Signal(object)
editShortcutRequested = Signal(str, str, str)
deleteGameRequested = Signal(str, str)
addToMenuRequested = Signal(str, str)
@@ -340,6 +213,8 @@ class GameCard(AnimatedCard):
addToSteamRequested = Signal(str, str, str)
removeFromSteamRequested = Signal(str, str)
openGameFolderRequested = Signal(str, str)
hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool)
def __init__(
self, name, description, cover_path, appid, controller_support, exec_line,
@@ -349,7 +224,6 @@ class GameCard(AnimatedCard):
card_width=250, parent=None, context_menu_manager=None
):
super().__init__(parent)
self.coverReady.connect(self.on_cover_loaded, Qt.ConnectionType.QueuedConnection)
self.name = name
self.description = description
self.cover_path = cover_path
@@ -383,47 +257,33 @@ class GameCard(AnimatedCard):
self.display_filter = game_config.get_display_filter()
self.badge_view_mode = ui_config.get_badge_view_mode()
self.current_theme_name = ui_config.get_theme()
parent_mode = parent.property("library_layout_mode") if parent is not None else None
self.layout_mode = str(
parent_mode or getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")
).lower()
self.list_layout = self.layout_mode in {"list", "vertical"}
self.vertical_layout = self.layout_mode == "vertical"
self.horizontal_layout = self.layout_mode in {"horizontal", "horizontal_top"}
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
self.list_layout = self.layout_mode == "list"
self.economy_mode = ui_config.get_economy_mode()
self.missing_executable_path = self._get_missing_executable_path()
self.steam_visible = str(game_source).lower() == "steam"
self.gog_visible = str(game_source).lower() == "gog"
self.egs_visible = str(game_source).lower() == "egs"
self.portproton_visible = str(game_source).lower() == "portproton"
self.steam_visible = (str(game_source).lower() == "steam" and not self.economy_mode)
self.gog_visible = (str(game_source).lower() == "gog" and not self.economy_mode)
self.portproton_visible = (str(game_source).lower() == "portproton" and not self.economy_mode)
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
if self.vertical_layout:
config_name = "GAME_CARD_VERTICAL"
elif self.list_layout:
config_name = "GAME_CARD_LIST"
elif self.horizontal_layout:
config_name = "GAME_CARD_HORIZONTAL"
else:
config_name = "GAME_CARD_GRID"
self.card_layout_cfg = self._get_layout_config(self.theme, config_name)
self.card_geometry_cfg = self._get_card_geometry_config(self.theme)
config_name = "GAME_CARD_LIST" if self.list_layout else "GAME_CARD_GRID"
self.card_layout_cfg = getattr(self.theme, config_name, {})
default_margin = 8 if self.list_layout else 20
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
card_style_name = (
"GAME_CARD_VERTICAL_STYLE"
if self.vertical_layout
else "GAME_CARD_WINDOW_STYLE"
)
self.setProperty("theme_style_name", card_style_name)
card_style = (
self.theme.GAME_CARD_VERTICAL_STYLE
if self.vertical_layout
else self.theme.GAME_CARD_WINDOW_STYLE
)
self.setStyleSheet(card_style)
self.setup_card_animations(self.theme, self.card_layout_cfg)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
self._hovered = False
self._focused = False
self._hover_sound_pending = False
self.setMouseTracking(True)
self.animations = GameCardAnimations(self, self.theme)
self.animations.setup_animations()
self.shadow = QGraphicsDropShadowEffect(self)
self.shadow.setBlurRadius(self.theme.shadow_blur_radius)
@@ -441,7 +301,6 @@ class GameCard(AnimatedCard):
self.coverWidget = QWidget()
if self.list_layout:
self.coverWidget.setProperty("theme_style_name", "COVER_WIDGET_STYLE")
self.coverWidget.setStyleSheet(self.theme.COVER_WIDGET_STYLE)
coverLayout = QStackedLayout(self.coverWidget)
coverLayout.setContentsMargins(0, 0, 0, 0)
@@ -451,8 +310,8 @@ class GameCard(AnimatedCard):
self.coverLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
if self.missing_executable_path or (
str(self.game_source).lower() in ("gog", "egs")
and self.exec_line.startswith(("gog://install/", "egs://install/"))
str(self.game_source).lower() == "gog"
and self.exec_line.startswith("gog://install/")
):
self.coverOpacity = QGraphicsOpacityEffect(self.coverLabel)
self.coverOpacity.setOpacity(self.theme.missing_exe_cover_opacity)
@@ -464,7 +323,6 @@ class GameCard(AnimatedCard):
self.favoriteLabel = ClickableLabel(self.coverWidget)
self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in set(favorites_config.get_games())
self._favorite_icon_state = self.is_favorite
self.update_favorite_icon()
self.favoriteLabel.raise_()
if self.list_layout:
@@ -491,7 +349,9 @@ class GameCard(AnimatedCard):
config=corner_config,
parent=self.coverWidget,
)
self.steamLabel.setVisible(self.steam_visible and not self.vertical_layout)
self.steamLabel.setVisible(self.steam_visible)
if self.economy_mode:
self.steamLabel.setVisible(False)
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
self.gogLabel = SourceCorner(
@@ -499,15 +359,9 @@ class GameCard(AnimatedCard):
config=corner_config,
parent=self.coverWidget,
)
self.gogLabel.setVisible(self.gog_visible and not self.vertical_layout)
egs_icon = self.theme_manager.get_icon("badge_egs", as_path=True)
self.egsLabel = SourceCorner(
icon=egs_icon,
config=corner_config,
parent=self.coverWidget,
)
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
self.gogLabel.setVisible(self.gog_visible)
if self.economy_mode:
self.gogLabel.setVisible(False)
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
self.portprotonLabel = SourceCorner(
@@ -515,9 +369,9 @@ class GameCard(AnimatedCard):
config=corner_config,
parent=self.coverWidget,
)
self.portprotonLabel.setVisible(
self.portproton_visible and not self.vertical_layout
)
self.portprotonLabel.setVisible(self.portproton_visible)
if self.economy_mode:
self.portprotonLabel.setVisible(False)
if self.ppdb_visible:
self.ppdbLabel = ClickableLabel(
@@ -563,36 +417,9 @@ class GameCard(AnimatedCard):
self.nameLabel.setContentsMargins(0, 0, 10, 0)
else:
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
if self.horizontal_layout:
self.nameLabel.setWordWrap(True)
name_style = (
self.theme.GAME_CARD_VERTICAL_NAME_STYLE
if self.vertical_layout
else self.theme.GAME_CARD_NAME_LABEL_STYLE
)
self.nameLabel.setStyleSheet(name_style)
if self.vertical_layout:
self.nameLabel.setProperty(
"theme_style_name", "GAME_CARD_VERTICAL_NAME_STYLE"
)
if self.vertical_layout:
stretches = self.theme.GAME_CARD_VERTICAL["column_stretches"]
self.layout_.addWidget(self.nameLabel, stretches[0])
for text, stretch in zip(
(self.last_launch, self.formatted_playtime, str(self.game_source)),
stretches[1:],
strict=True,
):
label = QLabel(text)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setProperty(
"theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE"
)
label.setStyleSheet(self.theme.GAME_CARD_COLUMN_LABEL_STYLE)
self.layout_.addWidget(label, stretch)
else:
self.layout_.addWidget(self.nameLabel)
if self.list_layout and not self.vertical_layout:
self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
self.layout_.addWidget(self.nameLabel)
if self.list_layout:
self.layout_.addStretch()
font_size = self.nameLabel.font().pointSizeF()
@@ -609,114 +436,6 @@ class GameCard(AnimatedCard):
layout.invalidate()
parent.updateGeometry()
def refresh_theme(self, theme: Any) -> None:
"""Refresh cached card visuals after a live theme change."""
old_theme = self.theme
old_layout_cfg = self.card_layout_cfg
self.theme = theme
if self.vertical_layout:
config_name = "GAME_CARD_VERTICAL"
elif self.list_layout:
config_name = "GAME_CARD_LIST"
elif self.horizontal_layout:
config_name = "GAME_CARD_HORIZONTAL"
else:
config_name = "GAME_CARD_GRID"
self.card_layout_cfg = self._get_layout_config(theme, config_name)
self.card_geometry_cfg = self._get_card_geometry_config(theme)
default_margin = 8 if self.list_layout else 20
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
spacing = self.card_layout_cfg.get("spacing", 12 if self.list_layout else 5)
self.layout_.setSpacing(spacing)
margin = self.base_extra_margin // 2
self.layout_.setContentsMargins(margin, margin, margin, margin)
self.shadow.setBlurRadius(theme.shadow_blur_radius)
self.shadow.setColor(QColor(theme.color_shadow_card))
self.shadow.setOffset(*theme.shadow_offset)
if hasattr(self, "coverOpacity"):
self.coverOpacity.setOpacity(theme.missing_exe_cover_opacity)
if is_valid_protondb_tier(self.protondb_tier):
self.protondbLabel.setStyleSheet(
theme.get_protondb_badge_style(self.protondb_tier)
)
if self.ppdb_id:
ppdb_style = (
theme.get_ppdb_badge_style(self.ppdb_rating)
if self.ppdb_rating
else theme.STEAM_BADGE_STYLE
)
self.ppdbLabel.setStyleSheet(ppdb_style)
if self.getAntiCheatText(self.anticheat_status):
self.anticheatLabel.setStyleSheet(
theme.get_anticheat_badge_style(self.anticheat_status)
)
corner_config = theme.get_source_corner_config()
for label, icon_name in (
(self.steamLabel, "badge_steam"),
(self.gogLabel, "badge_gog"),
(self.egsLabel, "badge_egs"),
(self.portprotonLabel, "badge_portproton"),
):
icon = self.theme_manager.get_icon(
icon_name, self.current_theme_name, as_path=True
)
label.refresh_source_theme(corner_config, icon)
if (
old_theme.GAME_CARD_ANIMATION != theme.GAME_CARD_ANIMATION
or old_layout_cfg != self.card_layout_cfg
):
self.animations.refresh_theme(theme)
else:
self.animations.theme = theme
self.update_favorite_icon()
layout_changed = (
old_layout_cfg != self.card_layout_cfg
or old_theme.COMPACT_CARD != theme.COMPACT_CARD
or old_theme.favoriteLabelSize != theme.favoriteLabelSize
or old_theme.favoriteLabelIconSize != theme.favoriteLabelIconSize
)
if layout_changed:
self.update_scale()
else:
self.update()
def _get_card_geometry_config(self, theme: Any) -> dict:
theme_orientation = self.card_layout_cfg.get(
"card_orientation", "horizontal"
)
if (
self.horizontal_layout
and ui_config.get_horizontal_card_orientation(theme_orientation)
== "vertical"
):
return getattr(theme, "GAME_CARD_GRID", {})
return self.card_layout_cfg
@staticmethod
def _get_layout_config(theme: Any, config_name: str) -> dict:
config = getattr(theme, config_name, {})
get_override = getattr(theme, "get_theme_override", None)
if not callable(get_override) or get_override(config_name) is not None:
return config
config = dict(config)
for name in (
"GAME_CARD_GRID", "GAME_CARD_HORIZONTAL",
"GAME_CARD_LIST", "GAME_CARD_VERTICAL",
):
override = get_override(name)
if isinstance(override, dict) and "card_animation_type" in override:
config["card_animation_type"] = override["card_animation_type"]
break
else:
animation = getattr(theme, "GAME_CARD_ANIMATION", {})
config["card_animation_type"] = animation.get(
"card_animation_type", "gradient"
)
radius = str(getattr(theme, "border_radius_card", "")).removesuffix("px")
if radius.isdigit():
config.update(cover_radius=int(radius), border_radius=int(radius))
return config
def on_cover_loaded(self, pixmap):
self.animated_cover_path = ""
self.base_pixmap = pixmap
@@ -729,8 +448,7 @@ class GameCard(AnimatedCard):
height = cover_size
else:
width = self.base_card_width
layout_config = getattr(self, "card_geometry_cfg", {})
height = int(self.base_card_width * layout_config.get("cover_aspect_ratio", 1.5))
height = int(self.base_card_width * 1.5)
if self._set_animated_cover(cover_path, width, height):
return
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback()
@@ -738,7 +456,7 @@ class GameCard(AnimatedCard):
cover_path,
width,
height,
self.coverReady.emit,
self.on_cover_loaded,
app_name=str(self.appid or ""),
fallback_exe=fallback_exe,
fallback_icon_path=fallback_icon_path,
@@ -873,14 +591,13 @@ class GameCard(AnimatedCard):
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
source_ribbons = [
(self.steam_visible and not self.vertical_layout, self.steamLabel),
(self.gog_visible and not self.vertical_layout, self.gogLabel),
(self.egs_visible and not self.vertical_layout, self.egsLabel),
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
(self.steam_visible, self.steamLabel),
(self.gog_visible, self.gogLabel),
(self.portproton_visible, self.portprotonLabel),
]
for is_visible, ribbon in source_ribbons:
if not is_visible or ribbon is None:
if not is_visible or ribbon is None or hidden_badges:
if ribbon is not None:
ribbon.setVisible(False)
continue
@@ -898,7 +615,6 @@ class GameCard(AnimatedCard):
self.protondbLabel.raise_()
self.portprotonLabel.raise_()
self.gogLabel.raise_()
self.egsLabel.raise_()
self.steamLabel.raise_()
except RuntimeError:
pass
@@ -921,15 +637,7 @@ class GameCard(AnimatedCard):
int(self.card_layout_cfg.get("cover_size", 56) * self._scale),
)
margin_left = self.card_layout_cfg.get("cover_left_margin", 10)
if self.vertical_layout:
self.setMinimumWidth(0)
self.setFixedHeight(row_height + scaled_extra)
self.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
else:
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
self.coverWidget.setFixedSize(icon_size + margin_left, icon_size)
self.coverWidget.setContentsMargins(margin_left, 0, 0, 0)
self.coverLabel.setFixedSize(icon_size, icon_size)
@@ -937,11 +645,11 @@ class GameCard(AnimatedCard):
small_card_mode = self.base_card_width < self.theme.COMPACT_CARD["width_threshold"]
height_ratio = (
self.theme.COMPACT_CARD["height_ratio"]
if small_card_mode else self.card_geometry_cfg.get("card_height_ratio", 1.8)
if small_card_mode else self.card_layout_cfg.get("card_height_ratio", 1.8)
)
scaled_height = int(self.base_card_width * height_ratio * self._scale)
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
cover_ratio = self.card_geometry_cfg.get("cover_aspect_ratio", 1.5)
cover_ratio = self.card_layout_cfg.get("cover_aspect_ratio", 1.5)
cover_height = int(scaled_width * cover_ratio)
self.coverWidget.setFixedSize(scaled_width, cover_height)
self.coverLabel.setFixedSize(scaled_width, cover_height)
@@ -1017,17 +725,23 @@ class GameCard(AnimatedCard):
# Handle the case where the Qt object was deleted
pass
# Ensure parent layout is updated safely
try:
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
layout.activate()
layout.update()
parent.updateGeometry()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def update_card_size(self, new_width: int):
if self.base_card_width == new_width:
return
self.base_card_width = new_width
cover_height = int(new_width * self.card_geometry_cfg.get("cover_aspect_ratio", 1.5))
if not self.animated_cover_path and (
self.base_pixmap is None or self.base_pixmap.isNull()
or self.base_pixmap.width() < new_width
or self.base_pixmap.height() < cover_height
):
self._load_cover_image(self.cover_path or "")
self._load_cover_image(self.cover_path or "")
self.update_scale()
def update_badge_visibility(self, display_filter: str):
@@ -1037,24 +751,19 @@ class GameCard(AnimatedCard):
self.display_filter = display_filter
self.economy_mode = ui_config.get_economy_mode()
self.steam_visible = str(self.game_source).lower() == "steam"
self.gog_visible = str(self.game_source).lower() == "gog"
self.egs_visible = str(self.game_source).lower() == "egs"
self.portproton_visible = str(self.game_source).lower() == "portproton"
self.steam_visible = (str(self.game_source).lower() == "steam" and not self.economy_mode)
self.gog_visible = (str(self.game_source).lower() == "gog" and not self.economy_mode)
self.portproton_visible = (str(self.game_source).lower() == "portproton" and not self.economy_mode)
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
hidden_badges = self.badge_view_mode == "hidden"
show_source_ribbons = self.vertical_layout is not True
try:
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
self.portprotonLabel.setVisible(
self.portproton_visible and show_source_ribbons
)
self.steamLabel.setVisible(self.steam_visible and not hidden_badges)
self.gogLabel.setVisible(self.gog_visible and not hidden_badges)
self.portprotonLabel.setVisible(self.portproton_visible and not hidden_badges)
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
@@ -1216,9 +925,6 @@ class GameCard(AnimatedCard):
# Handle the case where the Qt object was deleted
return
if self._favorite_icon_state == self.is_favorite:
return
self._favorite_icon_state = self.is_favorite
try:
parent = self.parent()
while parent:
@@ -1247,6 +953,76 @@ class GameCard(AnimatedCard):
favorites_config.set_games(favorites)
self.update_favorite_icon()
def getBorderWidth(self) -> int:
return self._borderWidth
def setBorderWidth(self, value: int):
if self._borderWidth != value:
self._borderWidth = value
self.borderWidthChanged.emit()
self.update()
def getGradientAngle(self) -> float:
return self._gradientAngle
def setGradientAngle(self, value: float):
if self._gradientAngle != value:
self._gradientAngle = value
self.gradientAngleChanged.emit()
self.update()
def getScale(self) -> float:
return self._scale
def setScale(self, value: float):
if self._scale != value:
self._scale = value
self.update_scale()
self.scaleChanged.emit()
borderWidth = Property(int, fget=getBorderWidth, fset=setBorderWidth, notify=borderWidthChanged)
gradientAngle = Property(float, fget=getGradientAngle, fset=setGradientAngle, notify=gradientAngleChanged)
scale = Property(float, fget=getScale, fset=setScale, notify=scaleChanged)
def paintEvent(self, event):
super().paintEvent(event)
self.animations.paint_border(QPainter(self))
def enterEvent(self, event):
self._hover_sound_pending = True
self.animations.handle_enter_event()
super().enterEvent(event)
def leaveEvent(self, event):
self._hover_sound_pending = False
self.animations.handle_leave_event()
super().leaveEvent(event)
def mouseMoveEvent(self, event):
if self._hover_sound_pending:
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self._hover_sound_pending = False
super().mouseMoveEvent(event)
def focusInEvent(self, event):
if (
QApplication.activeWindow() is not None
and event.reason() not in (
Qt.FocusReason.ActiveWindowFocusReason,
Qt.FocusReason.MouseFocusReason,
)
):
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self.animations.handle_focus_in_event()
super().focusInEvent(event)
def focusOutEvent(self, event):
self.animations.handle_focus_out_event()
super().focusOutEvent(event)
def hideEvent(self, event: QHideEvent) -> None:
self.set_animated_cover_paused(True)
super().hideEvent(event)
@@ -1255,34 +1031,28 @@ class GameCard(AnimatedCard):
self.set_animated_cover_paused(False)
super().showEvent(event)
def click(self) -> None:
if self._get_missing_executable_path() and self.context_menu_manager:
self.context_menu_manager.handle_missing_executable(self)
return
game_data = {
"name": self.name,
"description": self.description,
"cover_path": self.cover_path,
"appid": self.appid,
"controller_support": self.controller_support,
"exec_line": self.exec_line,
"last_launch": self.last_launch,
"formatted_playtime": self.formatted_playtime,
"playtime_seconds": self.playtime_seconds,
"protondb_tier": self.protondb_tier,
"game_source": self.game_source,
"anticheat_status": self.anticheat_status,
"anticheat_slug": self.anticheat_slug,
"ppdb_id": self.ppdb_id,
"ppdb_rating": self.ppdb_rating,
"protondb_appid": self.protondb_appid,
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
}
self.select_callback(game_data)
def mousePressEvent(self, event: QMouseEvent) -> None:
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.click()
game_data = {
"name": self.name,
"description": self.description,
"cover_path": self.cover_path,
"appid": self.appid,
"controller_support": self.controller_support,
"exec_line": self.exec_line,
"last_launch": self.last_launch,
"formatted_playtime": self.formatted_playtime,
"playtime_seconds": self.playtime_seconds,
"protondb_tier": self.protondb_tier,
"game_source": self.game_source,
"anticheat_status": self.anticheat_status,
"anticheat_slug": self.anticheat_slug,
"ppdb_id": self.ppdb_id,
"ppdb_rating": self.ppdb_rating,
"protondb_appid": self.protondb_appid,
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
}
self.select_callback(game_data)
super().mousePressEvent(event)
def cleanup(self):
+136 -453
View File
@@ -1,6 +1,5 @@
from types import SimpleNamespace
from typing import Any, Protocol
from portprotonqt.game_card import AnimatedCard, GameCard
from typing import Protocol
from portprotonqt.game_card import GameCard
from portprotonqt.search_utils import (
SearchOptimizer,
ThreadedSearch,
@@ -8,42 +7,15 @@ from portprotonqt.search_utils import (
search_index,
)
from PySide6.QtWidgets import QAbstractButton
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
from PySide6.QtCore import QElapsedTimer, QRect, QRectF, Qt, QTimer
from PySide6.QtGui import QPaintEvent, QPainter, QPainterPath, QPixmap, QRegion
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller, QStackedWidget
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QRegion
from portprotonqt.custom_widgets import FlowLayout, AutoHideScrollArea
from portprotonqt.config import favorites_config, game_config, ui_config
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.localization import _
from portprotonqt.detail_pages.utils import remove_cover_background, setup_cover_background
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from collections import deque
class FullLibraryTile(AnimatedCard):
def __init__(self, theme: Any):
super().__init__()
self.name = ""
self.animation_base_size = theme.fullLibraryTileSize
self.tile_pixmap = QPixmap()
self.setup_card_animations(
theme, GameCard._get_layout_config(theme, "GAME_CARD_HORIZONTAL")
)
self.update_scale()
def set_tile_pixmap(self, pixmap: QPixmap) -> None:
self.tile_pixmap = pixmap
self.update()
def paintEvent(self, event: QPaintEvent) -> None:
QFrame.paintEvent(self, event)
painter = QPainter(self)
painter.drawPixmap(self.rect(), self.tile_pixmap)
painter.end()
border_painter = QPainter(self)
self.animations.paint_border(border_painter)
border_painter.end()
class MainWindowProtocol(Protocol):
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
@@ -74,31 +46,17 @@ class GameLibraryManager:
self.game_card_cache = {}
self.pending_images = {}
self.card_width = ui_config.get_card_width()
theme_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid"))
self.layout_mode = ui_config.get_library_layout_mode(theme_mode)
self.layout_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
self.gamesListWidget: QWidget | None = None
self.gamesListLayout: FlowLayout | QHBoxLayout | QVBoxLayout | None = None
self.gamesListLayout: FlowLayout | None = None
self.gamesScrollArea: QScrollArea | None = None
self.libraryHeaderWidget: QWidget | None = None
self.libraryContentLayout: QVBoxLayout | None = None
self.libraryBackgroundLabel: QLabel | None = None
self.fullLibraryTile: FullLibraryTile | None = None
self._full_library_tile_covers: list[str] = []
self._full_library_tile_pixmaps: dict[int, QPixmap] = {}
self.full_library_open = False
self.sizeSlider: QSlider | None = None
self._update_timer: QTimer | None = None
self._card_resize_timer: QTimer | None = None
self._card_resize_queue: deque[tuple[str, str]] = deque()
self._card_resize_batch_size: int = 8
self._incremental_add_timer: QTimer | None = None
self._incremental_add_queue: deque[tuple[str, str]] = deque()
self._incremental_new_games_map: dict[tuple[str, str], tuple] = {}
self._incremental_search_text: str = ""
self._incremental_card_positions: dict[tuple[str, str], int] = {}
self._incremental_batch_size: int = 8
self._incremental_batch_budget_ms: int = 8
self._incremental_scroll_delay_ms: int = 250
self._incremental_batch_size: int = 16
self._focus_first_card_after_update = False
self._pending_update = False
self.pending_deletions = deque()
@@ -111,21 +69,8 @@ class GameLibraryManager:
def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider."""
self.gamesLibraryWidget = QWidget()
self.gamesLibraryWidget.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
stack_layout = QGridLayout(self.gamesLibraryWidget)
if isinstance(library_background, dict):
stack_layout.setContentsMargins(*library_background["margins"])
else:
stack_layout.setContentsMargins(0, 0, 0, 0)
self.libraryBackgroundLabel = QLabel()
stack_layout.addWidget(self.libraryBackgroundLabel, 0, 0)
content_widget = QWidget()
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
layout = QVBoxLayout(content_widget)
stack_layout.addWidget(content_widget, 0, 0)
self.libraryContentLayout = layout
layout = QVBoxLayout(self.gamesLibraryWidget)
layout.setSpacing(15)
# Search widget
@@ -134,41 +79,16 @@ class GameLibraryManager:
# Scroll area for game grid
scrollArea = AutoHideScrollArea(theme=self.theme)
scrollArea.setProperty("theme_style_name", "TRANSPARENT_BACKGROUND_STYLE")
self.gamesScrollArea = scrollArea
scrollArea.setWidgetResizable(True)
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.gamesListWidget = QWidget()
self.gamesListWidget.setProperty("library_layout_mode", self.layout_mode)
self.gamesListWidget.setProperty("theme_style_name", "LIST_WIDGET_STYLE")
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
if self.layout_mode in {"horizontal", "horizontal_top"}:
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
elif self.layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
else:
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
if self.layout_mode == "vertical":
self.libraryHeaderWidget = self._create_library_header()
layout.addWidget(self.libraryHeaderWidget)
layout.addWidget(scrollArea)
# Slider for card size
@@ -186,7 +106,7 @@ class GameLibraryManager:
self.main_window._register_gamepad_tooltip(self.sizeSlider, f"{self.card_width} px")
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
if self.layout_mode in {"list", "vertical", "horizontal", "horizontal_top"}:
if self.layout_mode == "list":
self.sizeSlider.setVisible(False)
self._set_card_width_from_slider()
@@ -197,168 +117,36 @@ class GameLibraryManager:
self._update_timer.setSingleShot(True)
self._update_timer.setInterval(100) # 100ms debounce
self._update_timer.timeout.connect(self._perform_update)
self._card_resize_timer = QTimer(self.gamesListWidget)
self._card_resize_timer.setSingleShot(True)
self._card_resize_timer.timeout.connect(self._resize_next_card_batch)
self._incremental_add_timer = QTimer(self.gamesListWidget)
self._incremental_add_timer = QTimer()
self._incremental_add_timer.setSingleShot(True)
self._incremental_add_timer.timeout.connect(self._process_incremental_add_batch)
# Connect scroll event for lazy loading
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
scrollArea.horizontalScrollBar().valueChanged.connect(self.load_visible_images)
scrollArea.viewportResized.connect(self.force_update_cards_library)
return self.gamesLibraryWidget
def _create_library_header(self) -> QWidget:
"""Create the shared header for vertical library columns."""
config = self.theme.GAME_CARD_VERTICAL
header = QWidget()
header.setFixedHeight(config["header_height"])
header.setProperty("theme_style_name", "LIBRARY_HEADER_STYLE")
header.setStyleSheet(self.theme.LIBRARY_HEADER_STYLE)
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(*config["header_margins"])
header_layout.setSpacing(config["header_spacing"])
header_layout.addSpacing(config["header_cover_width"] + config["header_spacing"])
labels = (_("Game Title"), _("LAST LAUNCH"), _("TIME SPENT"), _("Library"))
for text, stretch in zip(labels, config["column_stretches"], strict=True):
label = QLabel(text.upper())
label.setAlignment(
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
if text == labels[0] else Qt.AlignmentFlag.AlignCenter
)
label.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
label.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
header_layout.addWidget(label, stretch)
if self.gamesScrollArea is not None:
header_layout.addSpacing(self.gamesScrollArea.verticalScrollBar().sizeHint().width())
return header
def rebuild_library_layout(self, layout_mode: str) -> None:
"""Replace the card layout when a theme changes its library mode."""
if self.gamesListWidget is None or self.gamesListLayout is None:
return
self.clear_layout(self.gamesListLayout)
self.fullLibraryTile = None
old_layout = self.gamesListLayout
if layout_mode in {"horizontal", "horizontal_top"}:
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
self.gamesListLayout = QHBoxLayout()
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
elif layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout()
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
else:
self.gamesListLayout = FlowLayout()
QWidget().setLayout(old_layout)
self.gamesListWidget.setLayout(self.gamesListLayout)
if self.gamesScrollArea is not None:
horizontal = layout_mode in {"horizontal", "horizontal_top"}
vertical_policy = (
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
if horizontal
else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if layout_mode == "vertical"
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
self.gamesScrollArea.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
theme_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
self.full_library_open = theme_mode == "horizontal_top" and layout_mode == "grid"
if self.full_library_open and self.libraryBackgroundLabel is not None:
remove_cover_background(self.libraryBackgroundLabel)
self.layout_mode = layout_mode
if (
layout_mode == "vertical"
and self.libraryHeaderWidget is None
and self.libraryContentLayout is not None
):
self.libraryHeaderWidget = self._create_library_header()
self.libraryContentLayout.insertWidget(1, self.libraryHeaderWidget)
if self.libraryHeaderWidget is not None:
self.libraryHeaderWidget.setVisible(layout_mode == "vertical")
self.gamesListWidget.setProperty("library_layout_mode", layout_mode)
if self.sizeSlider is not None:
self.sizeSlider.setVisible(
not self.full_library_open
and layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
)
self._set_card_width_from_slider()
self.main_window.card_width = self.card_width
self.set_games(self.games, focus_first_card=False)
def open_full_library(self) -> None:
"""Open the complete grid from the horizontal top mode."""
if self.layout_mode == "horizontal_top":
self.rebuild_library_layout("grid")
def close_full_library(self) -> bool:
"""Return from the complete grid to the horizontal top mode."""
if not self.full_library_open:
return False
self.full_library_open = False
self.rebuild_library_layout("horizontal_top")
return True
def on_slider_released(self):
"""Handles slider release to update card size."""
if self.full_library_open or self.layout_mode in {
"list", "vertical", "horizontal", "horizontal_top"
}:
if self.layout_mode == "list":
return
if self.sizeSlider is None:
return
self._set_card_width_from_slider()
if hasattr(self.main_window, "_gamepad_tooltip_map"):
self.main_window._gamepad_tooltip_map[self.sizeSlider] = f"{self.card_width} px"
ui_config.set_card_width(self.card_width)
if self.layout_mode != "list":
ui_config.set_card_width(self.card_width)
self.main_window.card_width = self.card_width
self._card_resize_queue = deque(self.game_card_cache)
self._resize_next_card_batch()
def _resize_next_card_batch(self) -> None:
if self.gamesListWidget is None or self.gamesListLayout is None:
self._card_resize_queue.clear()
return
self.gamesListLayout.setEnabled(False)
try:
for _ in range(min(self._card_resize_batch_size, len(self._card_resize_queue))):
card = self.game_card_cache.get(self._card_resize_queue.popleft())
if card is not None:
card.update_card_size(self.card_width)
finally:
self.gamesListLayout.invalidate()
self.gamesListLayout.setEnabled(True)
self.gamesListLayout.activate()
if self._card_resize_queue and self._card_resize_timer is not None:
self._card_resize_timer.start()
else:
self.force_update_cards_library()
self.load_visible_images()
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.update_game_grid()
def _set_card_width_from_slider(self):
"""Use max card width for fixed-size layouts."""
"""Use max card width for list layout."""
if self.sizeSlider is None:
return
if self.full_library_open or self.layout_mode in {
"list", "vertical", "horizontal", "horizontal_top"
}:
if self.layout_mode == "list":
self.card_width = self.sizeSlider.maximum()
else:
self.card_width = self.sizeSlider.value()
@@ -368,8 +156,6 @@ class GameLibraryManager:
"""Loads images for visible game cards."""
if self.gamesListWidget is None:
return
if self._incremental_add_queue and self._incremental_add_timer is not None:
self._incremental_add_timer.start(self._incremental_scroll_delay_ms)
visible_region = self.gamesListWidget.visibleRegion()
max_concurrent_loads = 5
loaded_count = 0
@@ -392,7 +178,6 @@ class GameLibraryManager:
card.set_animated_cover_paused(True)
def stop_background_activity(self) -> None:
self._cancel_incremental_add()
for card in self.game_card_cache.values():
card.stop_background_activity()
@@ -414,7 +199,7 @@ class GameLibraryManager:
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
try:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.animations.handle_leave_event()
self.main_window.current_hovered_card.leaveEvent(None)
except RuntimeError:
pass # Card already deleted
self.main_window.current_hovered_card = None
@@ -425,36 +210,10 @@ class GameLibraryManager:
except RuntimeError:
pass # Card already deleted
self.main_window.current_focused_card = card
self._update_library_background(card)
else:
if self.main_window.current_focused_card == card:
self.main_window.current_focused_card = None
def _update_library_background(self, card: GameCard) -> None:
"""Render the configured library background from the active cover."""
if self.full_library_open or self.libraryBackgroundLabel is None:
return
self.render_library_background(self.libraryBackgroundLabel, card)
def render_library_background(self, label: QLabel, card: GameCard) -> None:
"""Render the configured library background on a target label."""
pixmap = card.coverLabel.pixmap()
if pixmap is None or pixmap.isNull():
return
library_config = getattr(self.theme, "LIBRARY_BACKGROUND", None)
if not isinstance(library_config, dict):
remove_cover_background(label)
return
backgrounds = dict(getattr(self.theme, "DETAIL_PAGE_BACKGROUNDS", {}))
backgrounds.update(library_config.get("backgrounds", {}))
background_theme = SimpleNamespace(
DETAIL_PAGE_BG_MODE=library_config.get("mode", "gradient"),
DETAIL_PAGE_BACKGROUNDS=backgrounds,
)
setup_cover_background(
label, pixmap, self.main_window, background_theme
)
def _collapse_library_filter_controls(self) -> None:
if getattr(self.main_window, "_library_controls_hover_close_delayed", False):
return
@@ -492,11 +251,10 @@ class GameLibraryManager:
try:
if self.main_window.current_hovered_card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.animations.handle_leave_event()
self.main_window.current_hovered_card.leaveEvent(None)
except RuntimeError:
pass # Card already deleted
self.main_window.current_hovered_card = card
self._update_library_background(card)
else:
if self.main_window.current_hovered_card == card:
self.main_window.current_hovered_card = None
@@ -517,16 +275,9 @@ class GameLibraryManager:
"""Schedules a game grid update with debouncing."""
if focus_first_card is not None:
self._focus_first_card_after_update = focus_first_card
theme_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
if not self.full_library_open:
self.layout_mode = theme_mode
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
if self.sizeSlider is not None:
self.sizeSlider.setVisible(
not self.full_library_open
and self.layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
)
self.sizeSlider.setVisible(self.layout_mode != "list")
old_card_width = self.card_width
self._set_card_width_from_slider()
self.main_window.card_width = self.card_width
@@ -541,9 +292,6 @@ class GameLibraryManager:
# When filtering, we want to update with the current filtered_games
# which has already been set by _perform_search
pass
if is_filter and self.layout_mode == "horizontal_top":
self.dirty = True
is_filter = False
self.is_filtering = is_filter
self._pending_update = True
@@ -560,42 +308,18 @@ class GameLibraryManager:
def _perform_force_update(self):
"""Perform the actual force update on the layout."""
self._fit_horizontal_cards()
if self.gamesListLayout:
self.gamesListLayout.invalidate()
if self.gamesListWidget:
self.gamesListWidget.adjustSize()
self.gamesListWidget.updateGeometry()
def _fit_horizontal_cards(self, card: GameCard | None = None) -> None:
if self.layout_mode not in {"horizontal", "horizontal_top"}:
return
if self.gamesScrollArea is None or self.gamesListLayout is None:
return
margins = self.gamesListLayout.contentsMargins()
height = self.gamesScrollArea.viewport().height() - margins.top() - margins.bottom()
if height <= 0:
return
cards = (card,) if card is not None else self.game_card_cache.values()
for card in cards:
config = card.card_layout_cfg
scale = max(
config.get(name, self.theme.GAME_CARD_ANIMATION[name])
for name in ("default_scale", "hover_scale", "focus_scale")
)
available = height / scale - card.base_extra_margin
ratio = card.card_geometry_cfg.get("card_height_ratio", 1.8)
width = min(self.card_width, max(1, int(available / ratio)))
if width < self.theme.COMPACT_CARD["width_threshold"]:
width = min(width, max(1, int(available / self.theme.COMPACT_CARD["height_ratio"])))
card.update_card_size(width)
def _cancel_incremental_add(self) -> None:
if self._incremental_add_timer is not None and self._incremental_add_timer.isActive():
self._incremental_add_timer.stop()
self._incremental_add_queue.clear()
self._incremental_new_games_map = {}
self._incremental_search_text = ""
self._incremental_card_positions = {}
def _start_incremental_add(
self,
@@ -606,43 +330,41 @@ class GameLibraryManager:
if self.gamesListLayout is None:
return
self._cancel_incremental_add()
self._incremental_card_positions = {key: index for index, key in enumerate(card_order)}
desired_keys = set(card_order)
self.gamesListLayout.setEnabled(False)
try:
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
for key, card in self.game_card_cache.items():
visible = key in desired_keys and (not search_text or search_text in key[0].lower())
card.setVisible(visible)
card.set_animated_cover_paused(not visible)
for key in card_order:
card = self.game_card_cache.get(key)
if card is not None:
self.gamesListLayout.addWidget(card)
finally:
self.gamesListLayout.setEnabled(True)
self._incremental_add_queue = deque(key for key in card_order if key not in self.game_card_cache)
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
for card in self.game_card_cache.values():
if card.isVisible():
card.setVisible(False)
self._incremental_add_queue = deque(card_order)
self._incremental_new_games_map = new_games_map
self._incremental_search_text = search_text
self._process_incremental_add_batch()
if self._incremental_add_timer is not None:
self._incremental_add_timer.start(0)
def _process_incremental_add_batch(self) -> None:
if self.gamesListLayout is None or self.gamesListWidget is None:
self._cancel_incremental_add()
return
batch_timer = QElapsedTimer()
batch_timer.start()
self.gamesListLayout.setEnabled(False)
added_new_card = False
processed = 0
max_batch = min(self._incremental_batch_size, len(self._incremental_add_queue))
self.gamesListWidget.setUpdatesEnabled(False)
try:
for _ in range(min(self._incremental_batch_size, len(self._incremental_add_queue))):
while processed < max_batch:
game_key = self._incremental_add_queue.popleft()
game_data = self._incremental_new_games_map.get(game_key)
if game_data is None or self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card = self.game_card_cache.get(game_key)
if card is None:
if self.context_menu_manager is None:
processed += 1
continue
game_data = self._incremental_new_games_map.get(game_key)
if game_data is None:
processed += 1
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
added_new_card = True
should_be_visible = (
not self._incremental_search_text or
self._incremental_search_text in str(game_key[0]).lower()
@@ -650,29 +372,23 @@ class GameLibraryManager:
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
card.set_animated_cover_paused(not should_be_visible)
self.gamesListLayout.insertWidget(self._incremental_card_positions[game_key], card)
if batch_timer.elapsed() >= self._incremental_batch_budget_ms:
break
self.gamesListLayout.addWidget(card)
processed += 1
finally:
self.gamesListLayout.invalidate()
self.gamesListLayout.setEnabled(True)
if self.layout_mode in {"horizontal", "horizontal_top"}:
self.gamesListWidget.resize(
self.gamesListWidget.size().expandedTo(self.gamesListLayout.minimumSize())
)
self.gamesListLayout.activate()
self.gamesListWidget.setUpdatesEnabled(True)
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self._focus_first_visible_card()
if self._incremental_add_queue:
if self._incremental_add_timer is not None:
self._incremental_add_timer.start(self._incremental_batch_budget_ms)
self._incremental_add_timer.start(0)
return
self.load_visible_images()
self._sync_full_library_tile()
if added_new_card:
self.load_visible_images()
self.force_update_cards_library()
self._cancel_incremental_add()
self._schedule_focus_first_card()
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
@@ -688,13 +404,12 @@ class GameLibraryManager:
self._update_search_results(search_text)
else:
# Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games
if self.layout_mode != "horizontal_top" and not games_list:
games_list = self.games
games_list = self.filtered_games if self.filtered_games else self.games
favorites = favorites_config.get_games()
sort_method = game_config.get_sort_method()
# Batch layout updates (extended scope)
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
@@ -718,31 +433,15 @@ class GameLibraryManager:
sorted_fav = sorted(fav_games, key=partition_sort_key)
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
sorted_games = sorted_fav + sorted_non_fav
if self.layout_mode == "horizontal_top":
limit = self.theme.horizontalTopGameLimit
tile_games = sorted_games[limit:limit + self.theme.fullLibraryTileGameCount]
self._full_library_tile_covers = [str(game[2] or "") for game in tile_games]
sorted_games = sorted_games[:limit]
else:
self._full_library_tile_covers = []
# Build set of current game keys for faster lookup
current_game_keys = {(game[0], game[5]) for game in sorted_games}
# Remove cards that no longer exist (batch)
retained_keys = {
(game[0], game[5])
for games in getattr(self.main_window, "_loaded_library_cache", {}).values()
for game in games
}
cards_to_remove = []
for card_key in list(self.game_card_cache.keys()):
if card_key not in current_game_keys:
card = self.game_card_cache[card_key]
card.setVisible(False)
card.set_animated_cover_paused(True)
if card_key not in retained_keys:
cards_to_remove.append(card_key)
cards_to_remove.append(card_key)
for card_key in cards_to_remove:
card = self.game_card_cache.pop(card_key)
@@ -755,15 +454,15 @@ class GameLibraryManager:
# Track current layout order (only if dirty/full update needed)
if self.dirty and self.gamesListLayout is not None:
current_layout_order = []
card_keys = {card: key for key, card in self.game_card_cache.items()}
for i in range(self.gamesListLayout.count()):
item = self.gamesListLayout.itemAt(i)
if item is not None:
widget = item.widget()
if widget:
key = card_keys.get(widget)
if key is not None:
current_layout_order.append(key)
for key, card in self.game_card_cache.items():
if card == widget:
current_layout_order.append(key)
break
else:
current_layout_order = None # Skip reorg if not dirty
@@ -819,81 +518,22 @@ class GameLibraryManager:
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.force_update_cards_library()
self.is_filtering = False # Reset flag in any case
if not self._incremental_add_queue:
self._sync_full_library_tile()
self._schedule_focus_first_card()
def _sync_full_library_tile(self) -> None:
if self.fullLibraryTile is not None:
self.fullLibraryTile.setParent(None)
self.fullLibraryTile.deleteLater()
self.fullLibraryTile = None
if self.layout_mode != "horizontal_top" or self.gamesListLayout is None:
return
if len(self.filtered_games) <= self.theme.horizontalTopGameLimit:
return
tile = FullLibraryTile(self.theme)
tile.setProperty("full_library_tile", True)
tile.setStyleSheet(self.theme.FULL_LIBRARY_TILE_STYLE)
tile.clicked.connect(self.open_full_library)
self.gamesListLayout.addWidget(tile)
self.fullLibraryTile = tile
self._full_library_tile_pixmaps = {}
cover_width, cover_height = self.theme.fullLibraryTileSize
for index, cover_path in enumerate(self._full_library_tile_covers):
load_pixmap_async(
cover_path,
cover_width,
cover_height,
lambda pixmap, item=index: self._set_full_library_tile_cover(
tile, item, pixmap
),
)
def _set_full_library_tile_cover(
self, tile: FullLibraryTile, index: int, pixmap: QPixmap
) -> None:
if tile is not self.fullLibraryTile:
return
self._full_library_tile_pixmaps[index] = pixmap
width, height = self.theme.fullLibraryTileSize
cell_width = width // self.theme.fullLibraryTileColumns
cell_height = height // self.theme.fullLibraryTileRows
canvas = QPixmap(width, height)
canvas.fill(Qt.GlobalColor.transparent)
painter = QPainter(canvas)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
clip = QPainterPath()
clip.addRoundedRect(
QRectF(canvas.rect()),
self.theme.fullLibraryTileRadius,
self.theme.fullLibraryTileRadius,
)
painter.setClipPath(clip)
for item, cover in self._full_library_tile_pixmaps.items():
cell = QRect(
item % self.theme.fullLibraryTileColumns * cell_width,
item // self.theme.fullLibraryTileColumns * cell_height,
cell_width,
cell_height,
)
painter.drawPixmap(cell, cover)
painter.end()
tile.set_tile_pixmap(canvas)
def _schedule_focus_first_card(self) -> None:
if not self._focus_first_card_after_update:
return
QTimer.singleShot(0, self._focus_first_visible_card)
def _focus_first_visible_card(self) -> None:
if not self._focus_first_card_after_update or self.gamesListWidget is None:
if self.gamesListWidget is None:
return
if getattr(self.main_window.stackedWidget, "currentIndex", lambda: -1)() != 0:
return
@@ -910,14 +550,74 @@ class GameLibraryManager:
if self.gamesListLayout is None or self.gamesListWidget is None:
return
visible_keys = {(game[0], game[5]) for game in self.filtered_games}
card_order = [key for key in self.game_card_cache if key in visible_keys]
new_games_map = {
(game[0], game[5]): game for game in self.filtered_games
if (game[0], game[5]) not in self.game_card_cache
}
card_order.extend(new_games_map)
self._start_incremental_add(card_order, new_games_map, search_text)
# Batch layout updates
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Create set of keys for current filtered games for fast lookup
filtered_keys = {(game[0], game[5]) for game in self.filtered_games} # (name, exec_line)
# Process existing cards: show cards that are in filtered results, hide others
cards_to_hide = []
for card_key, card in self.game_card_cache.items():
if card_key in filtered_keys:
# Card should be visible
if not card.isVisible():
card.setVisible(True)
card.set_animated_cover_paused(False)
else:
# Card should be hidden
if card.isVisible():
card.setVisible(False)
cards_to_hide.append(card_key)
card.set_animated_cover_paused(True)
# Now add any missing cards that are in filtered results but not in cache
cards_to_add = []
for game_data in self.filtered_games:
game_name = game_data[0]
exec_line = game_data[5]
game_key = (game_name, exec_line)
if game_key not in self.game_card_cache:
if self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(True) # New cards should be visible
card.set_animated_cover_paused(False)
cards_to_add.append((game_key, card))
# Add new cards to layout
for _game_key, card in cards_to_add:
self.gamesListLayout.addWidget(card)
# Remove cards that are no longer needed (if any)
# Note: we're not removing them completely as they might be needed later
# Instead, we just hide them and they'll be reused if needed
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.force_update_cards_library()
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()
# If search is empty, load images for visible ones
if not search_text:
self.load_visible_images()
else:
QTimer.singleShot(0, self.load_visible_images)
def _create_game_card(self, game_data: tuple) -> GameCard:
"""Creates a new game card with all necessary connections."""
@@ -933,8 +633,6 @@ class GameLibraryManager:
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
self._fit_horizontal_cards(card)
if self.context_menu_manager:
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
@@ -980,8 +678,6 @@ class GameLibraryManager:
# Always schedule widget for deletion regardless of cache state
if isinstance(widget, GameCard):
widget.cleanup()
if widget is self.fullLibraryTile:
self.fullLibraryTile = None
widget.deleteLater()
# Also clear the cache completely if needed (in case layout wasn't in sync)
@@ -1025,9 +721,7 @@ class GameLibraryManager:
def add_game_incremental(self, game_data: tuple):
"""Add a single game without full reload."""
self.games.append(game_data)
if self.filtered_games is not self.games:
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
self._build_search_indices(self.games)
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
self.dirty = True
self.update_game_grid()
self._update_missing_exe_button()
@@ -1054,17 +748,6 @@ class GameLibraryManager:
self.update_game_grid()
self._update_missing_exe_button()
detail_manager = getattr(self.main_window, "detail_page_manager", None)
source = getattr(detail_manager, "_current_detail_source", None)
if detail_manager and source and source[0] == "game" and detail_manager._detail_page_active:
detail_data = source[1]
if (detail_data.get("name"), detail_data.get("exec_line")) == old_key:
detail_data.update(
name=game_data[0], description=game_data[1],
cover_path=game_data[2], exec_line=game_data[5],
)
detail_manager._reopen_current_detail_page()
def remove_game_incremental(self, game_name: str, exec_line: str):
"""Remove a single game without full reload."""
key = (game_name, exec_line)
+244
View File
@@ -0,0 +1,244 @@
import ctypes
import ctypes.util
from dataclasses import dataclass
from enum import Enum
from functools import lru_cache
from typing import Any
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
SDL_INIT_GAMEPAD = 0x00002000
SDL_GAMEPAD_TYPE_STANDARD = 1
SDL_GAMEPAD_TYPE_XBOX360 = 2
SDL_GAMEPAD_TYPE_XBOXONE = 3
SDL_GAMEPAD_TYPE_PS3 = 4
SDL_GAMEPAD_TYPE_PS4 = 5
SDL_GAMEPAD_TYPE_PS5 = 6
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
SDL_GAMEPAD_TYPE_GAMECUBE = 11
SDL_GAMEPAD_TYPE_STEAM = 12
SDL3_XBOX_LIKE_TYPES = {
SDL_GAMEPAD_TYPE_STANDARD,
SDL_GAMEPAD_TYPE_XBOX360,
SDL_GAMEPAD_TYPE_XBOXONE,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
SDL_GAMEPAD_TYPE_GAMECUBE,
SDL_GAMEPAD_TYPE_STEAM,
}
SDL3_PLAYSTATION_TYPES = {
SDL_GAMEPAD_TYPE_PS3,
SDL_GAMEPAD_TYPE_PS4,
SDL_GAMEPAD_TYPE_PS5,
}
SDL_GAMEPAD_BUTTON_SOUTH = 0
SDL_GAMEPAD_BUTTON_EAST = 1
SDL_GAMEPAD_BUTTON_WEST = 2
SDL_GAMEPAD_BUTTON_NORTH = 3
SDL_GAMEPAD_BUTTON_BACK = 4
SDL_GAMEPAD_BUTTON_GUIDE = 5
SDL_GAMEPAD_BUTTON_START = 6
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
SDL_GAMEPAD_AXIS_LEFTX = 0
SDL_GAMEPAD_AXIS_LEFTY = 1
SDL_GAMEPAD_AXIS_RIGHTX = 2
SDL_GAMEPAD_AXIS_RIGHTY = 3
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
def _get_sdl3_error(sdl: ctypes.CDLL) -> str:
error = sdl.SDL_GetError()
if not error:
return ""
return error.decode(errors="replace")
def _configure_sdl3_gamepad_api(sdl: ctypes.CDLL) -> None:
sdl.SDL_InitSubSystem.argtypes = [ctypes.c_uint32]
sdl.SDL_InitSubSystem.restype = ctypes.c_bool
sdl.SDL_GetGamepads.argtypes = [ctypes.POINTER(ctypes.c_int)]
sdl.SDL_GetGamepads.restype = ctypes.POINTER(ctypes.c_uint32)
sdl.SDL_GetGamepadTypeForID.argtypes = [ctypes.c_uint32]
sdl.SDL_GetGamepadTypeForID.restype = ctypes.c_int
sdl.SDL_GetGamepadNameForID.argtypes = [ctypes.c_uint32]
sdl.SDL_GetGamepadNameForID.restype = ctypes.c_char_p
sdl.SDL_OpenGamepad.argtypes = [ctypes.c_uint32]
sdl.SDL_OpenGamepad.restype = ctypes.c_void_p
sdl.SDL_CloseGamepad.argtypes = [ctypes.c_void_p]
sdl.SDL_CloseGamepad.restype = None
sdl.SDL_GamepadConnected.argtypes = [ctypes.c_void_p]
sdl.SDL_GamepadConnected.restype = ctypes.c_bool
sdl.SDL_GetGamepadName.argtypes = [ctypes.c_void_p]
sdl.SDL_GetGamepadName.restype = ctypes.c_char_p
sdl.SDL_GetGamepadButton.argtypes = [ctypes.c_void_p, ctypes.c_int]
sdl.SDL_GetGamepadButton.restype = ctypes.c_bool
sdl.SDL_GetGamepadAxis.argtypes = [ctypes.c_void_p, ctypes.c_int]
sdl.SDL_GetGamepadAxis.restype = ctypes.c_int16
sdl.SDL_UpdateGamepads.argtypes = []
sdl.SDL_UpdateGamepads.restype = None
sdl.SDL_PumpEvents.argtypes = []
sdl.SDL_PumpEvents.restype = None
sdl.SDL_QuitSubSystem.argtypes = [ctypes.c_uint32]
sdl.SDL_QuitSubSystem.restype = None
sdl.SDL_GetError.argtypes = []
sdl.SDL_GetError.restype = ctypes.c_char_p
sdl.SDL_free.argtypes = [ctypes.c_void_p]
sdl.SDL_free.restype = None
@lru_cache(maxsize=1)
def _load_sdl3() -> ctypes.CDLL | None:
library_names = (
ctypes.util.find_library("SDL3"),
"libSDL3.so.0",
"libSDL3.so",
)
for library_name in library_names:
if not library_name:
continue
try:
sdl = ctypes.CDLL(library_name)
_configure_sdl3_gamepad_api(sdl)
except (AttributeError, OSError) as e:
logger.debug("Failed to load SDL3 from %s: %s", library_name, e)
continue
if sdl.SDL_InitSubSystem(SDL_INIT_GAMEPAD):
return sdl
logger.debug("Failed to initialize SDL3 gamepad subsystem: %s", _get_sdl3_error(sdl))
return None
def _decode_sdl3_name(name: bytes | None) -> str:
if not name:
return ""
return name.decode(errors="replace")
@dataclass
class SDLGamepad:
sdl: ctypes.CDLL
controller: ctypes.c_void_p
name: str
path: str
instance_id: int
def close(self) -> None:
try:
self.sdl.SDL_CloseGamepad(self.controller)
except (AttributeError, OSError) as e:
logger.debug("Failed to close SDL gamepad: %s", e)
def connected(self) -> bool:
return bool(self.sdl.SDL_GamepadConnected(self.controller))
def update(self) -> None:
self.sdl.SDL_UpdateGamepads()
def get_button(self, button: int) -> int:
return int(self.sdl.SDL_GetGamepadButton(self.controller, button))
def get_axis(self, axis: int) -> int:
return int(self.sdl.SDL_GetGamepadAxis(self.controller, axis))
def find_gamepad() -> SDLGamepad | None:
sdl = _load_sdl3()
if sdl is None:
return None
sdl.SDL_PumpEvents()
sdl.SDL_UpdateGamepads()
count = ctypes.c_int()
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
if not gamepads:
return None
try:
return _open_first_sdl3_gamepad(sdl, gamepads, count.value)
finally:
sdl.SDL_free(gamepads)
def _open_first_sdl3_gamepad(
sdl: ctypes.CDLL,
gamepads: Any,
count: int,
) -> SDLGamepad | None:
for index in range(count):
instance_id = int(gamepads[index])
gamepad = sdl.SDL_OpenGamepad(instance_id)
if not gamepad:
logger.debug("Skipping SDL3 gamepad %s: %s", instance_id, _get_sdl3_error(sdl))
continue
return SDLGamepad(
sdl=sdl,
controller=gamepad,
name=_decode_sdl3_name(sdl.SDL_GetGamepadName(gamepad)),
path=f"sdl3-gamepad:{instance_id}",
instance_id=instance_id,
)
return None
def detect_gamepad_type(gamepad: SDLGamepad) -> GamepadType:
sdl = _load_sdl3()
if sdl is None:
return GamepadType.XBOX
count = ctypes.c_int()
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
if not gamepads:
return GamepadType.XBOX
try:
gamepad_type = _find_sdl3_gamepad_type(sdl, gamepads, count.value, gamepad.name)
finally:
sdl.SDL_free(gamepads)
return gamepad_type or GamepadType.XBOX
def _find_sdl3_gamepad_type(
sdl: ctypes.CDLL,
gamepads: Any,
count: int,
target_name: str,
) -> GamepadType | None:
for index in range(count):
gamepad_id = gamepads[index]
sdl_name = _decode_sdl3_name(sdl.SDL_GetGamepadNameForID(gamepad_id))
if count > 1 and sdl_name.casefold() != target_name.casefold():
continue
gamepad_type = _gamepad_type_from_sdl3_value(sdl.SDL_GetGamepadTypeForID(gamepad_id))
if gamepad_type is not None:
return gamepad_type
return None
def _gamepad_type_from_sdl3_value(sdl_type: int) -> GamepadType | None:
if sdl_type in SDL3_PLAYSTATION_TYPES:
return GamepadType.PLAYSTATION
if sdl_type in SDL3_XBOX_LIKE_TYPES:
return GamepadType.XBOX
return None
def shutdown() -> None:
sdl = _load_sdl3()
if sdl is not None:
sdl.SDL_QuitSubSystem(SDL_INIT_GAMEPAD)
+6 -117
View File
@@ -6,8 +6,6 @@ import platform
import re
import shutil
import subprocess
import time
import zlib
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
@@ -27,14 +25,10 @@ GOG_LOGIN_URL = (
)
GOGDL_RELEASE_URL = "https://api.github.com/repos/Heroic-Games-Launcher/heroic-gogdl/releases/latest"
GOGDL_AUTH_TIMEOUT = 60
GOGDL_UPDATE_INTERVAL = 30 * 24 * 60 * 60
GOG_USER_TIMEOUT = 15
GOG_METADATA_WORKERS = 4
GOG_SETUP_MARKER_VERSION = 1
GOG_SETUP_TIMEOUT = 600
GOG_REPAIR_METADATA_TIMEOUT = 30
GOG_COMPRESSED_MANIFEST_VERSION = 2
GOG_BUILDS_API_VERSION = 2
GOG_PRODUCT_LOCALES = {
"bg": "bg-BG", "cs": "cs-CZ", "da": "da-DK", "de": "de-DE",
"el": "el-GR", "en": "en-US", "es": "es-ES", "fi": "fi-FI",
@@ -51,26 +45,10 @@ class GOGAPI:
def __init__(self) -> None:
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
self.data_dir = data_home / "PortProtonQt" / "launcher" / "gog"
self.bin_dir = data_home / "PortProtonQt" / "bin"
# Remove after the legacy launcher storage migration period.
legacy_dir = data_home / "PortProtonQt" / "gog"
if legacy_dir.exists() and not self.data_dir.exists():
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
legacy_dir.rename(self.data_dir)
legacy_bin = self.data_dir / "bin"
if legacy_bin.is_dir():
self.bin_dir.mkdir(parents=True, exist_ok=True)
for legacy_binary in legacy_bin.iterdir():
target = self.bin_dir / legacy_binary.name
if not target.exists():
legacy_binary.rename(target)
if not any(legacy_bin.iterdir()):
legacy_bin.rmdir()
self.data_dir = data_home / "PortProtonQt" / "gog"
self.auth_path = self.data_dir / "auth.json"
self.account_path = self.data_dir / "account.json"
self.config_dir = self.data_dir / "gogdl"
self.gogdl_version_path = self.bin_dir / "gogdl.version"
self.library_path = self.data_dir / "library.json"
self.installed_path = self.data_dir / "installed.json"
self.sizes_path = self.data_dir / "sizes.json"
@@ -78,7 +56,7 @@ class GOGAPI:
def get_gogdl_path(self) -> str | None:
"""Return an available gogdl executable."""
bundled = self.bin_dir / "gogdl"
bundled = self.data_dir / "bin/gogdl"
if bundled.is_file() and os.access(bundled, os.X_OK):
return str(bundled)
return shutil.which("gogdl")
@@ -98,62 +76,22 @@ class GOGAPI:
existing = self.get_gogdl_path()
if existing:
return existing
asset, release_tag = self._get_latest_gogdl_release()
return self._install_gogdl_release(asset, release_tag)
def update_gogdl(self) -> str:
"""Update the bundled gogdl binary to the latest official release."""
bundled = self.bin_dir / "gogdl"
if bundled.is_file() and os.access(bundled, os.X_OK):
try:
last_check = self.gogdl_version_path.stat().st_mtime
except OSError:
last_check = 0
if time.time() - last_check < GOGDL_UPDATE_INTERVAL:
return str(bundled)
self.gogdl_version_path.touch()
asset, release_tag = self._get_latest_gogdl_release()
if (
bundled.is_file()
and os.access(bundled, os.X_OK)
and self._get_gogdl_release_tag() == release_tag
):
return str(bundled)
return self._install_gogdl_release(asset, release_tag)
def _get_latest_gogdl_release(self) -> tuple[dict, str]:
"""Return the matching asset and tag from the latest gogdl release."""
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(platform.machine())
if not architecture:
raise OSError(f"Unsupported gogdl architecture: {platform.machine()}")
response = requests.get(GOGDL_RELEASE_URL, timeout=15)
response.raise_for_status()
release = response.json()
asset_name = f"gogdl_linux_{architecture}"
asset = next(
(item for item in release.get("assets", []) if item.get("name") == asset_name),
(item for item in response.json().get("assets", []) if item.get("name") == asset_name),
None,
)
if not asset:
raise OSError(f"gogdl release asset not found: {asset_name}")
release_tag = str(release.get("tag_name", ""))
if not release_tag:
raise OSError("gogdl release tag not found")
return asset, release_tag
def _get_gogdl_release_tag(self) -> str:
try:
return self.gogdl_version_path.read_text(encoding="utf-8").strip()
except OSError:
return ""
def _install_gogdl_release(self, asset: dict, release_tag: str) -> str:
path = self._download_gogdl_asset(asset)
self.gogdl_version_path.write_text(release_tag, encoding="utf-8")
return path
return self._download_gogdl_asset(asset)
def _download_gogdl_asset(self, asset: dict) -> str:
target = self.bin_dir / "gogdl"
target = self.data_dir / "bin/gogdl"
temporary = target.with_suffix(".part")
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
@@ -394,53 +332,6 @@ class GOGAPI:
logger.warning("Failed to inspect GOG installation path %s: %s", parent, error)
return None
def prepare_repair_manifest(self, app_id: str, install_path: Path) -> None:
"""Restore gogdl metadata before verifying an imported Windows game."""
manifest_path = self.config_dir / "heroic_gogdl" / "manifests" / app_id
if manifest_path.is_file():
return
info = self._load_json(install_path / f"goggame-{app_id}.info", {})
build_info = self._load_json(install_path / f"goggame-{app_id}.id", {})
build_id = build_info.get("buildId") or info.get("buildId")
credentials = self.get_credentials()
response = requests.get(
f"https://content-system.gog.com/products/{app_id}/os/windows/builds",
params={"generation": GOG_COMPRESSED_MANIFEST_VERSION, "_version": GOG_BUILDS_API_VERSION},
headers={"Authorization": f"Bearer {credentials.get('access_token', '')}"},
timeout=GOG_REPAIR_METADATA_TIMEOUT,
)
response.raise_for_status()
builds = response.json().get("items", [])
build = next((item for item in builds if (
str(item.get("build_id")) == str(build_id) if build_id
else not item.get("branch")
)), None)
if build is None and build_id:
build = next((item for item in builds if not item.get("branch") and item.get("urls")), None)
if build:
logger.warning(
"GOG build %s for %s is unavailable; verifying against build %s",
build_id, app_id, build.get("build_id"),
)
if not build or not build.get("urls"):
raise OSError(f"GOG build not found for installed game {app_id}")
response = requests.get(build["urls"][0]["url"], timeout=GOG_REPAIR_METADATA_TIMEOUT)
response.raise_for_status()
data = response.content
if build.get("generation") == GOG_COMPRESSED_MANIFEST_VERSION:
data = zlib.decompress(data)
manifest = orjson.loads(data)
manifest["HGLPlatform"] = "windows"
manifest["HGLInstallLanguage"] = (
info.get("language") or next(iter(info.get("languages", [])), "en-US")
)
manifest["HGLdlcs"] = [
{"id": path.stem.removeprefix("goggame-")}
for path in install_path.glob("goggame-*.info")
if path.stem != f"goggame-{app_id}"
]
self._save_json(manifest_path, manifest)
def get_launch_target(self, app_id: str) -> str | None:
"""Return the primary executable from gogdl installation metadata."""
primary, install_path = self._get_primary_task(app_id)
@@ -561,8 +452,6 @@ class GOGAPI:
"""Run GOG setup and expose its process for UI cancellation."""
process = subprocess.Popen(
command, cwd=install_path, start_new_session=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, errors="replace", bufsize=1,
)
if process_callback:
process_callback(process)
@@ -681,7 +570,7 @@ class GOGAPI:
response.raise_for_status()
data = response.json()
game = data.get("game", {})
if data.get("type") not in {"game", "mod"} or not game.get("visible_in_library", True):
if not game.get("visible_in_library", True):
return {}
steam_release = next(
(
+12 -51
View File
@@ -2,12 +2,10 @@
import os
import re
import shlex
import struct
import io
import pefile
from PIL import Image
from portprotonqt.config.base import CACHE_DIR
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -17,41 +15,16 @@ RT_GROUP_ICON = 14
THUMBNAIL_SIZE = 128
def _resolve_batch_icon_target(inputfile: str) -> str | None:
"""Return an executable launched by a neighboring command file."""
input_dir = os.path.dirname(inputfile)
try:
command_files = sorted(
filename for filename in os.listdir(input_dir)
if filename.lower().endswith((".bat", ".cmd"))
)
except (OSError, ValueError) as error:
logger.warning("Failed to inspect command files in %s: %s", input_dir, error)
return None
for filename in command_files:
batch_path = os.path.join(input_dir, filename)
try:
with open(batch_path, encoding="utf-8") as batch_file:
for line in batch_file:
for token in shlex.split(line, posix=False):
exe_name = token.strip('"')
exe_path = os.path.join(input_dir, exe_name)
if (
exe_name.lower().endswith(".exe")
and os.path.isfile(exe_path)
):
return exe_path
except (OSError, UnicodeError, ValueError) as error:
logger.warning("Failed to process command file %s: %s", batch_path, error)
return None
def get_exe_icon_cache_path(exe_path: str) -> str:
"""Return the shared cache path for an executable icon."""
cache_home = os.getenv(
"XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache"),
)
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
return os.path.join(
CACHE_DIR,
cache_home,
"PortProtonQt",
"images",
"exe_icons",
f"{icon_name}.png",
@@ -71,10 +44,6 @@ def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resi
try:
extractor = IconExtractor(inputfile)
data = extractor.get_icon()
if data is None:
target_path = _resolve_batch_icon_target(inputfile)
if target_path:
data = IconExtractor(target_path).get_icon()
if data is None:
return False
@@ -86,18 +55,14 @@ def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resi
im.save(outfile, "PNG")
logger.info("Thumbnail successfully saved to %s", outfile)
return True
except IconExtractorError as error:
logger.warning("Icon extraction failed: %s", error)
return False
except Exception as error:
logger.error("Error generating thumbnail: %s", error)
except Exception as e:
logger.error("Error generating thumbnail: %s", e)
return False
class IconExtractorError(Exception):
"""Base exception for icon extraction errors."""
class IconExtractor:
"""Extracts icons from NE and PE files."""
@@ -107,10 +72,7 @@ class IconExtractor:
self._groups = [] # List of group icon data
def get_icon(self) -> io.BytesIO | None:
"""Extract the best quality icon and return it as image data.
Raises IconExtractorError when the source cannot be read or parsed.
"""
"""Extract the best quality icon and return it as image data."""
try:
with open(self.file_path, "rb") as f:
if not self._parse_file(f):
@@ -124,10 +86,9 @@ class IconExtractor:
if data is None:
return None
return self._icon_data_to_image(data)
except Exception as error:
raise IconExtractorError(
f"Failed to extract icon from {self.file_path}: {error}"
) from error
except Exception as e:
logger.warning("Failed to extract icon from %s: %s", self.file_path, e)
return None
def _parse_file(self, f) -> bool:
"""Identify file type and call appropriate parser."""
+15 -28
View File
@@ -8,8 +8,8 @@ from PySide6.QtCore import (
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
from PIL import Image, ImageQt, ImageSequence
from portprotonqt.config import CACHE_DIR, ui_config
from portprotonqt.theme_manager import ThemeManager, load_theme
from portprotonqt.config import ui_config
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.downloader import Downloader
from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.logger import get_logger
@@ -23,7 +23,6 @@ downloader = Downloader()
logger = get_logger(__name__)
COVER_IMAGE_EXTENSIONS = (".png", ".apng", ".jpg", ".jpeg", ".gif", ".webp", ".jxl", ".svg")
DEFAULT_ANIMATION_DELAY_MS = 100
REMOTE_COVER_DOWNLOAD_TIMEOUT = 15
# Global queue and thread pool for image loading
image_load_queue = Queue()
@@ -367,7 +366,8 @@ def _get_ppdb_autoinstall_image_name(cover: str) -> str:
def clear_ppdb_autoinstall_image_cache(cover_urls: list[str]) -> None:
image_folder = str(CACHE_DIR / "images")
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
for cover in cover_urls:
filename = _get_ppdb_autoinstall_image_name(cover)
if not filename:
@@ -396,16 +396,15 @@ def load_pixmap_async(
def process_image():
theme_manager = ThemeManager()
current_theme_name = ui_config.get_theme()
theme = theme_manager.current_theme_module or load_theme(current_theme_name)
def finish_with(pixmap: QPixmap):
# Check if pixmap is valid before attempting to scale it
if pixmap.isNull():
# Create a default placeholder pixmap instead of trying to scale a null pixmap
placeholder_pixmap = QPixmap(width, height)
placeholder_pixmap.fill(QColor(theme.color_placeholder_bg))
placeholder_pixmap.fill(QColor("#333333"))
painter = QPainter(placeholder_pixmap)
painter.setPen(QPen(QColor(theme.color_white)))
painter.setPen(QPen(QColor("white")))
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
callback(placeholder_pixmap)
@@ -433,9 +432,9 @@ def load_pixmap_async(
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
return pixmap
pixmap = QPixmap(width, height)
pixmap.fill(QColor(theme.color_placeholder_bg))
pixmap.fill(QColor("#333333"))
painter = QPainter(pixmap)
painter.setPen(QPen(QColor(theme.color_white)))
painter.setPen(QPen(QColor("white")))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
return pixmap
@@ -456,7 +455,8 @@ def load_pixmap_async(
pixmap.load(icon_path)
return pixmap
image_folder = str(CACHE_DIR / "images")
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
os.makedirs(image_folder, exist_ok=True)
if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):
@@ -488,11 +488,7 @@ def load_pixmap_async(
finish_with(pixmap)
finish_with(load_fallback_pixmap())
downloader.download_async(
cover, local_path,
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
callback=on_downloaded,
)
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
return
except Exception as e:
logger.error(f"URL processing error {cover}: {e}")
@@ -527,11 +523,7 @@ def load_pixmap_async(
logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
finish_with(load_fallback_pixmap())
downloader.download_async(
cover, local_path,
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
callback=on_downloaded,
)
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
return
except Exception as e:
@@ -562,11 +554,7 @@ def load_pixmap_async(
finish_with(pixmap)
finish_with(load_fallback_pixmap())
downloader.download_async(
cover, local_path,
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
callback=on_downloaded,
)
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
return
except Exception as e:
logger.error("Error processing remote image URL %s: %s", cover, str(e))
@@ -617,7 +605,6 @@ def round_corners(pixmap, radius):
painter.end()
return rounded
class FullscreenDialog(QDialog):
"""
Dialog for viewing images without standard controls.
@@ -703,9 +690,9 @@ class FullscreenDialog(QDialog):
if pixmap.isNull():
# Create a default placeholder pixmap instead of trying to scale a null pixmap
placeholder_pixmap = QPixmap(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
placeholder_pixmap.fill(QColor(self.theme.color_placeholder_bg))
placeholder_pixmap.fill(QColor("#333333"))
painter = QPainter(placeholder_pixmap)
painter.setPen(QPen(QColor(self.theme.color_white)))
painter.setPen(QPen(QColor("white")))
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
self.imageLabel.setPixmap(placeholder_pixmap)
File diff suppressed because it is too large. Load diff
Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff. Show more