Compare commits

..
Author SHA1 Message Date
Boria138 c02366d76a bump to v1.3.2
Build AppImage, Arch, Fedora and Debian Packages / Build AppImage (push) Successful in 4m27s
Build AppImage, Arch, Fedora and Debian Packages / Build Arch Package (push) Successful in 1m39s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (42) (push) Successful in 4m32s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (43) (push) Successful in 1m2s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (44) (push) Successful in 1m23s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (rawhide) (push) Successful in 2m49s
Build AppImage, Arch, Fedora and Debian Packages / Build Debian Package (push) Successful in 2m26s
Build AppImage, Arch, Fedora and Debian Packages / Create and Publish Release (push) Successful in 30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-10 22:03:44 +05:00
Boria138 742e4e646c fix: detect Steam user without MostRecent flag
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-10 22:01:55 +05:00
Boria138 b134c73d2a bump to v1.3.1
Build AppImage, Arch, Fedora and Debian Packages / Build AppImage (push) Successful in 4m37s
Build AppImage, Arch, Fedora and Debian Packages / Build Arch Package (push) Successful in 1m15s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (rawhide) (push) Successful in 1m15s
Build AppImage, Arch, Fedora and Debian Packages / Build Debian Package (push) Successful in 2m32s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (42) (push) Successful in 3m55s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (43) (push) Successful in 1m2s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (44) (push) Successful in 1m9s
Build AppImage, Arch, Fedora and Debian Packages / Create and Publish Release (push) Successful in 15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-07-18 17:22:25 +05:00
Boria138 0d969f40de fix CVE-2026-59678
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-07-18 17:22:25 +05:00
263 changed files with 7115 additions and 27530 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
+6 -31
View File
@@ -2,26 +2,16 @@ name: Build AppImage, Arch, Fedora and Debian Packages
on:
workflow_dispatch:
inputs:
mode:
description: "Run mode"
required: true
default: "hotfix"
type: choice
options:
- hotfix
- release
push:
tags:
- 'v[0-9]*.[0-9]*'
env:
# Common version, will be used for tagging the release
VERSION: 1.4.1
VERSION: 1.3.2
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RUN_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'release' }}
jobs:
build-appimage:
@@ -43,13 +33,7 @@ jobs:
run: |
cd build-aux/AppImage
chmod +x get-dependencies.sh portprotonqt-appimage.sh
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
./get-dependencies.sh --branch "${{ github.ref_name }}"
elif [ "${{ github.event_name }}" = "push" ]; then
./get-dependencies.sh --tag "${{ github.ref_name }}"
else
./get-dependencies.sh
fi
./get-dependencies.sh
- name: Build AppImage
run: |
@@ -101,9 +85,6 @@ jobs:
cd /__w/portproton-repo
cp -r "$GITHUB_WORKSPACE" PortProtonQt
cd /__w/portproton-repo/PortProtonQt/build-aux
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
sed -i "s|^source=.*|source=(\"git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git#branch=${{ github.ref_name }}\")|" PKGBUILD
fi
chown user -R ..
su user -c "yes '' | makepkg --noconfirm -s"
@@ -119,7 +100,7 @@ jobs:
strategy:
matrix:
fedora_version: [43, 44, 45, rawhide]
fedora_version: [42, 43, 44, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
@@ -128,11 +109,9 @@ jobs:
steps:
- name: Install build dependencies
run: |
dnf upgrade -y --exclude=openh264,mozilla-openh264
dnf 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: |
@@ -147,9 +126,6 @@ jobs:
- name: Copy fedora.spec
run: |
cp build-aux/fedora.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
sed -i "s|git checkout v%{pypi_version}|git checkout ${{ github.ref_name }}|" /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
fi
chown -R rpmbuild:users /home/rpmbuild
- name: Build RPM
@@ -174,7 +150,7 @@ jobs:
run: |
apt-get update
apt-get install -y build-essential debhelper dpkg-dev gettext git \
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
libvulkan-dev meson ninja-build nodejs npm \
pkgconf python3
- name: Checkout
@@ -194,7 +170,6 @@ jobs:
release:
name: Create and Publish Release
if: github.event_name != 'workflow_dispatch' || inputs.mode == 'release'
runs-on: ubuntu-latest
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@v4
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
name: Check code
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
image: archlinux:base-devel@sha256:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
steps:
- name: Install system dependencies
run: |
@@ -30,7 +30,7 @@ jobs:
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
-22
View File
@@ -1,22 +0,0 @@
name: Download stats
on:
schedule:
- cron: '0 0 1 * *'
workflow_dispatch: {}
jobs:
stats:
runs-on: ubuntu-latest
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Update download stats
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_ACTOR: ${{ gitea.actor }}
GITEA_SERVER: "git.linux-gaming.ru"
GITEA_REPOSITORY: ${{ gitea.repository }}
run: bash dev-scripts/get_download_stats.sh
+1 -1
View File
@@ -11,7 +11,7 @@ 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+16 -18
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,7 +80,7 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
image: archlinux:base-devel@sha256:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
options: --privileged --device /dev/fuse
steps:
- name: Prepare container
@@ -89,10 +89,10 @@ jobs:
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@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 }}
@@ -126,7 +126,7 @@ jobs:
strategy:
matrix:
fedora_version: [43, 44, 45, rawhide]
fedora_version: [42, 43, 44, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
@@ -135,11 +135,9 @@ jobs:
steps:
- name: Install build dependencies
run: |
dnf upgrade -y --exclude=openh264,mozilla-openh264
dnf 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: |
@@ -149,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 }}
@@ -185,7 +183,7 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
image: archlinux:base-devel@sha256:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
steps:
- name: Prepare container
@@ -213,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 }}
@@ -251,7 +249,7 @@ jobs:
needs: changes
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
container:
image: debian:trixie-20260824@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
image: debian:trixie-20260623@sha256:d07d1b51c39f51188e60be9b64e6bf769fa94e187f092bc32b91305cfa34ba5a
options: --privileged
steps:
@@ -259,15 +257,15 @@ jobs:
run: |
apt-get update
apt-get install -y build-essential debhelper dpkg-dev gettext git \
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
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@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
uses: https://git.linux-gaming.ru/actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
-2
View File
@@ -43,5 +43,3 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
# AI
.codex
CODE_QUALITY_PLAN.md
.repowise
+19 -145
View File
@@ -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
@@ -211,9 +186,6 @@ if key_value >= Qt.Key.Key_Escape.value: ...
- Do not refactor unrelated code
- Do not modify localization files (`portprotonqt/locales/`) unless explicitly requested
- Do not extract single-use logic into a helper function without a clear need
- **Before adding any helper, wrapper, class, or infrastructure, search for an existing method that already performs the operation and call it directly**
- **NEVER add a task-local helper that only wraps an existing method or a single call; keep the call in the modified block unless extraction is required by the function limits**
- If the user identifies overengineering, remove the unnecessary abstraction before continuing; do not layer another fix on top of it
- Do not add comments for obvious code
- **NEVER leave outdated comments after refactoring** (e.g., "without numpy" after numpy removal, "legacy" after rewrite)
- **ALWAYS update or remove comments that reference removed dependencies, patterns, or context**
@@ -413,13 +385,11 @@ tests/
├── test_cli.py # normalize_launch_path, URL/resolution parsing
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
├── 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_main_window.py # Main window data processing and callback regressions
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
├── 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
@@ -427,9 +397,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
@@ -454,7 +422,7 @@ 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
### Key Regression Areas
@@ -469,8 +437,6 @@ pre-commit run pytest
| `classic/styles.py` | Missing styles after theme rewrite (NAV, COMBOBOX, TAB, etc.) | 519edd1 |
| `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 | — |
---
@@ -903,113 +869,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
│ │ ├── gog_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
```
@@ -1067,6 +941,6 @@ PortProtonQt/
---
**Last updated:** 2026-08-11
**Version:** 1.3
**Last updated:** 2026-06-18
**Version:** 1.2
**Status:** Release
-135
View File
@@ -3,141 +3,6 @@
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- Добавлена настройка принудительного применения английского языка
- Добавлена интеграция Epic Games Store с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
- Добавлена поддержка сборок Proton CachyOS WineLand
- Добавлена кнопка возврата на рабочий стол в системной вкладке при указании команды в `PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT`
- Добавлены режимы библиотеки `horizontal` и `horizontal_top` с циклической навигацией и плиткой открытия полной сетки в топ-режиме
- Добавлен режим библиотеки `vertical` с табличным отображением названия, последнего запуска, проведённого времени и источника игры
### Changed
- Поиск в библиотеке теперь находит совпадения в любой части названия и показывает одноимённые игры из разных источников
- Тема теперь применяется без перезапуска приложения
- Добавлена сборка 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/`
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
- Порог определения быстрого вылета Windows-программы увеличен с 5 до 7 секунд
- Открытие карточек и тихий запуск унифицированы
- `minazuki` добавлен в список конфликтующих с GameMode процессов
### Fixed
- Исправлено открытие карточек GOG через URI при старте приложения
- Исправлен сброс состояния кнопки записи лога при переключении детальной страницы в компактный режим
- Исправлено отсутствие вкладок Wi-Fi и Bluetooth при запуске до инициализации системных адаптеров
- Исправлена ошибка, из-за которой не работал поиск по настройкам при отсутствии доступных параметров MangoHud или Gamescope
- Фильтр установленных игр теперь переключается без повторной загрузки библиотеки
- Некорректные изображения в кэше Steam теперь пропускаются при поиске обложек
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
### Contributors
- @rogerio-mattos
- @Rubin13
- @adem4ik
---
## [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-иконок унифицирован для карточек и детальных страниц
- Папки `custom_data` теперь используют имя exe и MD5 полного пути для предотвращения конфликтов одинаковых имён
- Управление геймпадом переведено с `pygame` на SDL3, зависимость `pygame` удалена
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
- Возвращено отображение нативных Linux-игр в библиотеке Steam без отображения саундтреков
- После создания или удаления ярлыков меню обновляется база desktop-файлов
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
### Fixed
- Настройки прокси теперь передаются `curl` при загрузке файлов скриптами PortProton
- Устранена повторная перестройка индексов Steam и PPDB для каждой карточки в экономном режиме
- Скрытие бейджей больше не скрывает ленту источника игры на карточках библиотеки
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
- Исправлено обрезание подсказок управления при переносе на вторую строку
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
- Системные действия в режиме `--ppqtos` переведены на `systemctl` и `loginctl`
- Исправлена перекраска SVG-иконок подсказок управления через `ICON_COLORS`
- Исправлен ввод кириллицы при поиске игр
- Поиск игр ограничен названиями без совпадений по описаниям
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
- Исправлено зависание интерфейса после завершения игры
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
- Распаковка WINE/Proton больше не меняет рабочий каталог приложения и отклоняет пути из архива, выходящие за каталог установки
### Contributors
- @Dervart
- @Rubin13
- @rogerio-mattos
- @Vekt0R_null
---
## [1.3.2] - 2026-08-10
### 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;
-17
View File
@@ -434,20 +434,3 @@ 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)
+1 -4
View File
@@ -5,8 +5,7 @@
<div align="center">
<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-10488-green?style=flat-square" alt="Total Downloads"></a>
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, Epic Games Store, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
<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)
@@ -68,8 +67,6 @@ PortProtonQt uses code and dependencies from the following projects:
- [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.
See the [LICENSE](LICENSE) file for the full text of the licenses.
+1 -5
View File
@@ -5,8 +5,7 @@
<div align="center">
<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-10488-green?style=flat-square" alt="Всего загрузок"></a>
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam, Epic Games Store и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
</div>
### Установка (devel)
@@ -68,9 +67,6 @@ PortProtonQt использует код и зависимости от след
- [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.
Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING]
+58
View File
@@ -0,0 +1,58 @@
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
- [X] Добавить возможность управления с геймпада
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
- [X] Добавить возможность управления с мыши и клавиатуры
- [X] Добавить систему тем [Документация](documentation/theme_guide)
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
- [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
- [X] Разобраться почему теряется часть стилей в Gamescope
- [x] Разработать адаптивный дизайн (за эталон берётся 1000x700 из гайдлайнов Flathub)
- [x] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
- [X] Получать описания и названия игр из базы данных Steam
- [X] Получать обложки для игр из CDN Steam
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
- [X] Избавиться от вызовов yad
- [X] Реализовать собственный системный трей вместо использования трея PortProton
- [X] Добавить экранную клавиатуру в поиск
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
- [X] Добавить индикацию запуска приложения
- [X] Достигнуть паритета функциональности с Ingame
- [X] Достигнуть паритета функциональности с PortProton
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
- [X] Добавить переводы в переопределения
- [X] Добавить в карточку игры сведения о поддержке геймпада
- [X] Добавить в карточки данные с ProtonDB
- [X] Добавить в карточки данные с AreWeAntiCheatYet
- [X] Продублировать бейджи с карточки на страницу с деталями игры
- [X] Добавить парсинг ярлыков из Steam
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
- [X] Добавить поддержку версий Steam для Flatpak и Snap
- [X] Реализовать добавление игры как сторонней в Steam без перезапуска
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
- [X] Отображать описания игр и другие данные на языке системы
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
- [X] Добавить систему избранного для карточек
- [X] Заменить все `print` на `logging`
- [X] Привести все логи к единому языку
- [X] Уменьшить количество подстановок в переводах
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
- [X] Исправить частичное применение тем на лету
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
- [ ] Добавить поддержку GOG (?)
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
- [X] Добавить данные с HowLongToBeat на страницу с деталями игры
- [X] Добавить виброотдачу на геймпаде при запуске игры
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
- [X] Добавить подсказки к управлению с геймпада
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
+20 -22
View File
@@ -5,8 +5,6 @@ set -eu
# Initialize variables
LOCAL_MODE=false
BRANCH="main"
USE_BRANCH=false
TAG=""
REPO_URL="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git"
# Parse arguments
@@ -18,22 +16,12 @@ while [ $# -gt 0 ]; do
--branch)
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
BRANCH="$2"
USE_BRANCH=true
shift
else
echo "Error: --branch requires an argument"
exit 1
fi
;;
--tag)
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
TAG="$2"
shift
else
echo "Error: --tag requires an argument"
exit 1
fi
;;
--repo)
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
REPO_URL="$2"
@@ -57,12 +45,9 @@ EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImage
if [ "$LOCAL_MODE" = true ]; then
echo "Using local PKGBUILD-git from repository..."
PPQT_PKGBUILD=""
elif [ -n "$TAG" ]; then
echo "Using stable version of PortProtonQt from $TAG tag..."
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/tag/$TAG/build-aux/PKGBUILD"
else
echo "Using stable version of PortProtonQt from $BRANCH branch..."
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/$BRANCH/build-aux/PKGBUILD"
echo "Using stable version of PortProtonQt from main branch..."
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/build-aux/PKGBUILD"
fi
echo "Tweak makepkg..."
@@ -83,6 +68,23 @@ echo "---------------------------------------------------------------"
pacman-key --init
pacman -S --noconfirm archlinux-keyring
# Chaotic-AUR keys
pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com
pacman-key --lsign-key 3056513887B78AEB
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst'
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst'
# Enable the multilib repository
cat << EOM >> /etc/pacman.conf
[multilib]
Include = /etc/pacman.d/mirrorlist
[chaotic-aur]
Include = /etc/pacman.d/chaotic-mirrorlist
SigLevel = Never
EOM
# Disable locale noextract
sed -i -E 's@[[:space:]]*usr/share/locale/\*@@g; s@[[:space:]]+@ @g; s@[[:space:]]+$@@' /etc/pacman.conf
@@ -95,11 +97,7 @@ if [ "$LOCAL_MODE" = true ]; then
else
wget --retry-connrefused --tries=30 "$PPQT_PKGBUILD" -O ./PKGBUILD
fi
if [ "$USE_BRANCH" = true ]; then
sed -i "s|^source=.*|source=(\"git+${REPO_URL}#branch=$BRANCH\")|" PKGBUILD
elif [ -n "$TAG" ]; then
sed -i "s|^source=.*|source=(\"git+${REPO_URL}#tag=$TAG\")|" PKGBUILD
fi
sed -i "s|source=(\"git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git\")|source=(\"git+${REPO_URL}#branch=$BRANCH\")|" PKGBUILD
makepkg -si --noconfirm
echo "Installing debloated packages..."
+6 -64
View File
@@ -14,7 +14,6 @@ export UPINFO="gitea-releases-zsync|git.linux-gaming.ru|Linux-Gaming|PortProtonQ
export DEPLOY_OPENGL=1
export DEPLOY_SDL=1
export DEPLOY_PYTHON=1
export MAIN_BIN=portprotonqt
export OPTIMIZE_LAUNCH=1
# Adjust comp settings to bypass oom-killer
@@ -29,78 +28,21 @@ 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 manual because im to lazzy for wait ./quick-sharun strace 208 .ppdb
mkdir -p ./AppDir/share
cp -r /usr/share/portproton ./AppDir/share
# QSoundEffect only needs PCM WAV and PulseAudio. Remove the optional native
# PipeWire path and FFmpeg media backend before quick-sharun scans Qt plugins.
pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
if [ -d /usr/local/share/portproton ]; then
cp -r /usr/local/share/portproton ./AppDir/share
elif [ -d /usr/share/portproton ]; then
cp -r /usr/share/portproton ./AppDir/share
fi
# 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 \
/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
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
# build/development content and PySide bindings not imported by PortProtonQt.
PYTHON_DIR=$(find ./AppDir/lib -maxdepth 1 -type d -name 'python3.*' -print -quit)
PYTHON_SITE=$PYTHON_DIR/site-packages
PYSIDE_DIR=$PYTHON_SITE/PySide6
rm -rf \
"$PYTHON_DIR"/ensurepip \
"$PYTHON_DIR"/idlelib \
"$PYTHON_DIR"/pydoc_data \
"$PYTHON_DIR"/tkinter \
"$PYTHON_DIR"/turtledemo \
"$PYTHON_DIR"/venv \
"$PYTHON_SITE"/Cython \
"$PYTHON_SITE"/cython.py \
"$PYTHON_SITE"/mesonbuild \
"$PYTHON_SITE"/pygments \
"$PYTHON_SITE"/pyximport \
"$PYTHON_SITE"/setuptools \
"$PYTHON_SITE"/vapoursynth \
"$PYTHON_SITE"/wheel \
"$PYTHON_SITE"/cython-*.dist-info \
"$PYTHON_SITE"/meson-*.dist-info \
"$PYTHON_SITE"/pygments-*.dist-info \
"$PYTHON_SITE"/setuptools-*.dist-info \
"$PYTHON_SITE"/vapoursynth-*.dist-info \
"$PYTHON_SITE"/wheel-*.dist-info
find "$PYTHON_DIR" -depth -type d \( -name test -o -name tests \) \
-exec rm -rf {} +
find "$PYTHON_SITE" -type f -name '*.pyi' -delete
for module in "$PYSIDE_DIR"/Qt*.so; do
case "${module##*/}" in
QtCore.*|QtGui.*|QtMultimedia.*|QtNetwork.*|QtSvg.*|QtWidgets.*) continue ;;
esac
rm -f "$module"
done
rm -rf "$PYSIDE_DIR"/QtAsyncio
/usr/lib/qt6/plugins/imageformats/libqwebp.so
# Turn AppDir into AppImage
./quick-sharun --make-appimage
+4 -4
View File
@@ -1,12 +1,12 @@
pkgname=('portprotonqt')
pkgver=1.4.1
pkgver=1.3.2
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
arch=('x86_64')
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')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' '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' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
conflicts=("portprotonqt-steam-compat")
provides=('portprotonqt-steam-compat')
+3 -3
View File
@@ -2,11 +2,11 @@ pkgname=('portprotonqt-git')
pkgver=.
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
arch=('x86_64')
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')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' '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' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
conflicts=("portprotonqt-steam-compat")
provides=('portprotonqt-steam-compat')
+4 -6
View File
@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 1.4.1
%global pypi_version 1.3.2
%global oname PortProtonQt
%global build_timestamp %(date +"%Y%m%d")
%global _python_no_extras_requires 1
@@ -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}
@@ -34,6 +33,7 @@ Requires: python3-websocket-client
Requires: python3-orjson
Requires: python3-psutil
Requires: python3-pyside6
Requires: python3-pygame
Requires: python3-requests
Requires: python3-tqdm
Requires: python3-vdf
@@ -43,9 +43,9 @@ 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
Requires: cabextract
Requires: gzip
Requires: unzip
@@ -65,7 +65,6 @@ Requires: procps-ng
Requires: psmisc
Requires: 7zip
Requires: python3-dbus-fast
Requires: desktop-file-utils
# System Tab
Recommends: NetworkManager
@@ -115,11 +114,10 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%{_sysusersdir}/portprotonqt.conf
%{_datadir}/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.log.desktop
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.silent.desktop
%{_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
+4 -6
View File
@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 1.4.1
%global pypi_version 1.3.2
%global oname PortProtonQt
%global _python_no_extras_requires 1
@@ -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}
@@ -31,6 +30,7 @@ Requires: python3-websocket-client
Requires: python3-orjson
Requires: python3-psutil
Requires: python3-pyside6
Requires: python3-pygame
Requires: python3-requests
Requires: python3-tqdm
Requires: python3-vdf
@@ -40,9 +40,9 @@ 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
Requires: cabextract
Requires: gzip
Requires: unzip
@@ -62,7 +62,6 @@ Requires: procps-ng
Requires: psmisc
Requires: 7zip
Requires: python3-dbus-fast
Requires: desktop-file-utils
# System Tab
Recommends: NetworkManager
@@ -113,11 +112,10 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%{_sysusersdir}/portprotonqt.conf
%{_datadir}/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.log.desktop
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.silent.desktop
%{_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.
@@ -11,4 +11,4 @@ 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-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;
@@ -1,20 +0,0 @@
[Desktop Entry]
Name=PortProtonQt (log)
Name[ru]=PortProtonQt (режим лога)
Name[es]=PortProtonQt (modo de registro)
Name[pt]=PortProtonQt (modo de registro)
Name[de]=PortProtonQt (Protokollmodus)
Exec=portprotonqt --log %u
Type=Application
Comment=Launch Windows applications with logging through PortProtonQt
Comment[ru]=Запуск Windows-приложений с записью лога через PortProtonQt
Comment[es]=Ejecuta aplicaciones de Windows con registro mediante PortProtonQt
Comment[pt]=Executa aplicativos do Windows com registro pelo PortProtonQt
Comment[de]=Startet Windows-Anwendungen mit Protokollierung über PortProtonQt
Terminal=false
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;
StartupNotify=true
@@ -1,20 +0,0 @@
[Desktop Entry]
Name=PortProtonQt (silent)
Name[ru]=PortProtonQt (тихий режим)
Name[es]=PortProtonQt (modo silencioso)
Name[pt]=PortProtonQt (modo silencioso)
Name[de]=PortProtonQt (stiller Modus)
Exec=portprotonqt --silent %u
Type=Application
Comment=Launch Windows applications silently through PortProtonQt
Comment[ru]=Тихий запуск Windows-приложений через PortProtonQt
Comment[es]=Ejecuta aplicaciones de Windows silenciosamente mediante PortProtonQt
Comment[pt]=Executa aplicativos do Windows silenciosamente pelo PortProtonQt
Comment[de]=Startet Windows-Anwendungen lautlos über PortProtonQt
Terminal=false
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;
StartupNotify=true
@@ -5,15 +5,15 @@
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<summary>Modern GUI for managing and launching games from PortProton, Steam, and GOG</summary>
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton, Steam и GOG</summary>
<summary>Modern GUI for managing and launching games from PortProton and Steam</summary>
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton и Steam</summary>
<description>
<p>
A modern and intuitive interface for managing and launching games from PortProton, Steam, and GOG. Combines libraries in one place and simplifies running Windows games on Linux.
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">
Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
</p>
</description>
@@ -24,14 +24,8 @@
</developer>
<releases>
<release version="1.4.1" date="2026-08-26">
<description></description>
</release>
<release version="1.4.0" date="2026-08-23">
<description/>
</release>
<release version="1.3.2" date="2026-08-10">
<description/>
<description></description>
</release>
<release version="1.3.1" date="2026-07-17">
<description/>
@@ -10,10 +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-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

@@ -135,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
@@ -461,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" ]]
@@ -504,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
@@ -513,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'
@@ -533,21 +511,6 @@ try_download () {
return 0
}
prepare_epic_wrapper () {
local expected_hash="1feb21e21e19cbb34e881791c8f2557769f59231e46850915d49a6d0c1ce4583"
export PW_EPIC_WRAPPER_EXE="${PORT_WINE_TMP_PATH}/plugins${PW_PLUGINS_VER}/epic/EpicGamesLauncher.exe"
if [[ ! -f "$PW_EPIC_WRAPPER_EXE" ]] ; then
create_new_dir "$(dirname "$PW_EPIC_WRAPPER_EXE")"
try_download \
"https://github.com/BananaWorks07/heroic-epic-integration/releases/download/v${PW_EPIC_WRAPPER_VER}/EpicGamesLauncher.exe" \
"$PW_EPIC_WRAPPER_EXE" no_mirror || return 1
fi
if [[ "$(sha256sum "$PW_EPIC_WRAPPER_EXE" | awk '{print $1}')" != "$expected_hash" ]] ; then
try_remove_file "$PW_EPIC_WRAPPER_EXE"
fatal "Epic launcher wrapper checksum verification failed"
fi
}
var_winedlloverride_update () {
export WINEDLLOVERRIDES="${1}${WINEDLLOVERRIDES:+;$WINEDLLOVERRIDES}"
}
@@ -580,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
@@ -1996,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
@@ -2306,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"
@@ -2319,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
}
@@ -3009,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 ; then
export GAMEMODERUN=0
export PW_GAMEMODERUN_SLR=""
local current_power_profile
@@ -4084,7 +4089,6 @@ steamplay_launch () {
fi
print_info "$proton_runner in use."
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
pw_launch_wrapper \
${PW_RUN_GAMESCOPE} \
env \
@@ -4110,7 +4114,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}" &
@@ -4170,12 +4173,7 @@ EOF
else
case "${PW_EXE_FILE,,}" in
*.exe)
if [[ -n "${PW_EPIC_WRAPPER_EXE:-}" ]] ; then
pw_run "${PW_VD_TMP[@]}" "${PW_EPIC_WRAPPER_EXE}" \
"${PW_EXE_FILE}" "$@"
else
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
fi
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
;;
*.bat|*.cmd)
PW_USE_TERMINAL=1
@@ -4215,13 +4213,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.json"
METADATA=$(curl -A 'PortProton' -s "$METADATA_URL")
if [[ -z "$METADATA" ]] ; then
fatal "failed to fetch metadata"
fi
View File
File mode changed.
View File
File mode changed.
+10 -16
View File
@@ -19,6 +19,7 @@ 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
@@ -54,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
@@ -73,9 +74,6 @@ fi
export PW_START_PID="$$"
# shellcheck source=/dev/null
source "${PORT_SCRIPTS_PATH}/var"
read -r -a pw_full_command_line <<< "$0 $*"
export pw_full_command_line
export orig_IFS="$IFS"
@@ -122,9 +120,6 @@ elif [[ "$1" =~ ^--(debug|launch|edit-db)$ && "${2,,}" =~ \.(exe|bat|cmd|msi|reg
elif [[ -f "$OLDPWD/$2" ]] ; then
PW_EXE_FILE="$(realpath -s "$OLDPWD/$2")"
fi
elif [[ "$1" == "--epic-wrapper" && "${2,,}" =~ \.exe$ ]] ; then
prepare_epic_wrapper || fatal "Failed to prepare Epic launcher wrapper"
PW_EXE_FILE="$(realpath -s "$2")"
fi
export PW_EXE_FILE
@@ -153,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:]]/_}"
@@ -161,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
@@ -169,9 +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"
@@ -474,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
;;
@@ -579,10 +577,6 @@ Usage examples:
portwine_launch
stop_portproton
;;
--epic-wrapper)
portwine_launch "${@:3}"
stop_portproton
;;
--stop)
start_path="$(realpath "$0")"
pgrep -f -- "${start_path}.*--autoinstall" | while read -r autoinstall_pid ; do
+1 -2
View File
@@ -16,7 +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_EPIC_WRAPPER_VER="0.4"
export PW_GALLIUM_NINE_VER="0.10"
export DOTPFX_VER="48v9"
export DEFPFX_VER="v1"
@@ -40,7 +39,7 @@ 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"
-122
View File
@@ -1,122 +0,0 @@
#include <SDL3/SDL.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
typedef struct {
SDL_Gamepad *gamepad;
SDL_JoystickID instance_id;
int sdl_type;
} PortProtonGamepad;
static PortProtonGamepad *open_gamepad(SDL_JoystickID instance_id)
{
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
if (gamepad == NULL) {
return NULL;
}
PortProtonGamepad *result = malloc(sizeof(*result));
if (result == NULL) {
SDL_CloseGamepad(gamepad);
SDL_SetError("Failed to allocate gamepad handle");
return NULL;
}
result->gamepad = gamepad;
result->instance_id = instance_id;
result->sdl_type = SDL_GetGamepadTypeForID(instance_id);
return result;
}
PortProtonGamepad *portproton_gamepad_find(void)
{
SDL_ClearError();
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
return NULL;
}
SDL_PumpEvents();
SDL_UpdateGamepads();
int count = 0;
SDL_JoystickID *gamepads = SDL_GetGamepads(&count);
if (gamepads == NULL) {
return NULL;
}
if (count == 0) {
SDL_free(gamepads);
SDL_ClearError();
return NULL;
}
PortProtonGamepad *result = NULL;
for (int index = 0; index < count && result == NULL; index++) {
result = open_gamepad(gamepads[index]);
}
SDL_free(gamepads);
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;
}
if (gamepad->gamepad != NULL) {
SDL_CloseGamepad(gamepad->gamepad);
}
free(gamepad);
}
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
{
return gamepad != NULL && gamepad->gamepad != NULL &&
SDL_GamepadConnected(gamepad->gamepad);
}
void portproton_gamepad_update(void)
{
SDL_UpdateGamepads();
}
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return 0;
}
return SDL_GetGamepadButton(gamepad->gamepad, button);
}
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return 0;
}
return SDL_GetGamepadAxis(gamepad->gamepad, axis);
}
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return "";
}
const char *name = SDL_GetGamepadName(gamepad->gamepad);
return name != NULL ? name : "";
}
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->instance_id : 0;
}
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->sdl_type : 0;
}
void portproton_gamepad_shutdown(void)
{
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
}
File renamed without changes.
-12
View File
@@ -1,15 +1,3 @@
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.
+1 -3
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,
@@ -21,7 +20,6 @@ Depends:
7zip,
cabextract,
curl,
desktop-file-utils,
file,
gawk,
libimage-exiftool-perl,
@@ -39,9 +37,9 @@ Depends:
python3-pefile,
python3-pil,
python3-psutil,
python3-pygame,
python3-pyside6.qtcore,
python3-pyside6.qtgui,
python3-pyside6.qtmultimedia,
python3-pyside6.qtnetwork,
python3-pyside6.qtsvg,
python3-pyside6.qtwidgets,
-2
View File
@@ -4,8 +4,6 @@ usr/lib/python3/dist-packages/portprotonqt/
usr/lib/sysusers.d/portprotonqt.conf
usr/lib/udev/rules.d/60-portprotonqt.rules
usr/share/applications/ru.linux_gaming.PortProtonQt.desktop
usr/share/applications/ru.linux_gaming.PortProtonQt.log.desktop
usr/share/applications/ru.linux_gaming.PortProtonQt.silent.desktop
usr/share/bash-completion/completions/portprotonqt
usr/share/fish/vendor_completions.d/portprotonqt.fish
usr/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
remote="${1:-origin}"
main_branch="${2:-main}"
weblate_branch="${3:-weblate}"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Working tree is not clean."
exit 1
fi
git fetch "$remote" "$main_branch" "$weblate_branch"
git switch "$main_branch"
git merge --ff-only "$remote/$main_branch"
if git merge-base --is-ancestor "$remote/$weblate_branch" HEAD; then
echo "No new Weblate changes to merge."
elif git merge-base --is-ancestor HEAD "$remote/$weblate_branch"; then
git merge --ff-only "$remote/$weblate_branch"
else
git switch -C "$weblate_branch" "$remote/$weblate_branch"
git rebase "$main_branch"
git switch "$main_branch"
git merge --ff-only "$weblate_branch"
fi
python dev-scripts/l10n.py
git push "$remote" "$main_branch"
git push --force "$remote" "$main_branch:$weblate_branch"
echo "Weblate translations accepted and $weblate_branch synced to $main_branch."
+2 -11
View File
@@ -141,23 +141,14 @@ def bump_workflow(path: Path, old: str, new: str) -> bool:
def bump_changelog(path: Path, old: str, new: str) -> bool:
"""
Create a new [Unreleased] section and release the current one
Update [Unreleased] to [new] - YYYY-MM-DD in CHANGELOG.md
"""
if not path.exists():
return False
text = path.read_text(encoding='utf-8')
pattern = re.compile(r"(?m)^##\s*\[Unreleased\]$")
current_date = date.today().strftime('%Y-%m-%d')
release_header = (
"## [Unreleased]\n\n"
"### Added\n\n"
"### Changed\n\n"
"### Fixed\n\n"
"### Contributors\n\n"
"---\n\n"
f"## [{new}] - {current_date}"
)
new_text, count = pattern.subn(release_header, text)
new_text, count = pattern.subn(f"## [{new}] - {current_date}", text)
if count:
path.write_text(new_text, encoding='utf-8')
return bool(count)
+3 -5
View File
@@ -18,8 +18,8 @@ _portprotonqt_completions() {
prev="${COMP_WORDS[COMP_CWORD-1]}"
# All available options
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --restore-prefix --create-backup --help -h"
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --restore-prefix --create-backup --help"
# Values for options with arguments
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
@@ -77,7 +77,6 @@ _portprotonqt() {
'--reset-settings[Reset PortProtonQt settings and exit]'
'--ppqtos[Show the system tab]'
'--silent[Launch .exe file without GUI]'
'--log[Enable PortProton logging]'
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
'(-h --help)'{-h,--help}'[Show help message]'
@@ -94,7 +93,7 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
# Fish completion for portprotonqt
complete -c portprotonqt -f
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --restore-prefix --create-backup --help"
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
@@ -105,7 +104,6 @@ complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
complete -c portprotonqt -l ppqtos -d "Show the system tab"
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
complete -c portprotonqt -l log -d "Enable PortProton logging"
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
complete -c portprotonqt -s h -l help -d "Show help message"
-81
View File
@@ -1,81 +0,0 @@
#!/bin/bash
set -euo pipefail
GITHUB_REPOS=("Boria138/PortProtonQt" "linux-gaming-ru/PortProtonQt")
GITEA_REPO="Linux-Gaming/PortProtonQt"
GITEA_URL="https://git.linux-gaming.ru"
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 -sf "https://api.github.com/repos/${repo}/releases?per_page=100"
done | jq -s 'add')
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
echo "Releases: $GH_RELEASES"
echo "Total downloads: $GH_COUNT"
echo
echo "Per release:"
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 -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"
echo "Total downloads: $GIT_COUNT"
echo
echo "Per release:"
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))
echo "========================"
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)\t\(.name)"'
echo "GitHub:"
echo "$GH_RAW" | jq -r "$jq_filter_top"
echo
echo "Gitea:"
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)"'
echo "GitHub:"
echo "$GH_RAW" | jq -r "$jq_type"
echo
echo "Gitea:"
echo "$GIT_RAW" | jq -r "$jq_type"
if [[ "${CI:-}" != "true" ]]; then
echo
echo "Local run: README update and commit skipped"
exit 0
fi
sed -i "s|-[0-9]*-green?style=flat-square|-$TOTAL-green?style=flat-square|g" README.md README.ru.md
echo
echo "Badge updated: $TOTAL"
git diff --quiet README.md README.ru.md && { echo "No changes to commit"; exit 0; }
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
git config http.sslVerify false
git config --local user.email "gitea-actions@users.noreply.gitea.com"
git config --local user.name "Gitea Actions"
git add README.md README.ru.md
git commit -m "chore: download stats ${timestamp}"
remote_repo="https://${GITEA_ACTOR}:${GITEA_TOKEN}@${GITEA_SERVER}/${GITEA_REPOSITORY}.git"
git push "${remote_repo}" HEAD:main
echo "Pushed to Gitea"
+1 -1
View File
@@ -18,7 +18,7 @@ DEBUG_MODE = os.environ.get('DEBUG_MODE', 'false').lower() == 'true'
# Конфигурация API
STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
PPDB_RATINGS_URL = "https://linux-gaming.ru/api/games/ratings?fields=id,name,overall_rating&has_reports=true"
PPDB_RATINGS_URL = "https://ppdb.linux-gaming.ru/api/games/ratings?fields=id,name,overall_rating&has_reports=true"
CATEGORY_STEAM = "games"
# Отключаем предупреждения об SSL в дебаг-режиме
+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.
+59 -293
View File
@@ -13,8 +13,8 @@
- [Library Layout Mode](#library-layout-mode)
- [Detail Page Layout Mode](#detail-page-layout-mode)
- [Detail Page Background Mode](#detail-page-background-mode)
- [Background Configuration](#background-configuration)
- [Library Background](#library-background)
- [Custom Gradient Stops](#custom-gradient-stops)
- [Wave Configuration](#wave-configuration)
- [Preloader](#preloader)
- [Source Corner (Ribbon)](#source-corner-ribbon)
- [Terminal Color Schemes](#terminal-color-schemes)
@@ -26,7 +26,6 @@
- [Recoloring SVG Icons](#recoloring-svg-icons)
- [AutoSizeButton State Recoloring](#autosizebutton-state-recoloring)
- [Non-Inherited Icon Colors](#non-inherited-icon-colors)
- [Sound Effects](#sound-effects-optional)
---
@@ -79,7 +78,6 @@ my_custom_theme/
├── metainfo.ini
├── fonts/
├── images/
├── sounds/
└── styles/
├── __init__.py # This empty file makes the directory a Python package
├── constants.py
@@ -144,7 +142,7 @@ THEME_INHERITS = "classic"
If `THEME_INHERITS` is not defined, the theme inherits styles from `standart`.
Fonts, icons, images, and sounds also follow the inheritance chain — the first match found walking from child to `standart` wins. If your theme has a `fonts/` directory, it takes priority over the parent's. Missing sound events fall back individually to the parent theme. Screenshots are the exception — they are loaded only from the current theme, not inherited.
Fonts, icons, and images also follow the inheritance chain — the first match found walking from child to `standart` wins. If your theme has a `fonts/` directory, it takes priority over the parent's. If it doesn't, the parent's fonts are loaded. Screenshots are the exception — they are loaded only from the current theme, not inherited.
### How Inheritance Works
@@ -235,99 +233,12 @@ 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`:
```python
GAME_CARD_LIST = {
"extra_margin": 8,
"spacing": 12,
"cover_load_size": 64,
"cover_size": 56,
"min_cover_size": 48,
"cover_left_margin": 10,
"row_height": 72,
"min_row_height": 68,
"cover_radius": 8,
"border_radius": 18,
}
GAME_CARD_GRID = {
"card_animation_type": "gradient",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 1.5,
"card_height_ratio": 1.8,
"cover_radius": 15,
"border_radius": 18,
}
GAME_CARD_HORIZONTAL = {
"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,
}
```
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.
```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.
This is a theme-level option and does not depend on app settings.
@@ -354,205 +265,84 @@ Economy mode also forces the compact detail page layout.
You can control the detail page background style from the theme via `styles.py`:
```python
# One of the background modes listed below
# "gradient" (default), "static_waves", or "waves"
DETAIL_PAGE_BG_MODE = "gradient"
```
- `gradient`: diagonal linear gradient extracted from the cover image palette (default).
- `static_waves`: gradient background with static (non-animated) wave shapes overlaid.
- `waves`: gradient background with animated wave shapes that drift over time.
- `aurora`: animated vertical aurora ribbons.
- `metaballs`: animated glowing metaballs.
- `leaf`: animated falling leaves with a configurable foliage type.
- `veins`: animated connected vein pattern.
- `diagnostics`: animated grid, signal trace, scanner, and compatibility status.
Prefix an animated background mode with `static_` to render one frame without
starting its animation timer. For example:
### Custom Gradient Stops
By default, the detail page gradient is generated from the cover image palette with evenly distributed positions. Define `DETAIL_PAGE_GRADIENT` in `styles.py` to override positions, colors, or both:
```python
DETAIL_PAGE_BG_MODE = "static_diagnostics"
# Custom positions — palette colors redistributed to these positions
DETAIL_PAGE_GRADIENT = [0, 0.3, 0.7, 1]
# Custom colors — position and color for each stop
DETAIL_PAGE_GRADIENT = [
{"position": 0, "color": "#101010"},
{"position": 0.5, "color": "#202020"},
{"position": 1, "color": "#303030"},
]
# Raw QSS stop string
DETAIL_PAGE_GRADIENT = "stop:0 #101010, stop:0.5 #202020, stop:1 #303030"
```
The supported static modes are `static_waves`, `static_aurora`,
`static_metaballs`, `static_leaf`, `static_veins`, and
`static_diagnostics`.
### Gradient Type and Number of Colors Extracted from the Cover
Set the number of colors extracted from the cover:
You can set the gradient type (linear or radial), by defining `DETAIL_PAGE_GRADIENT_TYPE` in `styles.py`:
```python
# Accepts `linear` or `radial`
DETAIL_PAGE_GRADIENT_TYPE = 'linear'
```
Set the gradient direction:
```python
# For linear gradients
DETAIL_PAGE_GRADIENT_X1 = 0
DETAIL_PAGE_GRADIENT_Y1 = 0
DETAIL_PAGE_GRADIENT_X2 = 1
DETAIL_PAGE_GRADIENT_Y2 = 0
# For radial gradients
DETAIL_PAGE_GRADIENT_CX = 0.3
DETAIL_PAGE_GRADIENT_CY = 0.3
DETAIL_PAGE_GRADIENT_RADIUS = 0.7
DETAIL_PAGE_GRADIENT_FX = 0.3
DETAIL_PAGE_GRADIENT_FY = 0.3
```
Set the number of colors to extract from the cover:
```python
# Must be at least 2
DETAIL_PAGE_PALETTE_COLORS = 5
```
### Background Configuration
### Wave Configuration
Background modes use `DETAIL_PAGE_BACKGROUNDS`:
When `DETAIL_PAGE_BG_MODE` is `"static_waves"` or `"waves"`, you can customize the wave appearance via the `DETAIL_PAGE_WAVES` dictionary:
```python
DETAIL_PAGE_BACKGROUNDS = {
# Common settings for aurora, diagnostics, leaf, metaballs, and veins.
"animation_interval_ms": 30, # Milliseconds between frames.
"animation_speed": 0.03, # Phase increment per frame.
"gradient": {
# None uses cover colors.
# [0, 0.3, 0.7, 1] distributes cover colors at custom positions.
# [{"position": 0, "color": "#101010"}, ...] sets custom colors.
# "stop:0 #101010, stop:1 #303030" accepts raw QSS stops.
"stops": None,
"type": "linear", # "linear" or "radial".
"x1": 0, # Linear gradient start X (0.0-1.0).
"y1": 0, # Linear gradient start Y (0.0-1.0).
"x2": 1, # Linear gradient end X (0.0-1.0).
"y2": 1, # Linear gradient end Y (0.0-1.0).
"cx": 0.5, # Radial gradient center X (0.0-1.0).
"cy": 0.5, # Radial gradient center Y (0.0-1.0).
"radius": 0.5, # Radial gradient radius relative to the page.
"fx": 0.5, # Radial gradient focal point X (0.0-1.0).
"fy": 0.5, # Radial gradient focal point Y (0.0-1.0).
},
"waves": {
"layer_count": 4, # Number of overlapping wave layers.
"wave_amplitude_ratio": 0.06, # Wave height / page height.
"wave_frequency": 2.0, # Oscillations across the page.
"layer_spacing_ratio": 0.04, # Layer gap / page height.
"base_opacity": 0.45, # First layer opacity (0.0-1.0).
"opacity_decay": 0.85, # Opacity multiplier for later layers.
"animation_speed": 0.03, # Wave phase increment per frame.
"animation_interval_ms": 30, # Milliseconds between wave frames.
},
"aurora": {
"line_count": 8, # Number of vertical ribbons.
"line_width_ratio": 0.006, # Ribbon width / page width.
"opacity": 0.35, # Ribbon opacity (0.0-1.0).
"steps": 40, # Curve segments per ribbon.
"frequency": 5.0, # Bends along each ribbon.
"speed": 1.2, # Ribbon motion speed multiplier.
"phase_step": 0.8, # Phase difference between ribbons.
"edge_offset": 0.5, # Offset used to distribute ribbons.
"drift_ratio": 0.06, # Horizontal drift / page width.
},
"diagnostics": {
"color": color_accent, # Color of every diagnostics element.
"font_family": font_family,# Status text font.
"font_size": 13, # Status text size in pixels.
"grid_spacing": 64, # Grid cell size in pixels.
"grid_line_width": 1, # Grid line width in pixels.
"grid_opacity": 0.08, # Grid opacity (0.0-1.0).
"trace_baseline": 0.78, # Trace Y position / page height.
"trace_amplitude": 0.025, # Trace height / page height.
"trace_frequency": 28, # Oscillations across the page.
"trace_speed": 1.4, # Trace motion speed multiplier.
"trace_step_width": 4, # Pixels between trace samples.
"trace_line_width": 2, # Trace line width in pixels.
"trace_opacity": 0.45, # Trace opacity (0.0-1.0).
"text_opacity": 0.7, # Status text opacity (0.0-1.0).
"text_x_ratio": 0.025, # Text left margin / page width.
"first_line_y_ratio": 0.88,# First status Y / page height.
"second_line_y_ratio": 0.91,# Second status Y / page height.
"status_text": "COMPATIBILITY LAYER: OPERATIONAL", # First status.
"containment_text": "WINDOWS CONTAINMENT: ACTIVE", # Second status.
"sweep_offset": 0.25, # Initial scanner X position (0.0-1.0).
"sweep_speed": 0.08, # Scanner motion speed multiplier.
"sweep_width": 2, # Scanner width in pixels.
"sweep_opacity": 0.2, # Scanner opacity (0.0-1.0).
},
"metaballs": {
"count": 18, # Number of glowing balls.
"seed_offset": 1, # Seed offset for another layout.
"min_radius_ratio": 0.08, # Minimum radius / page height.
"radius_range": 0.08, # Random extra radius range.
"min_speed": 0.004, # Minimum vertical travel speed.
"opacity": 0.55, # Ball center opacity (0.0-1.0).
"x_seed": 1.31, # Horizontal placement seed multiplier.
"y_seed": 7.13, # Vertical placement seed multiplier.
"speed_seed": 2.71, # Individual speed seed multiplier.
},
"leaf": {
# "generic", "sakura", "oak", "maple", or "birch".
"type": "generic", # Shape of the falling foliage.
"count": 32, # Number of leaves.
"seed_offset": 1, # Seed offset for another layout.
"min_size_ratio": 0.009, # Minimum size / page height.
"size_range": 0.009, # Random extra size range.
"min_speed": 0.002, # Minimum falling speed.
"opacity": 0.65, # Leaf opacity (0.0-1.0).
"sway_ratio": 0.04, # Horizontal sway / page width.
"rotation_speed": 40, # Degrees per animation phase.
"x_seed": 1.31, # Horizontal placement seed multiplier.
"y_seed": 7.13, # Vertical placement seed multiplier.
"speed_seed": 2.71, # Individual speed seed multiplier.
},
"veins": {
"columns": 9, # Number of point columns.
"rows": 6, # Number of point rows.
"seed_offset": 1, # Seed offset for another pattern.
"cell_offset": 0.5, # Point position inside a grid cell.
"motion_ratio": 0.12, # Horizontal movement / cell width.
"line_width": 2, # Connection width in pixels.
"opacity": 0.3, # Connection opacity (0.0-1.0).
},
DETAIL_PAGE_WAVES = {
"layer_count": 4, # Number of wave layers
"wave_amplitude_ratio": 0.06, # Wave height as ratio of page height
"wave_frequency": 2.0, # Number of wave cycles across the page
"layer_spacing_ratio": 0.04, # Vertical spacing between layers as ratio of page height
"base_opacity": 0.45, # Opacity of the first wave layer (0.0–1.0)
"opacity_decay": 0.85, # Opacity multiplier for each subsequent layer
"animation_speed": 0.03, # Phase increment per tick — "waves" mode only (higher = faster)
"animation_interval_ms": 30, # Timer interval in ms — "waves" mode only (lower = smoother)
}
```
Each named dictionary contains the visual constants for that effect. Copy the
corresponding dictionary from the standard theme before overriding individual
values so that all required keys remain present.
The `diagnostics` effect also exposes `status_text` and `containment_text`. The
standard theme uses `COMPATIBILITY LAYER: OPERATIONAL` and
`WINDOWS CONTAINMENT: ACTIVE`.
Existing themes may continue using `DETAIL_PAGE_GRADIENT`,
`DETAIL_PAGE_GRADIENT_TYPE`, `DETAIL_PAGE_GRADIENT_X1` through
`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`.
Wave colors are derived from the darkened cover image palette. Each layer uses a different palette color with decreasing opacity. The `animation_speed` and `animation_interval_ms` parameters only affect the `"waves"` mode.
---
@@ -952,27 +742,3 @@ When no `ICON_COLORS` entry matches an `AutoSizeButton` state, the button falls
`ICON_COLORS` is not inherited from parent themes. If a child theme needs SVG recoloring, define its own `ICON_COLORS` dictionary in that theme.
---
## 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`:
| Event | Used for |
|---|---|
| `navigate` | Moving focus between UI items |
| `click` | Pressing buttons, clickable labels, and confirming actions |
| `back` | Returning or closing with the Back action |
| `toggle` | Changing a checkbox or opening a combo box |
| `open` | Opening pages, dialogs, and menus |
| `tab_switch` | Switching application tabs |
| `game_launch` | Successful game process launch |
| `gamepad_connect` | Connecting a gamepad |
| `gamepad_off` | Disconnecting a gamepad |
Use uncompressed PCM WAV files, for example `sounds/navigate.wav`. Other audio formats are not supported.
Sounds are inherited per event. A child theme can override only `navigate`; missing events are resolved through its parent chain up to `standart`.
Theme validation checks the extension, file size, and format signature before a sound is loaded. Sound files larger than 20 MiB, symlinks, files outside the theme directory, and files whose content does not match their extension are rejected. Include the license and attribution required by every sound asset distributed with a theme.
---
+59 -294
View File
@@ -13,8 +13,8 @@
- [Режим сетки библиотеки](#режим-сетки-библиотеки)
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
- [Настройка фона](#настройка-фона)
- [Фон библиотеки](#фон-библиотеки)
- [Свои стопы градиента](#свои-стопы-градиента)
- [Настройка волн](#настройка-волн)
- [Прелоадер](#прелоадер)
- [Уголок источника (Лента)](#уголок-источника-лента)
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
@@ -26,7 +26,6 @@
- [Перекраска SVG-иконок](#перекраска-svg-иконок)
- [Перекраска состояний AutoSizeButton](#перекраска-состояний-autosizebutton)
- [Ненаследуемые цвета иконок](#ненаследуемые-цвета-иконок)
- [Звуковые эффекты](#звуковые-эффекты-опционально)
---
@@ -79,7 +78,6 @@ my_custom_theme/
├── metainfo.ini
├── fonts/
├── images/
├── sounds/
└── styles/
├── __init__.py # Этот пустой файл делает директорию Python-пакетом
├── constants.py
@@ -144,7 +142,7 @@ THEME_INHERITS = "classic"
Если `THEME_INHERITS` не указан, тема наследует стили от `standart`.
Шрифты, иконки, изображения и звуки также следуют цепочке наследования — побеждает первое совпадение при обходе от дочерней темы до `standart`. Если в вашей теме есть директория `fonts/`, она имеет приоритет над родительской. Отсутствующие звуковые события по отдельности загружаются из родительской темы. Скриншоты — исключение: загружаются только из текущей темы, не наследуются.
Шрифты, иконки и изображения также следуют цепочке наследования — побеждает первое совпадение при обходе от дочерней темы до `standart`. Если в вашей теме есть директория `fonts/`, она имеет приоритет над родительской. Если нет — загружаются шрифты родителя. Скриншоты — исключение: загружаются только из текущей темы, не наследуются.
### Как работает наследование
@@ -235,100 +233,12 @@ 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`:
```python
GAME_CARD_LIST = {
"extra_margin": 8,
"spacing": 12,
"cover_load_size": 64,
"cover_size": 56,
"min_cover_size": 48,
"cover_left_margin": 10,
"row_height": 72,
"min_row_height": 68,
"cover_radius": 8,
"border_radius": 18,
}
GAME_CARD_GRID = {
"card_animation_type": "gradient",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 1.5,
"card_height_ratio": 1.8,
"cover_radius": 15,
"border_radius": 18,
}
GAME_CARD_HORIZONTAL = {
"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,
}
```
Значения задаются в логических пикселях. `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`.
```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` как в сеточном, так и в списочном режиме.
Это параметр уровня темы и не зависит от настроек приложения.
@@ -355,205 +265,84 @@ DETAIL_PAGE_LAYOUT_MODE = "full"
Вы можете управлять стилем фона детальной страницы из темы через `styles.py`:
```python
# Один из перечисленных ниже режимов фона
# "gradient" (по умолчанию), "static_waves" или "waves"
DETAIL_PAGE_BG_MODE = "gradient"
```
- `gradient`: диагональный линейный градиент, извлечённый из палитры обложки (по умолчанию).
- `static_waves`: градиентный фон со статическими (без анимации) волнами.
- `waves`: градиентный фон с анимированными волнами, движущимися во времени.
- `aurora`: анимированные вертикальные ленты северного сияния.
- `metaballs`: анимированные светящиеся метаболы.
- `leaf`: анимированная падающая листва с настраиваемым типом.
- `veins`: анимированный связанный узор прожилок.
- `diagnostics`: анимированная сетка, осциллограмма, сканер и статус совместимости.
Добавьте префикс `static_` к анимированному режиму фона, чтобы отрисовать один
кадр без запуска таймера анимации. Например:
### Свои стопы градиента
По умолчанию градиент детальной страницы строится из палитры обложки с равномерными позициями. Задайте `DETAIL_PAGE_GRADIENT` в `styles.py`, чтобы переопределить позиции, цвета или и то, и другое:
```python
DETAIL_PAGE_BG_MODE = "static_diagnostics"
# Свои позиции — цвета обложки распределяются по этим позициям
DETAIL_PAGE_GRADIENT = [0, 0.3, 0.7, 1]
# Свои цвета — позиция и цвет для каждого стопа
DETAIL_PAGE_GRADIENT = [
{"position": 0, "color": "#101010"},
{"position": 0.5, "color": "#202020"},
{"position": 1, "color": "#303030"},
]
# Готовая QSS-строка
DETAIL_PAGE_GRADIENT = "stop:0 #101010, stop:0.5 #202020, stop:1 #303030"
```
Доступные статические режимы: `static_waves`, `static_aurora`,
`static_metaballs`, `static_leaf`, `static_veins` и `static_diagnostics`.
### Тип градиента и количество цветов извлекаемых из обложки
Количество цветов, извлекаемых из обложки:
Вы можете задать тип градиента (linear/radial), задав `DETAIL_PAGE_GRADIENT_TYPE` в `styles.py`:
```python
# Принимает значения `linear` или `radial`
DETAIL_PAGE_GRADIENT_TYPE = 'linear'
```
Задать направление градиента:
```python
# Для линейного
DETAIL_PAGE_GRADIENT_X1 = 0
DETAIL_PAGE_GRADIENT_Y1 = 0
DETAIL_PAGE_GRADIENT_X2 = 1
DETAIL_PAGE_GRADIENT_Y2 = 0
# Для радиального
DETAIL_PAGE_GRADIENT_CX = 0.3
DETAIL_PAGE_GRADIENT_CY = 0.3
DETAIL_PAGE_GRADIENT_RADIUS = 0.7
DETAIL_PAGE_GRADIENT_FX = 0.3
DETAIL_PAGE_GRADIENT_FY = 0.3
```
Задать количество цветов извлекаемых из обложки:
```python
# Не менее 2
DETAIL_PAGE_PALETTE_COLORS = 5
```
### Настройка фона
### Настройка волн
Режимы фона используют словарь `DETAIL_PAGE_BACKGROUNDS`:
При `DETAIL_PAGE_BG_MODE` равном `"static_waves"` или `"waves"` можно настроить внешний вид волн через словарь `DETAIL_PAGE_WAVES`:
```python
DETAIL_PAGE_BACKGROUNDS = {
# Общие настройки aurora, diagnostics, leaf, metaballs и veins.
"animation_interval_ms": 30, # Интервал между кадрами в мс.
"animation_speed": 0.03, # Приращение фазы за кадр.
"gradient": {
# None берёт цвета обложки.
# [0, 0.3, 0.7, 1] распределяет цвета обложки по своим позициям.
# [{"position": 0, "color": "#101010"}, ...] задаёт свои цвета.
# "stop:0 #101010, stop:1 #303030" принимает готовые стопы QSS.
"stops": None,
"type": "linear", # "linear" или "radial".
"x1": 0, # Начальная координата X (0.0-1.0).
"y1": 0, # Начальная координата Y (0.0-1.0).
"x2": 1, # Конечная координата X (0.0-1.0).
"y2": 1, # Конечная координата Y (0.0-1.0).
"cx": 0.5, # Координата X центра радиального градиента.
"cy": 0.5, # Координата Y центра радиального градиента.
"radius": 0.5, # Радиус градиента относительно страницы.
"fx": 0.5, # Координата X фокусной точки.
"fy": 0.5, # Координата Y фокусной точки.
},
"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, # Интервал между кадрами волн.
},
"aurora": {
"line_count": 8, # Количество вертикальных лент.
"line_width_ratio": 0.006, # Ширина ленты / ширина страницы.
"opacity": 0.35, # Прозрачность лент.
"steps": 40, # Сегменты кривой каждой ленты.
"frequency": 5.0, # Количество изгибов ленты.
"speed": 1.2, # Множитель скорости движения.
"phase_step": 0.8, # Сдвиг фазы между лентами.
"edge_offset": 0.5, # Смещение при распределении лент.
"drift_ratio": 0.06, # Отклонение / ширина страницы.
},
"diagnostics": {
"color": color_accent, # Цвет всех элементов диагностики.
"font_family": font_family,# Шрифт статусного текста.
"font_size": 13, # Размер текста в пикселях.
"grid_spacing": 64, # Размер ячейки сетки в пикселях.
"grid_line_width": 1, # Толщина линий сетки.
"grid_opacity": 0.08, # Прозрачность сетки.
"trace_baseline": 0.78, # Положение сигнала / высота страницы.
"trace_amplitude": 0.025, # Высота сигнала / высота страницы.
"trace_frequency": 28, # Количество колебаний по ширине.
"trace_speed": 1.4, # Множитель скорости сигнала.
"trace_step_width": 4, # Интервал между отсчётами сигнала.
"trace_line_width": 2, # Толщина линии сигнала.
"trace_opacity": 0.45, # Прозрачность сигнала.
"text_opacity": 0.7, # Прозрачность статусного текста.
"text_x_ratio": 0.025, # Левый отступ / ширина страницы.
"first_line_y_ratio": 0.88,# Положение первой строки / высота.
"second_line_y_ratio": 0.91,# Положение второй строки / высота.
"status_text": "COMPATIBILITY LAYER: OPERATIONAL", # Первая строка.
"containment_text": "WINDOWS CONTAINMENT: ACTIVE", # Вторая строка.
"sweep_offset": 0.25, # Начальная позиция сканера.
"sweep_speed": 0.08, # Множитель скорости сканера.
"sweep_width": 2, # Толщина сканера в пикселях.
"sweep_opacity": 0.2, # Прозрачность сканера.
},
"metaballs": {
"count": 18, # Количество светящихся метаболов.
"seed_offset": 1, # Смещение seed для другого рисунка.
"min_radius_ratio": 0.08, # Минимальный радиус / высота.
"radius_range": 0.08, # Случайный дополнительный радиус.
"min_speed": 0.004, # Минимальная скорость движения.
"opacity": 0.55, # Прозрачность центра метабола.
"x_seed": 1.31, # Seed горизонтального положения.
"y_seed": 7.13, # Seed вертикального положения.
"speed_seed": 2.71, # Seed индивидуальной скорости.
},
"leaf": {
# "generic", "sakura", "oak", "maple" или "birch".
"type": "generic", # Форма падающей листвы.
"count": 32, # Количество листьев.
"seed_offset": 1, # Смещение seed для другого рисунка.
"min_size_ratio": 0.009, # Минимальный размер / высота.
"size_range": 0.009, # Случайный дополнительный размер.
"min_speed": 0.002, # Минимальная скорость падения.
"opacity": 0.65, # Прозрачность листьев.
"sway_ratio": 0.04, # Покачивание / ширина страницы.
"rotation_speed": 40, # Градусы вращения на единицу фазы.
"x_seed": 1.31, # Seed горизонтального положения.
"y_seed": 7.13, # Seed вертикального положения.
"speed_seed": 2.71, # Seed индивидуальной скорости.
},
"veins": {
"columns": 9, # Количество столбцов точек.
"rows": 6, # Количество строк точек.
"seed_offset": 1, # Смещение seed для другого узора.
"cell_offset": 0.5, # Положение точки внутри ячейки.
"motion_ratio": 0.12, # Движение точки / ширина ячейки.
"line_width": 2, # Толщина соединений в пикселях.
"opacity": 0.3, # Прозрачность соединений.
},
DETAIL_PAGE_WAVES = {
"layer_count": 4, # Количество слоёв волн
"wave_amplitude_ratio": 0.06, # Высота волны как отношение к высоте страницы
"wave_frequency": 2.0, # Количество циклов волны на странице
"layer_spacing_ratio": 0.04, # Вертикальный отступ между слоями как отношение к высоте страницы
"base_opacity": 0.45, # Прозрачность первого слоя волны (0.0–1.0)
"opacity_decay": 0.85, # Множитель прозрачности для каждого следующего слоя
"animation_speed": 0.03, # Приращение фазы за тик — только для режима "waves" (больше = быстрее)
"animation_interval_ms": 30, # Интервал таймера в мс — только для режима "waves" (меньше = плавнее)
}
```
Каждый именованный словарь содержит визуальные константы соответствующего
эффекта. Перед изменением отдельных значений скопируйте нужный словарь из
стандартной темы, чтобы сохранить все обязательные ключи.
Эффект `diagnostics` также предоставляет параметры `status_text` и
`containment_text`. В стандартной теме используются строки
`COMPATIBILITY LAYER: OPERATIONAL` и `WINDOWS CONTAINMENT: ACTIVE`.
Существующие темы могут продолжать использовать `DETAIL_PAGE_GRADIENT`,
`DETAIL_PAGE_GRADIENT_TYPE`, переменные от `DETAIL_PAGE_GRADIENT_X1` до
`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` скопируйте нужный
словарь из описания выше.
Цвета волн берутся из затемнённой палитры обложки. Каждый слой использует разный цвет палитры с уменьшающейся прозрачностью. Параметры `animation_speed` и `animation_interval_ms` влияют только на режим `"waves"`.
---
@@ -953,27 +742,3 @@ ICON_COLORS = {
`ICON_COLORS` не наследуется от родительских тем. Если дочерней теме нужна перекраска SVG, задайте собственный словарь `ICON_COLORS` в этой теме.
---
## Звуковые эффекты (опционально)
Поместите звуковые эффекты интерфейса в директорию `sounds/` темы. Имя файла должно совпадать с именем события; не добавляйте числовые суффиксы наподобие `_001`:
| Событие | Назначение |
|---|---|
| `navigate` | Перемещение фокуса между элементами интерфейса |
| `click` | Нажатие кнопок, кликабельных меток и подтверждение действий |
| `back` | Возврат или закрытие действием Back |
| `toggle` | Изменение флажка или открытие комбобокса |
| `open` | Открытие страниц, диалогов и меню |
| `tab_switch` | Переключение вкладок приложения |
| `game_launch` | Успешный запуск процесса игры |
| `gamepad_connect` | Подключение геймпада |
| `gamepad_off` | Отключение геймпада |
Используйте несжатые PCM WAV-файлы, например `sounds/navigate.wav`. Другие аудиоформаты не поддерживаются.
Звуки наследуются отдельно для каждого события. Дочерняя тема может переопределить только `navigate`; отсутствующие события ищутся по цепочке родительских тем вплоть до `standart`.
При проверке темы проверяются расширение, размер и сигнатура формата каждого звука. Файлы размером более 20 МиБ, символические ссылки, файлы за пределами директории темы и файлы, содержимое которых не соответствует расширению, отклоняются. Добавляйте лицензию и требуемую атрибуцию для каждого звукового ресурса, распространяемого вместе с темой.
---
-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 -14
View File
@@ -1,5 +1,5 @@
project('portprotonqt', 'c',
version: '1.4.1',
version: '1.3.2',
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',
@@ -75,12 +84,6 @@ install_data(
install_dir: datadir / 'applications',
)
install_data(
'build-aux/share/applications/ru.linux_gaming.PortProtonQt.log.desktop',
'build-aux/share/applications/ru.linux_gaming.PortProtonQt.silent.desktop',
install_dir: datadir / 'applications',
)
# Install MIME type
install_data(
'build-aux/share/mime/packages/ru.linux_gaming.PortProtonQt.xml',
@@ -111,6 +114,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
View File
@@ -1,12 +1,10 @@
from portprotonqt.animations.detail_page import DetailPageAnimations
from portprotonqt.animations.detail_background import DetailBackgroundAnimations
from portprotonqt.animations.game_card import GameCardAnimations
from portprotonqt.animations.library_controls import ExpandingSearchAnimation, LibraryControlsAnimation
from portprotonqt.animations.virtual_keyboard import VirtualKeyboardAnimations
__all__ = [
"DetailPageAnimations",
"DetailBackgroundAnimations",
"ExpandingSearchAnimation",
"GameCardAnimations",
"LibraryControlsAnimation",
@@ -1,311 +0,0 @@
"""Procedural detail page backgrounds adapted from omikuji."""
import math
from weakref import WeakKeyDictionary
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
from PySide6.QtGui import (
QColor,
QFont,
QPainter,
QPainterPath,
QPen,
QRadialGradient,
)
from PySide6.QtWidgets import QWidget
class DetailBackgroundAnimations:
"""Manage animated and static omikuji detail backgrounds."""
effects = frozenset({"aurora", "diagnostics", "leaf", "metaballs", "veins"})
def __init__(self) -> None:
self._states: WeakKeyDictionary[QWidget, dict] = WeakKeyDictionary()
def setup(self, page: QWidget, palette: list, theme) -> bool:
"""Setup an omikuji background and report whether its mode matched."""
mode = getattr(theme, "DETAIL_PAGE_BG_MODE", "gradient")
effect = mode.removeprefix("static_")
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
self.remove(page)
state = {
"effect": effect,
"palette": palette,
"phase": 0.0,
"config": config,
"original_paint": page.paintEvent,
"timer": None,
}
self._states[page] = state
def paint_event(event) -> None:
state["original_paint"](event)
self._paint(page, state)
page.paintEvent = paint_event
if animated:
self._start_timer(page, state)
return True
def remove(self, page: QWidget) -> None:
"""Remove the omikuji background from a detail page."""
state = self._states.pop(page, None)
if state is None:
return
timer = state["timer"]
if timer is not None:
timer.stop()
timer.deleteLater()
page.paintEvent = state["original_paint"]
def _start_timer(self, page: QWidget, state: dict) -> None:
config = state["config"]
timer = QTimer(page)
timer.setInterval(config["animation_interval_ms"])
def tick() -> None:
if page.isHidden() or page.parent() is None:
timer.stop()
return
state["phase"] += config["animation_speed"]
page.update()
timer.timeout.connect(tick)
timer.start()
state["timer"] = timer
def _paint(self, page: QWidget, state: dict) -> None:
if not state["palette"]:
return
painter = QPainter(page)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(page.rect())
painters = {
"aurora": self._paint_aurora,
"diagnostics": self._paint_diagnostics,
"metaballs": self._paint_metaballs,
"leaf": self._paint_leaf,
"veins": self._paint_veins,
}
painters[state["effect"]](painter, rect, state)
painter.end()
def _paint_diagnostics(
self, painter: QPainter, rect: QRectF, state: dict,
) -> None:
config = state["config"]["diagnostics"]
color = QColor(config["color"])
grid_color = QColor(color)
grid_color.setAlphaF(config["grid_opacity"])
painter.setPen(QPen(grid_color, config["grid_line_width"]))
spacing = config["grid_spacing"]
for x in range(0, int(rect.width()), spacing):
painter.drawLine(QPointF(x, 0), QPointF(x, rect.height()))
for y in range(0, int(rect.height()), spacing):
painter.drawLine(QPointF(0, y), QPointF(rect.width(), y))
trace_color = QColor(color)
trace_color.setAlphaF(config["trace_opacity"])
painter.setPen(QPen(trace_color, config["trace_line_width"]))
trace = QPainterPath()
steps = max(int(rect.width() / config["trace_step_width"]), 1)
for step in range(steps + 1):
progress = step / steps
signal = math.sin(
progress * config["trace_frequency"]
+ state["phase"] * config["trace_speed"]
)
point = QPointF(
rect.width() * progress,
rect.height() * (config["trace_baseline"] + signal * config["trace_amplitude"]),
)
trace.moveTo(point) if step == 0 else trace.lineTo(point)
painter.drawPath(trace)
self._paint_diagnostic_status(painter, rect, state)
def _paint_diagnostic_status(
self, painter: QPainter, rect: QRectF, state: dict,
) -> None:
config = state["config"]["diagnostics"]
color = QColor(config["color"])
color.setAlphaF(config["text_opacity"])
painter.setPen(color)
font = QFont(config["font_family"])
font.setPixelSize(config["font_size"])
painter.setFont(font)
x = rect.width() * config["text_x_ratio"]
first_y = rect.height() * config["first_line_y_ratio"]
second_y = rect.height() * config["second_line_y_ratio"]
painter.drawText(QPointF(x, first_y), config["status_text"])
painter.drawText(QPointF(x, second_y), config["containment_text"])
sweep = (
config["sweep_offset"] + state["phase"] * config["sweep_speed"]
) % 1
sweep_color = QColor(color)
sweep_color.setAlphaF(config["sweep_opacity"])
painter.setPen(QPen(sweep_color, config["sweep_width"]))
sweep_x = rect.width() * sweep
painter.drawLine(QPointF(sweep_x, 0), QPointF(sweep_x, rect.height()))
def _paint_aurora(self, painter: QPainter, rect: QRectF, state: dict) -> None:
config = state["config"]["aurora"]
color = QColor(state["palette"][-1])
color.setAlphaF(config["opacity"])
painter.setPen(QPen(color, rect.width() * config["line_width_ratio"]))
for index in range(config["line_count"]):
path = QPainterPath()
for step in range(config["steps"] + 1):
progress = step / config["steps"]
drift = math.sin(
progress * config["frequency"]
+ state["phase"] * config["speed"]
+ index * config["phase_step"]
)
x = rect.width() * (
(index + config["edge_offset"]) / config["line_count"]
+ drift * config["drift_ratio"]
)
point = QPointF(x, rect.height() * progress)
path.moveTo(point) if step == 0 else path.lineTo(point)
painter.drawPath(path)
def _paint_metaballs(self, painter: QPainter, rect: QRectF, state: dict) -> None:
config = state["config"]["metaballs"]
accent = QColor(state["palette"][-1])
for index in range(config["count"]):
seed = index + config["seed_offset"]
radius = rect.height() * (
config["min_radius_ratio"] + self._hash(seed) * config["radius_range"]
)
x = rect.width() * self._hash(seed * config["x_seed"])
travel = state["phase"] * (
config["min_speed"] + self._hash(seed * config["speed_seed"])
)
y = rect.height() * ((self._hash(seed * config["y_seed"]) + travel) % 1)
gradient = QRadialGradient(QPointF(x, y), radius)
center = QColor(accent)
center.setAlphaF(config["opacity"])
edge = QColor(accent)
edge.setAlpha(0)
gradient.setColorAt(0, center)
gradient.setColorAt(1, edge)
painter.setBrush(gradient)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(x, y), radius, radius)
def _paint_leaf(self, painter: QPainter, rect: QRectF, state: dict) -> None:
config = state["config"]["leaf"]
color = QColor(state["palette"][-1])
color.setAlphaF(config["opacity"])
painter.setBrush(color)
painter.setPen(Qt.PenStyle.NoPen)
leaf_type = config["type"]
for index in range(config["count"]):
seed = index + config["seed_offset"]
size = rect.height() * (
config["min_size_ratio"] + self._hash(seed) * config["size_range"]
)
fall = state["phase"] * (
config["min_speed"] + self._hash(seed * config["speed_seed"])
)
y = rect.height() * ((self._hash(seed * config["y_seed"]) + fall) % 1)
sway = math.sin(state["phase"] + seed) * config["sway_ratio"]
x = rect.width() * ((self._hash(seed * config["x_seed"]) + sway) % 1)
painter.save()
painter.translate(x, y)
painter.rotate(state["phase"] * config["rotation_speed"] + seed)
painter.drawPath(self._leaf_path(leaf_type, size))
painter.restore()
@staticmethod
def _leaf_path(leaf_type: str, size: float) -> QPainterPath:
shapes = {
"sakura": (
(0, -0.62), (0.2, -0.95), (0.68, -0.82), (0.92, -0.3),
(0.72, 0.35), (0, 0.92), (-0.72, 0.35), (-0.92, -0.3),
(-0.68, -0.82), (-0.2, -0.95),
),
"oak": (
(0, -1), (0.3, -0.82), (0.18, -0.64), (0.56, -0.55),
(0.25, -0.34), (0.64, -0.16), (0.27, 0.02),
(0.55, 0.28), (0.2, 0.36), (0.14, 0.62), (0.08, 0.58),
(0.08, 1), (-0.08, 1), (-0.08, 0.58), (-0.14, 0.62),
(-0.2, 0.36), (-0.55, 0.28), (-0.27, 0.02),
(-0.64, -0.16), (-0.25, -0.34), (-0.56, -0.55),
(-0.18, -0.64), (-0.3, -0.82),
),
"maple": (
(0, -1),
(0.14, -0.55), (0.36, -0.72), (0.32, -0.36),
(0.72, -0.48), (0.56, -0.12), (1, -0.05),
(0.62, 0.2), (0.72, 0.5), (0.22, 0.38),
(0.08, 0.55), (0.08, 1), (-0.08, 1),
(-0.08, 0.55), (-0.22, 0.38), (-0.72, 0.5),
(-0.62, 0.2), (-1, -0.05), (-0.56, -0.12),
(-0.72, -0.48), (-0.32, -0.36), (-0.36, -0.72),
(-0.14, -0.55),
),
"birch": (
(0, -1), (0.18, -0.72), (0.38, -0.78), (0.34, -0.56),
(0.58, -0.55), (0.48, -0.34), (0.68, -0.26),
(0.52, -0.08), (0.62, 0.12), (0.35, 0.22),
(0.12, 0.48), (0.07, 1), (-0.07, 1), (-0.12, 0.48),
(-0.35, 0.22), (-0.62, 0.12), (-0.52, -0.08),
(-0.68, -0.26), (-0.48, -0.34), (-0.58, -0.55),
(-0.34, -0.56), (-0.38, -0.78), (-0.18, -0.72),
),
"generic": (
(0, -1), (0.38, -0.65), (0.62, -0.15), (0.5, 0.3),
(0.12, 0.62), (0.07, 1), (-0.07, 1), (-0.12, 0.62),
(-0.5, 0.3), (-0.62, -0.15), (-0.38, -0.65),
),
}
points = shapes.get(leaf_type, shapes["generic"])
path = QPainterPath(QPointF(size * points[0][0], size * points[0][1]))
for x_ratio, y_ratio in points[1:]:
path.lineTo(size * x_ratio, size * y_ratio)
path.closeSubpath()
return path
def _paint_veins(self, painter: QPainter, rect: QRectF, state: dict) -> None:
config = state["config"]["veins"]
color = QColor(state["palette"][-1])
color.setAlphaF(config["opacity"])
painter.setPen(QPen(color, config["line_width"]))
columns = config["columns"]
rows = config["rows"]
points = []
for row in range(rows):
row_points = []
for column in range(columns):
seed = row * columns + column + config["seed_offset"]
motion = math.sin(state["phase"] + seed) * config["motion_ratio"]
x = rect.width() * ((column + config["cell_offset"] + motion) / columns)
y = rect.height() * (
(row + config["cell_offset"] + self._hash(seed)) / rows
)
row_points.append(QPointF(x, y))
points.append(row_points)
for row in range(rows):
for column in range(columns):
if column + 1 < columns:
painter.drawLine(points[row][column], points[row][column + 1])
if row + 1 < rows:
painter.drawLine(points[row][column], points[row + 1][column])
@staticmethod
def _hash(value: float) -> float:
return math.sin(value * 12.9898) * 43758.5453 % 1
+35 -85
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,25 +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",
self.theme.GAME_CARD_ANIMATION.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
@@ -69,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)
@@ -109,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."""
@@ -159,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):
@@ -175,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,
)
@@ -186,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,
)
@@ -214,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,
)
@@ -225,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,
)
@@ -252,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)
@@ -294,8 +245,7 @@ class GameCardAnimations:
pen.setColor(QColor(0, 0, 0, 0))
painter.setPen(pen)
painter.setBrush(fill_brush)
radius = self.game_card.card_layout_cfg.get("border_radius", 18)
radius *= self.game_card._scale
radius = 18 * self.game_card._scale
bw = round(self.game_card._borderWidth / 2)
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
if rect.isEmpty():
+37 -180
View File
@@ -1,6 +1,7 @@
import argparse
import sys
import os
import importlib
import subprocess
import threading
import urllib.error
@@ -11,6 +12,16 @@ from logging import Logger
__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.2"
__app_version__ = os.environ.get("PORTPROTONQT_VERSION", APP_VERSION)
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (
QApplication,
@@ -49,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.4.1"
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():
@@ -201,24 +198,6 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
def main():
parsed_args = parse_args()
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:
success = reinstall_steam_compat_tool()
@@ -311,8 +290,6 @@ def main():
restore_prefix_path = None
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)
@@ -326,31 +303,17 @@ 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 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"
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
ipc_message = f"open:{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)
ipc_message = f"autoinstall:{quote(autoinstall_path, safe='')}"
elif args.file_or_url and args.file_or_url.startswith("gog://launch/"):
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}"
elif args.file_or_url:
theme_store_id = parse_portprotonqt_theme_url(args.file_or_url)
if theme_store_id is not None:
@@ -358,13 +321,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,
@@ -419,7 +380,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:
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)
@@ -503,59 +464,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:
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)
QTimer.singleShot(0, handle_launch_exe)
elif autoinstall_path:
def handle_autoinstall():
@@ -569,14 +484,6 @@ def main():
def handle_restore_prefix():
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)
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():
@@ -594,25 +501,19 @@ def main():
if (
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")
@@ -624,17 +525,11 @@ def main():
display_config.set_fullscreen(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:"):
log_mode = msg.startswith("log:")
launch_path = unquote(msg.split(":", 1)[1].strip())
if msg.startswith("open:"):
launch_path = msg[5:].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)
else:
logger.warning("Invalid launch file via IPC: %s", launch_path)
elif msg.startswith("restore:"):
@@ -644,18 +539,6 @@ def main():
window._perform_restore(backup_path)
else:
logger.warning("Invalid prefix backup via IPC: %s", backup_path)
elif msg.startswith("gog:"):
app_id = msg[4:].strip()
if app_id.isdigit():
handle_game_request(f"gog://launch/{app_id}", False)
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:
@@ -698,20 +581,15 @@ 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
)
if launch_minimized:
logger.info("Launching in tray")
@@ -776,27 +654,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."""
-222
View File
@@ -1,222 +0,0 @@
"""User-level AppImage desktop integration."""
import os
import re
import shlex
import shutil
import subprocess
from pathlib import Path
from PySide6.QtCore import QStandardPaths, 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-dosexec;application/vnd.microsoft.portable-executable;"
)
INTEGRATION_MODES = ("log", "silent")
def _find_appimage_asset(appdir: Path, names: tuple[str, ...]) -> Path | None:
for name in names:
path = appdir / name
if path.is_file():
return path
return None
def _copy_appimage(source: Path, destination: Path) -> None:
if source.resolve() == destination.resolve():
return
temporary = destination.with_suffix(".appimage.part")
shutil.copy2(source, temporary)
temporary.chmod(EXECUTABLE_MODE)
temporary.replace(destination)
def _remove_existing_integration(applications_dir: Path, icon_dir: Path) -> None:
desktop_names = (
f"{APP_ID}.desktop",
f"{APP_ID}.log.desktop",
f"{APP_ID}.silent.desktop",
)
for name in desktop_names:
(applications_dir / name).unlink(missing_ok=True)
for suffix in (".svg", ".png"):
(icon_dir / f"{APP_ID}{suffix}").unlink(missing_ok=True)
def _rewrite_main_desktop(content: str, appimage: 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)
if re.search(r"^TryExec=", content, flags=re.MULTILINE):
return re.sub(
r"^TryExec=.*$",
f"TryExec={appimage}",
content,
count=1,
flags=re.MULTILINE,
)
return content.replace("Type=Application\n", f"Type=Application\nTryExec={appimage}\n", 1)
def _mode_desktop(mode: str, appimage: 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 (
"[Desktop Entry]\n"
f"Name=PortProtonQt — {action}\n"
f"Exec={exec_line}\n"
f"TryExec={appimage}\n"
"Type=Application\n"
f"Comment={action}\n"
"Terminal=false\n"
f"Icon={APP_ID}\n"
f"StartupWMClass={APP_ID}\n"
"Categories=Game;\n"
"NoDisplay=true\n"
f"MimeType={WINDOWS_MIME_TYPES}\n"
"StartupNotify=true\n"
)
def _install_desktop_files(
source: Path,
applications_dir: Path,
appimage: 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)
)
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")
def _set_default_mime_handlers() -> None:
desktop_id = f"{APP_ID}.desktop"
for mime_type in WINDOWS_MIME_TYPES.rstrip(";").split(";"):
try:
subprocess.run(
["xdg-mime", "default", desktop_id, mime_type],
check=True,
timeout=DESKTOP_DATABASE_TIMEOUT,
)
except FileNotFoundError as error:
logger.warning("Failed to set default MIME handler: %s", error)
return
except subprocess.SubprocessError as error:
logger.warning("Failed to set default handler for %s: %s", mime_type, error)
def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
desktop = _find_appimage_asset(
appdir,
(
f"{APP_ID}.desktop",
f"usr/share/applications/{APP_ID}.desktop",
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(
appdir,
(
f"share/mime/packages/{MIME_PACKAGE_NAME}",
f"usr/share/mime/packages/{MIME_PACKAGE_NAME}",
),
)
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,
)
def integrate_appimage() -> Path:
"""Install the running AppImage and its desktop handlers for this user."""
source = Path(os.environ["APPIMAGE"]).expanduser()
appdir = Path(os.environ["APPDIR"]).expanduser()
if not source.is_file():
raise FileNotFoundError("AppImage desktop metadata is incomplete")
desktop_source, icon_source = _get_appimage_metadata(appdir)
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"
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)
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"],
)
_remove_existing_integration(applications_dir, icon_dir)
shutil.copy2(resolved_icon, icon)
_install_desktop_files(desktop_source, applications_dir, destination)
_install_mime_package(appdir, data_home)
_set_default_mime_handlers()
try:
subprocess.run(
["update-desktop-database", str(applications_dir)],
check=False,
timeout=DESKTOP_DATABASE_TIMEOUT,
)
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
class AppImageIntegrationWorker(QThread):
"""Install AppImage integration outside the UI thread."""
completed = Signal(bool, str)
def run(self) -> None:
try:
destination = integrate_appimage()
self.completed.emit(True, str(destination))
except (OSError, subprocess.SubprocessError, KeyError) as error:
logger.error("Failed to integrate AppImage: %s", error)
self.completed.emit(False, str(error))
+29 -25
View File
@@ -28,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
@@ -129,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:
@@ -312,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
@@ -330,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
+1 -8
View File
@@ -69,11 +69,6 @@ def parse_args():
action="store_true",
help="Launch supported Windows file in tray without showing the main window"
)
parser.add_argument(
"--log",
action="store_true",
help="Enable PortProton logging for the launched Windows file"
)
parser.add_argument(
"--restore-prefix",
action="store_true",
@@ -327,10 +322,8 @@ def remove_steam_compat_tool() -> bool:
def clear_cache() -> bool:
"""Clear PortProtonQt cache."""
from portprotonqt.config import cache_config
from portprotonqt.gog_api import GOGAPI
cache_config.clear_cache()
GOGAPI().clear_library_cache()
print("PortProtonQt cache cleared")
return True
@@ -435,7 +428,7 @@ def is_autoinstall_file(path: str) -> bool:
def parse_portproton_url(url: str) -> str | None:
"""Parse a portproton:// URL to extract the full download URL.
Expected format: portproton://https//linux-gaming.ru/api/games/130127/ppdb/download
Expected format: portproton://https//ppdb.linux-gaming.ru/api/games/130127/ppdb/download
Args:
url: The portproton:// URL to parse
-531
View File
@@ -1,531 +0,0 @@
# Copyright 2026 mirkobrombin <brombin94@gmail.com>
# Adapted from the Bottles compatibility analyser under GPL-3.0.
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import orjson
import pefile
from portprotonqt.debug_utils import (
get_portproton_env,
get_prefix_name,
get_selectable_gpu_entries,
get_vulkan_use_info,
get_wine_version,
get_winetricks_log,
)
from portprotonqt.logger import get_logger
from portprotonqt.scripts_utils.graphics_detector import analyze_executable
logger = get_logger(__name__)
CRASH_THRESHOLD_SECONDS = 7
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
PE_MACHINE_AMD64 = 0x8664
COMPATIBILITY_SCAN_BYTES = 4_194_304
COMPATIBILITY_NEIGHBOR_SCAN_LIMIT = 20
COMPATIBILITY_RULES_PATH = Path(__file__).with_name("compatibility_rules.json")
I586_ELF_LOADER = "/lib/ld-linux.so.2"
DXVK_VULKAN_REQUIREMENTS = {
"6": ("Newest", "DXVK_NEW_VER", (1, 4)),
"2": ("Stable", "DXVK_OLD_VER", (1, 3)),
"1": ("Sarek", "DXVK_SAREK_VER", (1, 1)),
}
DXVK_DRIVER_REQUIREMENTS = {
"0x1002": ("AMD RADV", (25, 0), "driver_version"),
"0x10de": ("NVIDIA", (550, 54, 14), "driver_info"),
"0x8086": ("Intel ANV", (25, 1), "driver_version"),
}
DXVK_NVK_REQUIREMENT = ("NVIDIA NVK", (25, 1), "driver_version")
DXVK_NEWEST_NVIDIA_REQUIREMENT = ("NVIDIA", (575, 51, 2), "driver_info")
VCRUN_COMPONENTS = {
"Visual C++ 2010": ("vcrun2010",),
"Visual C++ 2012": ("vcrun2012",),
"Visual C++ 2013": ("vcrun2013",),
"Visual C++ 2015-2022": ("vcrun2015", "vcrun2017", "vcrun2019", "vcrun2022"),
}
DLL_MAPPINGS = {
"mscoree.dll": (".NET Framework", "Runtimes"),
"hostfxr.dll": (".NET Core/5+", "Runtimes"),
"coreclr.dll": (".NET Core/5+", "Runtimes"),
"mono-2.0-bdwgc.dll": ("Mono", "Runtimes"),
"jvm.dll": ("Java", "Runtimes"),
"python3.dll": ("Python", "Runtimes"),
"msvcp100.dll": ("Visual C++ 2010", "Runtimes"),
"msvcp110.dll": ("Visual C++ 2012", "Runtimes"),
"msvcp120.dll": ("Visual C++ 2013", "Runtimes"),
"msvcp140.dll": ("Visual C++ 2015-2022", "Runtimes"),
"vcruntime140.dll": ("Visual C++ 2015-2022", "Runtimes"),
"unityplayer.dll": ("Unity", "Engines"),
"gameassembly.dll": ("Unity IL2CPP", "Engines"),
"cryrenderd3d11.dll": ("CryEngine", "Engines"),
"tier0.dll": ("Source", "Engines"),
"libcef.dll": ("Chromium Embedded Framework", "Frameworks"),
"electron.dll": ("Electron", "Frameworks"),
"qt5core.dll": ("Qt 5", "Frameworks"),
"qt6core.dll": ("Qt 6", "Frameworks"),
"xinput1_3.dll": ("XInput 1.3", "Input"),
"dinput8.dll": ("DirectInput", "Input"),
"sdl2.dll": ("SDL2", "Input"),
"xaudio2_7.dll": ("XAudio 2.7", "Audio"),
"fmod.dll": ("FMOD", "Audio"),
"fmod64.dll": ("FMOD", "Audio"),
"binkw64.dll": ("Bink", "Audio"),
"openal32.dll": ("OpenAL", "Audio"),
"steam_api64.dll": ("Steamworks", "Social/DRM"),
"galaxy64.dll": ("GOG Galaxy", "Social/DRM"),
"eossdk-win64-shipping.dll": ("Epic Online Services", "Social/DRM"),
"easyanticheat_x64.dll": ("Easy Anti-Cheat", "Protection"),
"beclient_x64.dll": ("BattlEye", "Protection"),
"vmprotectsdk64.dll": ("VMProtect", "Protection"),
}
@dataclass(frozen=True)
class CompatibilityLaunch:
executable: str
exit_code: int | None
duration: float
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 duration < CRASH_THRESHOLD_SECONDS))
and executable.lower().endswith((".exe", ".msi"))
and os.path.isfile(executable)
)
def _read_imports(pe: pefile.PE) -> set[str]:
imports = set()
for directory_name in ("DIRECTORY_ENTRY_IMPORT", "DIRECTORY_ENTRY_DELAY_IMPORT"):
for entry in getattr(pe, directory_name, []):
try:
imports.add(entry.dll.decode("utf-8", errors="ignore").lower())
except AttributeError:
continue
return imports
def _load_rules() -> list[dict[str, Any]]:
try:
data = orjson.loads(COMPATIBILITY_RULES_PATH.read_bytes())
except (OSError, orjson.JSONDecodeError) as error:
logger.error("Compatibility report failed to load signature rules: %s", error)
return []
rules = data.get("rules", [])
return rules if isinstance(rules, list) else []
def _matched_pattern_ids(
data: bytes,
lowered: bytes,
flattened_wide: bytes,
patterns: list[dict[str, str]],
) -> set[str]:
matched = set()
for pattern in patterns:
identifier = pattern.get("id", "")
text = pattern.get("text")
if text is not None:
needle = text.encode("utf-8", errors="ignore").lower()
if needle in lowered or needle in flattened_wide:
matched.add(identifier)
continue
expression = pattern.get("regex")
if expression and re.search(expression.encode(), data, re.IGNORECASE):
matched.add(identifier)
return matched
def _scan_file(
file_path: str, rules: list[dict[str, Any]], source: str
) -> list[dict[str, str]]:
try:
with open(file_path, "rb") as source_file:
data = source_file.read(COMPATIBILITY_SCAN_BYTES)
except OSError as error:
logger.warning("Compatibility report failed to scan %s: %s", file_path, error)
return []
data = os.path.basename(file_path).encode(errors="ignore") + b"\n" + data
lowered = data.lower()
flattened_wide = lowered.replace(b"\x00", b"")
findings = []
for rule in rules:
matched = _matched_pattern_ids(
data,
lowered,
flattened_wide,
rule.get("patterns", []),
)
required_sets = rule.get("required_sets", [])
if not any(set(required).issubset(matched) for required in required_sets):
continue
findings.append({
"category": str(rule.get("category", "Unknown")),
"name": str(rule.get("name", rule.get("id", "Unknown"))),
"description": str(rule.get("description", "")),
"severity": str(rule.get("severity", "info")),
"source": source,
})
return findings
def _scan_installer(
installer_path: str, rules: list[dict[str, Any]]
) -> list[dict[str, str]]:
seven_zip = shutil.which("7z") or shutil.which("7za") or shutil.which("7zr")
if seven_zip is None:
return []
findings = []
try:
with tempfile.TemporaryDirectory(prefix="portprotonqt-compatibility-") as extract_dir:
result = subprocess.run(
[seven_zip, "x", "-y", f"-o{extract_dir}", installer_path],
capture_output=True,
check=False,
timeout=120,
)
if result.returncode != 0:
logger.warning("Compatibility report could not extract installer %s", installer_path)
return []
extracted = []
for root, _directories, files in os.walk(extract_dir):
extracted.extend(
os.path.join(root, name)
for name in files
if name.lower().endswith((".exe", ".dll", ".msi", ".js", ".json", ".node"))
)
for file_path in extracted[:COMPATIBILITY_NEIGHBOR_SCAN_LIMIT]:
findings.extend(
_scan_file(file_path, rules, f"Installer: {os.path.basename(file_path)}")
)
except (OSError, subprocess.SubprocessError) as error:
logger.warning("Compatibility report installer scan failed for %s: %s", installer_path, error)
return findings
def _neighbor_files(executable: str) -> list[str]:
executable_dir = os.path.realpath(os.path.dirname(executable))
unsafe_dirs = {
os.path.realpath(str(Path.home())),
*(os.path.realpath(str(Path.home() / name))
for name in ("Downloads", "Desktop", "Documents", "Templates")),
}
if executable_dir in unsafe_dirs:
return []
try:
neighbors = [
os.path.join(executable_dir, name)
for name in os.listdir(executable_dir)
if name.lower().endswith(".dll")
]
except OSError:
return []
executable_base = os.path.splitext(os.path.basename(executable))[0]
plugins_dir = os.path.join(executable_dir, f"{executable_base}_Data", "Plugins")
if os.path.isdir(plugins_dir):
for root, _directories, files in os.walk(plugins_dir):
neighbors.extend(
os.path.join(root, name) for name in files if name.lower().endswith(".dll")
)
return neighbors[:COMPATIBILITY_NEIGHBOR_SCAN_LIMIT]
def _add_directory_findings(executable: str, findings: dict[str, list[str]]) -> None:
executable_dir = os.path.dirname(executable)
executable_base = os.path.splitext(os.path.basename(executable))[0]
markers = (
("Engine", "Unreal", "Engines"),
(f"{executable_base}_Data", "Unity", "Engines"),
("renpy", "Ren'Py", "Engines"),
("www", "RPG Maker", "Engines"),
("resources/app.asar", "Electron", "Frameworks"),
(f"{executable_base}.pck", "Godot", "Engines"),
(f"{executable_base}.runtimeconfig.json", ".NET Core/5+", "Runtimes"),
(f"{executable_base}.deps.json", ".NET Core/5+", "Runtimes"),
)
for relative_path, name, category in markers:
if os.path.exists(os.path.join(executable_dir, relative_path)):
values = findings.setdefault(category, [])
if name not in values:
values.append(name)
def _analyze_pe(executable: str) -> tuple[str, dict[str, list[str]]]:
findings: dict[str, list[str]] = {}
try:
pe = pefile.PE(executable, fast_load=True)
pe.parse_data_directories(
directories=[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"],
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT"],
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"],
]
)
except (OSError, pefile.PEFormatError) as error:
logger.warning("Compatibility report could not parse %s: %s", executable, error)
return "Unknown", findings
machine = getattr(pe.FILE_HEADER, "Machine", None)
architecture = "64-bit" if machine == PE_MACHINE_AMD64 else "32-bit"
imports = _read_imports(pe)
for dll_name, (name, category) in DLL_MAPPINGS.items():
if dll_name in imports and name not in findings.setdefault(category, []):
findings[category].append(name)
try:
optional_header = getattr(pe, "OPTIONAL_HEADER", None)
data_directories = getattr(optional_header, "DATA_DIRECTORY", [])
clr = data_directories[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"]
]
if clr.VirtualAddress and ".NET Framework" not in findings.setdefault("Runtimes", []):
findings["Runtimes"].append(".NET Framework")
except (AttributeError, IndexError):
pass
_add_directory_findings(executable, findings)
return architecture, findings
def _apply_signature_findings(
findings: dict[str, list[str]], signature_findings: list[dict[str, str]]
) -> None:
for item in signature_findings:
values = findings.setdefault(item["category"], [])
if item["name"] not in values:
values.append(item["name"])
def _uses_integrated_gpu(environment: dict[str, str]) -> bool:
selected_gpu = environment.get("PW_GPU_USE", "")
if not selected_gpu or selected_gpu == "disabled":
return False
return any(
entry["device_name"] == selected_gpu
and entry.get("device_type") == "INTEGRATED_GPU"
for entry in get_selectable_gpu_entries()
)
def _version_at_least(version: str, required: tuple[int, ...]) -> bool:
parts = tuple(int(part) for part in re.findall(r"\d+", version)[:len(required)])
normalized = parts + (0,) * (len(required) - len(parts))
return bool(parts) and normalized >= required
def _selected_gpu(environment: dict[str, str]) -> dict[str, str] | None:
entries = get_selectable_gpu_entries()
selected_name = environment.get("PW_GPU_USE", "")
if selected_name and selected_name != "disabled":
return next(
(entry for entry in entries if entry["device_name"] == selected_name),
None,
)
return entries[0] if entries else None
def _dxvk_driver_requirement(
gpu: dict[str, str], backend: str,
) -> tuple[str, tuple[int, ...], str] | None:
vendor_id = gpu.get("vendor_id", "").lower()
driver_name = gpu.get("driver_name", "").lower()
if vendor_id == "0x10de" and "nvk" in driver_name:
return DXVK_NVK_REQUIREMENT
if vendor_id == "0x10de" and backend == "6":
return DXVK_NEWEST_NVIDIA_REQUIREMENT
return DXVK_DRIVER_REQUIREMENTS.get(vendor_id)
def _dxvk_vulkan_compatibility(
environment: dict[str, str],
) -> tuple[str | None, list[str]]:
backend = environment.get("PW_VULKAN_USE", "")
requirement = DXVK_VULKAN_REQUIREMENTS.get(backend)
if requirement is None:
return None, []
backend_name, version_key, required_vulkan = requirement
dxvk_version = environment.get(version_key, "unknown")
gpu = _selected_gpu(environment)
if gpu is None:
return f"{backend_name} DXVK {dxvk_version}: Vulkan version unknown", []
api_version = gpu.get("api_version", "unknown")
required_api = ".".join(map(str, required_vulkan))
details = f"{backend_name} DXVK {dxvk_version}: Vulkan {api_version} (requires {required_api}+)"
suggestions = []
if not _version_at_least(api_version, required_vulkan):
suggestions.append(
f"Update the graphics driver: {backend_name} DXVK requires Vulkan {required_api}+."
)
if backend != "1" and _version_at_least(api_version, (1, 1)):
suggestions.append("Switch to Sarek for Vulkan 1.1 drivers.")
driver_requirement = _dxvk_driver_requirement(gpu, backend)
if backend not in {"6", "2"} or driver_requirement is None:
return details, suggestions
driver_name, required_driver, version_field = driver_requirement
driver_version = gpu.get(version_field, "unknown")
required_version = ".".join(map(str, required_driver))
details += f"; {driver_name} {driver_version} (requires {required_version}+)"
if not _version_at_least(driver_version, required_driver):
fallback = "Stable DXVK" if backend == "6" else "Sarek"
suggestions.append(
f"Update {driver_name} to {required_version}+ or switch to {fallback}."
)
return details, suggestions
def has_dxvk_vulkan_incompatibility(executable: str) -> bool:
"""Return whether configured DXVK cannot run on the selected GPU."""
environment = get_portproton_env(executable)
_details, suggestions = _dxvk_vulkan_compatibility(environment)
return bool(suggestions)
def _glibc_32_compatibility() -> tuple[str, list[str]]:
if os.path.isfile(I586_ELF_LOADER):
return "available", []
return (
f"unavailable (missing {I586_ELF_LOADER})",
[
"Install or enable 32-bit glibc with its ELF loader."
],
)
def _compatibility_suggestions(
findings: dict[str, list[str]],
graphics: str,
shipping_executable: str,
environment: dict[str, str],
) -> list[str]:
suggestions = []
if shipping_executable:
suggestions.append(f"Try launching the game binary instead: {shipping_executable}")
uses_directx_8_11 = any(
version in graphics
for version in ("DirectX 8", "DirectX 9", "DirectX 10", "DirectX 11")
)
if uses_directx_8_11 and environment.get("PW_VULKAN_USE") == "0":
suggestions.append("Switch from WineD3D to DXVK for DirectX 8-11.")
if _uses_integrated_gpu(environment):
suggestions.append("Use the discrete GPU.")
runtimes = findings.get("Runtimes", [])
prefix_name = environment.get("PW_PREFIX_NAME", "DEFAULT")
needs_dotnet = any(".NET" in runtime for runtime in runtimes)
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]
for runtime, components in VCRUN_COMPONENTS.items()
if runtime in runtimes
and not any(component in winetricks_log for component in components)
]
if missing_vcrun:
suggestions.append(
f"Install {', '.join(missing_vcrun)} through Winetricks."
)
if "PhysX" in findings.get("Physics", []) and "physx" not in winetricks_log:
suggestions.append("Install physx through Winetricks.")
return list(dict.fromkeys(suggestions))
def _format_signature_details(items: list[dict[str, str]]) -> list[str]:
lines = []
seen = set()
for item in items:
key = (item["category"], item["name"])
if key in seen:
continue
seen.add(key)
severity = item["severity"].upper()
lines.append(
f"- [{severity}] {item['category']}: {item['name']} "
f"({item['source']}) — {item['description']}"
)
return lines
def analyze_launch(launch: CompatibilityLaunch, portproton_path: str) -> str:
"""Build a local compatibility report for a failed launch."""
architecture, findings = _analyze_pe(launch.executable)
rules = _load_rules()
signature_findings = _scan_file(launch.executable, rules, "Main executable")
for neighbor in _neighbor_files(launch.executable):
signature_findings.extend(_scan_file(neighbor, rules, f"Neighbor: {os.path.basename(neighbor)}"))
has_installer = launch.executable.lower().endswith(".msi") or any(
item["category"] == "Installer" for item in signature_findings
)
if has_installer:
signature_findings.extend(_scan_installer(launch.executable, rules))
_apply_signature_findings(findings, signature_findings)
graphics = analyze_executable(launch.executable)
graphics_executable = str(graphics.get("source", launch.executable))
graphics_name = os.path.basename(graphics_executable).lower()
shipping_executable = (
graphics_executable if graphics_name.endswith(("-win64-shipping.exe", "-win32-shipping.exe"))
else ""
)
detected_graphics = str(graphics["highest_directx"])
if graphics["uses_opengl"]:
detected_graphics = f"{detected_graphics}, OpenGL"
environment = get_portproton_env(launch.executable)
prefix_name = get_prefix_name(launch.executable)
environment.setdefault("PW_PREFIX_NAME", prefix_name)
environment["PW_WINETRICKS_LOG"] = get_winetricks_log(
portproton_path, environment["PW_PREFIX_NAME"]
)
suggestions = _compatibility_suggestions(
findings,
detected_graphics,
shipping_executable,
environment,
)
dxvk_compatibility, version_suggestions = _dxvk_vulkan_compatibility(environment)
suggestions.extend(version_suggestions)
glibc_32_compatibility, glibc_32_suggestions = _glibc_32_compatibility()
suggestions.extend(glibc_32_suggestions)
lines = [
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",
f"Architecture: {architecture}",
f"Prefix: {prefix_name}",
f"Wine/Proton: {get_wine_version(portproton_path, launch.executable)}",
f"Configured 3D API: {get_vulkan_use_info(portproton_path, launch.executable)}",
f"Detected 3D API: {detected_graphics}",
f"32-bit glibc: {glibc_32_compatibility}",
]
if dxvk_compatibility:
lines.append(f"DXVK/Vulkan compatibility: {dxvk_compatibility}")
if graphics_executable != launch.executable:
lines.append(f"3D API source: {graphics_executable}")
if environment.get("PW_WINDOWS_VER"):
lines.append(f"Windows version: {environment['PW_WINDOWS_VER']}")
for category, values in findings.items():
if values:
lines.append(f"Detected {category}: {', '.join(values)}")
detail_lines = _format_signature_details(signature_findings)
if detail_lines:
lines.extend(("", "Detection details:", *detail_lines))
if suggestions:
lines.extend(("", "Possible fixes:", *(f"- {item}" for item in suggestions)))
return "\n".join(lines)
File diff suppressed because it is too large. Load diff
-4
View File
@@ -24,14 +24,12 @@ from portprotonqt.config.portproton import (
create_desktop_file,
extract_exec_target_path,
find_game_by_exe,
get_custom_data_dir_name,
get_portproton_location,
get_portproton_scripts_path,
get_portproton_start_command,
migrate_legacy_shortcut,
parse_desktop_entry,
read_portdata_path_from_config,
resolve_custom_data_dir,
save_portdata_path_to_config,
)
from portprotonqt.config.proxy import ProxyConfig
@@ -97,8 +95,6 @@ __all__ = [
"parse_desktop_entry",
"extract_exec_target_path",
"find_game_by_exe",
"get_custom_data_dir_name",
"resolve_custom_data_dir",
"create_desktop_file",
"consume_download_counter_skip",
"reset_main_config",
+1 -19
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):
@@ -25,21 +25,3 @@ class GameConfig(BaseConfig):
"""Set game display filter."""
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")
def set_steam_account_id(self, account_id: str) -> None:
"""Set selected Steam account ID or automatic selection."""
validate_string(account_id, "steam_account_id", min_len=1, max_len=20)
self._save_value("steam_account_id", account_id, "str")
+6 -62
View File
@@ -1,6 +1,5 @@
"""PortProton configuration and launch helpers."""
import configparser
import hashlib
import os
import re
import shlex
@@ -223,16 +222,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:]
@@ -283,11 +273,7 @@ def _get_desktop_paths(desktop_dir: str | None) -> tuple[str, ...]:
return (os.path.join(os.path.expanduser("~"), "Desktop"),)
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("~")
@@ -306,8 +292,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"),
@@ -519,35 +504,13 @@ def find_game_by_exe(exe_path: str) -> configparser.SectionProxy | None:
return None
def get_custom_data_dir_name(exe_path: str) -> str:
"""Return a collision-safe custom data directory name for an executable."""
normalized_path = os.path.normpath(os.path.abspath(os.path.expanduser(exe_path)))
path_hash = hashlib.md5(
normalized_path.encode("utf-8"), usedforsecurity=False
).hexdigest()
exe_name = os.path.splitext(os.path.basename(exe_path))[0]
return f"{exe_name}_{path_hash}"
def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
"""Return hashed custom data directory, falling back to the legacy name."""
hashed_dir = os.path.join(custom_data_root, get_custom_data_dir_name(exe_path))
legacy_name = os.path.splitext(os.path.basename(exe_path))[0]
legacy_dir = os.path.join(custom_data_root, legacy_name)
if not os.path.isdir(hashed_dir) and os.path.isdir(legacy_dir):
return legacy_dir
return hashed_dir
def create_desktop_file(
exe_path: str,
game_name: str | None = None,
icon_source: str | None = None,
) -> 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):
if not os.path.isfile(exe_path):
logger.error("Executable not found: %s", exe_path)
return None
if not portproton_path:
@@ -558,30 +521,20 @@ def create_desktop_file(
game_name = os.path.splitext(os.path.basename(exe_path))[0]
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
else os.path.join(base_path, "img", f"{icon_name}.png")
)
icon_path = os.path.join(base_path, "img", f"{icon_name}.png")
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
if icon_path != "applications-games":
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
flatpak_id = os.getenv("FLATPAK_ID")
appimage_path = os.getenv("APPIMAGE", "").strip()
if flatpak_id:
exec_str = f'flatpak run {flatpak_id} --silent "{exe_path}"'
log_exec = f'flatpak run {flatpak_id} --log "{exe_path}"'
elif appimage_path and os.path.isfile(appimage_path):
exec_str = shlex.join([appimage_path, "--silent", exe_path])
log_exec = shlex.join([appimage_path, "--log", exe_path])
else:
exec_str = f'portprotonqt --silent "{exe_path}"'
log_exec = f'portprotonqt --log "{exe_path}"'
comment = _('Launch "{name}" with PortProton').format(name=game_name)
silent_name = _("Run in silent mode")
log_name = _("Run in logging mode")
desktop_entry = (
"[Desktop Entry]\n"
f"Name={game_name}\n"
@@ -592,14 +545,5 @@ def create_desktop_file(
"Categories=Game;\n"
"StartupNotify=true\n"
f"Icon={icon_path}\n"
"Actions=RunSilent;RunLog;\n"
"\n[Desktop Action RunSilent]\n"
f"Name={silent_name}\n"
f"Exec={exec_str}\n"
f"Icon={icon_path}\n"
"\n[Desktop Action RunLog]\n"
f"Name={log_name}\n"
f"Exec={log_exec}\n"
f"Icon={icon_path}\n"
)
return desktop_entry, desktop_path, icon_path
+1 -44
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")
@@ -446,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."""
@@ -471,24 +446,6 @@ class UIConfig(BaseConfig):
validate_bool(enabled, "enable_theme_store")
self._save_value("enable_theme_store", enabled, "bool")
def get_sounds_enabled(self) -> bool:
"""Get UI sounds enabled setting."""
return self._get_bool("sounds_enabled", True)
def set_sounds_enabled(self, enabled: bool):
"""Set UI sounds enabled setting."""
validate_bool(enabled, "sounds_enabled")
self._save_value("sounds_enabled", enabled, "bool")
def get_crash_reports_enabled(self) -> bool:
"""Get compatibility reports after game crashes setting."""
return self._get_bool("crash_reports_enabled", True)
def set_crash_reports_enabled(self, enabled: bool) -> None:
"""Set compatibility reports after game crashes setting."""
validate_bool(enabled, "crash_reports_enabled")
self._save_value("crash_reports_enabled", enabled, "bool")
def load_theme_metainfo(theme_name: str) -> dict:
"""Load theme metadata from metainfo.ini."""
+37 -295
View File
@@ -3,17 +3,14 @@ import glob
import shutil
import tempfile
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.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
from portprotonqt.localization import _
from portprotonqt.config import (
create_desktop_file,
extract_exec_target_path,
favorites_config,
favorites_folders_config,
get_custom_data_dir_name,
parse_desktop_entry,
resolve_custom_data_dir,
)
from portprotonqt.steam_api import (
add_to_steam,
@@ -456,33 +453,27 @@ 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
if game_card.game_source == "portproton":
if game_card.game_source != "steam":
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
if not exe_path:
# 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_running = self._is_game_running(game_card)
if not is_uninstalled_store_game:
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)
launch_action.triggered.connect(
lambda: self._launch_game(game_card)
)
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)
launch_action.triggered.connect(
lambda: self._launch_game(game_card)
)
favorites = favorites_config.get_games()
is_favorite = game_card.name in favorites
@@ -491,123 +482,6 @@ class ContextMenuManager:
favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
if game_card.game_source == "gog":
game = {
"app_id": str(game_card.appid),
"title": game_card.name,
"cover": game_card.cover_path,
}
if game_card.exec_line.startswith("gog://launch/"):
repair_action = menu.addAction(
self._get_safe_icon("update"), _("Repair")
)
repair_action.triggered.connect(
lambda: self.parent._repair_gog_game(game)
)
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)
)
delete_action = menu.addAction(
self._get_safe_icon("delete"), _("Delete")
)
delete_action.triggered.connect(
lambda: self.parent._delete_gog_game(game)
)
else:
import_action = menu.addAction(
self._get_safe_icon("folder"), _("Import")
)
import_action.triggered.connect(
lambda: self.parent._import_gog_game(game)
)
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)
)
if game_card.game_source == "steam":
desktop_dir = QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.DesktopLocation
@@ -637,17 +511,8 @@ class ContextMenuManager:
lambda: self.open_steam_game_folder(game_card.appid)
)
)
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect(
lambda: self.edit_game_shortcut(
game_card.name,
game_card.exec_line,
game_card.cover_path,
game_card.appid,
)
)
if game_card.game_source == "portproton":
if game_card.game_source != "steam":
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
desktop_path = self._get_shortcut_path(game_card.name, desktop_dir)
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
@@ -696,7 +561,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):
"""
@@ -840,19 +705,6 @@ class ContextMenuManager:
)
return os.path.join(data_home, "applications")
def _update_desktop_database(self) -> None:
"""Update the desktop entry database asynchronously."""
applications_dir = self._get_applications_dir()
result = QProcess.startDetached(
"update-desktop-database", [applications_dir]
)
started = result[0] if isinstance(result, tuple) else result
if not started:
logger.warning(
"Failed to start desktop database update for %s",
applications_dir,
)
def _get_menu_shortcut_path(self, game_name: str) -> str:
"""Return PortProton shortcut path in the applications directory."""
return self._get_shortcut_path(game_name, self._get_applications_dir())
@@ -941,34 +793,26 @@ class ContextMenuManager:
def _remove_installed_shortcuts(self, game_name: str, shortcut_paths: list[str]) -> None:
"""Remove existing menu and desktop shortcuts."""
removed_paths = set()
menu_shortcut_removed = False
applications_dir = os.path.normpath(self._get_applications_dir())
for shortcut_path in shortcut_paths:
if shortcut_path in removed_paths or not os.path.exists(shortcut_path):
continue
removed_paths.add(shortcut_path)
try:
os.remove(shortcut_path)
if os.path.dirname(os.path.normpath(shortcut_path)) == applications_dir:
menu_shortcut_removed = True
except OSError as e:
logger.warning("Failed to remove shortcut for '%s': %s", game_name, e)
if menu_shortcut_removed:
self._update_desktop_database()
def _remove_statistics_entry(self, exe_path, game_name):
"""Remove statistics entry for exact executable path."""
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
@@ -1008,6 +852,7 @@ class ContextMenuManager:
return
shortcut_paths = self._get_installed_shortcut_paths(game_name)
exe_path = self._parse_exe_path(resolved_exec_line, game_name)
exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None
if not self._remove_file(
desktop_path,
_("Failed to delete .desktop file: {error}"),
@@ -1017,13 +862,12 @@ class ContextMenuManager:
return
self._remove_installed_shortcuts(game_name, shortcut_paths)
self._remove_statistics_entry(exe_path, game_name)
if exe_path:
if exe_name:
xdg_data_home = os.getenv(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data")
custom_folder = resolve_custom_data_dir(custom_root, exe_path)
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
if os.path.exists(custom_folder):
try:
shutil.rmtree(custom_folder)
@@ -1057,44 +901,22 @@ class ContextMenuManager:
self.game_library_manager.add_game_incremental(game_data)
def add_to_menu(self, game_name, exec_line):
"""Add the game desktop entry to the XDG applications directory."""
"""Copy the .desktop file to the XDG applications directory."""
if not self._check_portproton():
return
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
desktop_path = self._get_desktop_path(game_name)
if not is_store_uri and not os.path.exists(desktop_path):
if 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:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exec_line = self._parse_exe_path(exec_line, game_name)
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)
shortcut = create_desktop_file(exec_line, game_name, icon_source)
if not shortcut:
return
desktop_entry = shortcut[0]
icon_path = shortcut[2]
if icon_source and not os.path.exists(icon_path):
if not generate_thumbnail(icon_source, icon_path, size=128):
logger.error("Failed to generate thumbnail for game: %s", icon_source)
applications_dir = self._get_applications_dir()
os.makedirs(applications_dir, exist_ok=True)
dest_path = self._get_menu_shortcut_path(game_name)
try:
with open(dest_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
shutil.copyfile(desktop_path, dest_path)
os.chmod(dest_path, 0o755)
self._update_desktop_database()
except OSError as e:
self.signals.show_warning_dialog.emit(
_("Error"),
@@ -1111,18 +933,17 @@ class ContextMenuManager:
game_name: The display name of the game.
"""
menu_path = self._get_menu_shortcut_path(game_name)
if self._remove_file(
self._remove_file(
menu_path,
_("Failed to remove '{game_name}' from {location}: {error}"),
_("Removed '{game_name}' from {location}"),
game_name,
location=_("Menu")
):
self._update_desktop_database()
)
def add_to_desktop(self, game_name, exec_line):
"""
Add the game desktop entry to the user's Desktop folder.
Copies the .desktop file to the user's Desktop folder.
Args:
game_name: The display name of the game.
@@ -1131,42 +952,30 @@ class ContextMenuManager:
if not self._check_portproton():
return
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
desktop_path = self._get_desktop_path(game_name)
if not is_store_uri and not os.path.exists(desktop_path):
if 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:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exec_line = self._parse_exe_path(exec_line, game_name)
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)
shortcut = create_desktop_file(exec_line, game_name, icon_source)
if not shortcut:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
desktop_entry = shortcut[0]
icon_path = shortcut[2]
thumbnail_source = icon_source or exec_line
if icon_path != "applications-games" and not os.path.exists(icon_path):
if not generate_thumbnail(thumbnail_source, icon_path, size=128):
logger.error("Failed to generate thumbnail for game: %s", thumbnail_source)
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
if not os.path.exists(icon_path):
if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error("Failed to generate thumbnail for game: %s", exe_path)
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
os.makedirs(desktop_dir, exist_ok=True)
dest_path = self._get_shortcut_path(game_name, desktop_dir)
try:
with open(dest_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
shutil.copyfile(desktop_path, dest_path)
os.chmod(dest_path, 0o755)
except OSError as e:
self.signals.show_warning_dialog.emit(
@@ -1227,10 +1036,6 @@ class ContextMenuManager:
with open(dest_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(dest_path, 0o755)
if os.path.normpath(target_dir) == os.path.normpath(
self._get_applications_dir()
):
self._update_desktop_database()
except OSError as e:
self.signals.show_warning_dialog.emit(
_("Error"),
@@ -1241,11 +1046,6 @@ class ContextMenuManager:
fetch_client_icon_async(appid_str, write_shortcut)
def remove_gog_shortcuts(self, game_name: str) -> None:
"""Remove desktop and application menu shortcuts for a GOG game."""
shortcut_paths = self._get_installed_shortcut_paths(game_name)
self._remove_installed_shortcuts(game_name, shortcut_paths)
def add_steam_to_desktop(self, game_name: str, appid: int | str) -> None:
"""Create a desktop shortcut for an installed Steam game."""
desktop_dir = QStandardPaths.writableLocation(
@@ -1275,66 +1075,15 @@ class ContextMenuManager:
def remove_steam_from_menu(self, game_name: str) -> None:
"""Remove a Steam game application menu shortcut."""
menu_path = self._get_steam_menu_shortcut_path(game_name)
if self._remove_file(
self._remove_file(
menu_path,
_("Failed to remove '{game_name}' from {location}: {error}"),
_("Removed '{game_name}' from {location}"),
game_name,
location=_("Menu")
):
self._update_desktop_database()
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=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):
selected_cover = dialog.last_cover_path
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()
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()
if extension not in COVER_IMAGE_EXTENSIONS:
return
target_cover = os.path.join(game_dir, f"cover{extension}")
if os.path.abspath(selected_cover) != os.path.abspath(target_cover):
self._remove_old_cover_files(game_dir)
shutil.copyfile(selected_cover, target_cover)
cover_path = target_cover
except OSError as e:
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]) != 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)
)
QTimer.singleShot(0, self.game_library_manager.load_visible_images)
return
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
def edit_game_shortcut(self, game_name, exec_line, cover_path):
"""
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
@@ -1343,9 +1092,6 @@ class ContextMenuManager:
exec_line: The executable command line of the game.
cover_path: The path to the game's cover image.
"""
if appid is not None:
self._edit_steam_shortcut(game_name, appid, cover_path)
return
if not self._check_portproton():
return
exec_line = self._get_exec_line(game_name, exec_line)
@@ -1416,16 +1162,12 @@ class ContextMenuManager:
# Use the downloaded file path if we have a URL and the file was downloaded, otherwise use the local file
if os.path.isfile(new_cover_path) or (is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path)):
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
xdg_data_home = os.getenv(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_folder = os.path.join(
xdg_data_home,
"PortProtonQt",
"custom_data",
get_custom_data_dir_name(new_exe_path),
)
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
os.makedirs(custom_folder, exist_ok=True)
# Use the actual cover file path (either from URL download or local file)
+4 -9
View File
@@ -135,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):
@@ -160,7 +160,7 @@ class FlowLayout(QLayout):
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 = item.sizeHint()
@@ -191,11 +191,6 @@ class FlowLayout(QLayout):
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)
@@ -676,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
)
@@ -842,7 +837,7 @@ 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._v_scrollbar.installEventFilter(self)
+4 -10
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,
@@ -175,13 +174,8 @@ def get_selectable_gpu_entries() -> list[dict[str, str]]:
device_id = gpu_info.get("device_id", "").strip()
entry = {
"device_name": device_name,
"device_type": device_type,
"vendor_id": vendor_id,
"device_id": device_id,
"driver_name": gpu_info.get("driver_name", "").strip(),
"driver_info": gpu_info.get("driver_info", "").strip(),
"driver_version": gpu_info.get("driver_version", "").strip(),
"api_version": gpu_info.get("api_version", "").strip(),
}
if device_type == "DISCRETE_GPU":
+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)
+29 -299
View File
@@ -1,10 +1,7 @@
"""Detail pages for PortProtonQt."""
import os
import weakref
from collections.abc import Callable, Sequence
from shiboken6 import isValid
from collections.abc import Callable
from PySide6.QtWidgets import (
QWidget,
QLabel,
@@ -16,7 +13,7 @@ from PySide6.QtWidgets import (
QBoxLayout,
QSizePolicy,
)
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
from PySide6.QtCore import Qt, QUrl, QTimer
from PySide6.QtGui import QDesktopServices
from portprotonqt.detail_pages.widgets import (
@@ -55,14 +52,7 @@ from portprotonqt.logger import get_logger
from portprotonqt.animations import DetailPageAnimations
from portprotonqt.debug_utils import DebugLogManager
from portprotonqt.image_utils import cleanup_widget_animated_covers
from portprotonqt.icon_extractor import get_exe_icon_cache_path
from portprotonqt.steam_api import (
get_local_steam_cover,
get_steam_compat_tool,
get_steam_home,
get_steam_libs,
safe_vdf_load,
)
from portprotonqt.steam_api import get_steam_compat_tool, get_steam_home, get_steam_libs, safe_vdf_load
from portprotonqt.time_utils import format_playtime
logger = get_logger(__name__)
@@ -83,18 +73,11 @@ class DetailPageManager:
self._autoinstall_status_controls = None
self._current_detail_source: tuple[str, dict] | None = None
self._resize_rebuild_pending = False
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."""
self._current_detail_source = ("game", dict(game_data))
detail_page = QWidget()
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
detail_page.setProperty("fallbackExe", fallback_exe)
detail_page.setProperty("fallbackIconPath", fallback_icon_path)
@@ -144,7 +127,19 @@ class DetailPageManager:
return "", ""
if not exe_path.lower().endswith(".exe"):
return "", ""
return exe_path, get_exe_icon_cache_path(exe_path)
xdg_cache_home = os.getenv(
"XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache"),
)
game_name = os.path.basename(game_data.get("name", ""))
icon_name = game_name or os.path.splitext(os.path.basename(exe_path))[0]
icon_path = os.path.join(
xdg_cache_home,
"PortProtonQt",
"images",
f"{icon_name}.png",
)
return exe_path, icon_path
def _get_favorite_icon_name(self, name: str) -> str:
return "star_fav_full" if name in favorites_config.get_games() else "star_fav"
@@ -179,15 +174,6 @@ class DetailPageManager:
buttons_layout: FlowLayout,
) -> dict:
cover_frame, image_label = cover_widgets
cover_path = game_data.get("cover_path")
if str(game_data.get("game_source", "")).lower() == "steam":
appid = game_data.get("appid", "")
xdg_data_home = os.getenv(
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", str(appid))
if os.path.dirname(os.path.abspath(cover_path or "")) != custom_dir:
cover_path = get_local_steam_cover(appid) or cover_path
return {
"cover_frame": cover_frame,
"image_label": image_label,
@@ -196,7 +182,7 @@ class DetailPageManager:
"buttons_layout": buttons_layout,
"name": game_data.get("name", ""),
"exec_line": game_data.get("exec_line", ""),
"cover_path": cover_path,
"cover_path": game_data.get("cover_path"),
}
def _is_compact_detail_layout(self) -> bool:
@@ -268,8 +254,7 @@ class DetailPageManager:
def _create_protondb_badge(self, parent: QWidget, game_data: dict) -> dict | None:
badge, visible = create_protondb_badge(
parent, game_data.get("protondb_tier", ""),
game_data.get("protondb_appid", game_data.get("appid", "")),
parent, game_data.get("protondb_tier", ""), game_data.get("appid", ""),
self.main_window
)
return {"label": badge, "visible": True} if badge and visible else None
@@ -312,167 +297,12 @@ 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)
self._setup_hltb_data(game_data.get("name", ""), hltb_layout, game_info_layout)
return game_info_layout
def _setup_gog_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() != "gog":
return
if not exec_line.startswith("gog://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]
cached = self.main_window.gog_api.get_cached_download_sizes(app_id)
if cached is not None:
self._set_gog_size_values(values, cached)
return
request = self._gog_size_requests.get(app_id)
if request is not None:
request[1].append(values)
return
try:
command = self.main_window.gog_api.build_command(
["info", app_id, "--platform", "windows", "--skip-dlcs"]
)
except FileNotFoundError as error:
logger.warning("Failed to get GOG download size: %s", error)
return
process = QProcess(self.main_window)
process.setProgram(command[0])
process.setArguments(command[1:])
environment = QProcessEnvironment.systemEnvironment()
environment.insert("GOGDL_CONFIG_PATH", str(self.main_window.gog_api.config_dir))
process.setProcessEnvironment(environment)
self._gog_size_requests[app_id] = (process, [values])
process.finished.connect(
lambda code, _status: self._finish_gog_size_request(app_id, code)
)
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:
return
process, value_groups = request
if code != 0:
logger.warning("gogdl info exited with code %s", code)
process.deleteLater()
return
try:
sizes = self.main_window.gog_api.parse_download_sizes(
bytes(process.readAllStandardOutput().data())
)
except (ValueError, TypeError) as error:
logger.warning("Failed to parse GOG download size: %s", error)
process.deleteLater()
return
self.main_window.gog_api.save_download_sizes(app_id, sizes)
for value_refs in value_groups:
self._set_gog_size_values(value_refs, sizes)
process.deleteLater()
@staticmethod
def _set_gog_size_values(
value_refs: list[Callable[[], QLabel | None]], sizes: tuple[int, int]
) -> None:
for value_ref, size in zip(value_refs, sizes, strict=True):
label = value_ref()
if label is None or not isValid(label):
continue
divisor, unit = (
(1024 ** 3, "GiB") if size >= 1024 ** 3 else (1024 ** 2, "MiB")
)
label.setText(f"{size / divisor:.1f} {unit}")
def _create_playtime_row(self, last_launch: str, formatted_playtime: str) -> QHBoxLayout:
"""Create first row with last launch and playtime."""
first_row = QHBoxLayout()
@@ -604,27 +434,9 @@ 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:
play_button.setText(_("Install"))
play_button.setIcon(
self.main_window.theme_manager.get_icon("update", as_path=True)
)
buttons_layout.addWidget(play_button)
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),
)
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):
if self._has_game_shortcut(game_name):
edit_button = self._make_action_button(
_("Edit Shortcut"),
self.main_window.theme_manager.get_icon("edit", as_path=True),
@@ -635,7 +447,7 @@ class DetailPageManager:
)
)
buttons_layout.addWidget(edit_button)
if str(game_source).lower() == "steam" and appid:
elif str(game_source).lower() == "steam" and appid:
open_folder_button = self._make_action_button(
_("Open Folder"),
self.main_window.theme_manager.get_icon("search", as_path=True),
@@ -644,10 +456,7 @@ class DetailPageManager:
lambda: self._open_steam_game_folder(str(appid))
)
buttons_layout.addWidget(open_folder_button)
elif (
str(game_source).lower() not in {"gog", "egs"}
and not self._has_game_shortcut(game_name)
):
else:
add_button = self._make_action_button(
_("Add a shortcut"),
self.main_window.theme_manager.get_icon("addgame", as_path=True),
@@ -662,47 +471,6 @@ class DetailPageManager:
# Show settings button for PortProton games or Steam games using PortProtonQt
if str(game_source).lower() == "portproton":
self._add_portproton_buttons(buttons_layout, exec_line)
elif (
source in {"gog", "egs"}
and appid
and exec_line.startswith(f"{source}://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:
return buttons_layout
if source == "gog":
store_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,
)
)
buttons_layout.addWidget(settings_button)
log_button = self._make_action_button(
_("Create Log"),
self.main_window.theme_manager.get_icon("edit", as_path=True),
)
log_button.clicked.connect(
lambda: self.toggleDebugLog(store_exe, log_button)
)
buttons_layout.addWidget(log_button)
self._debug_log_button = log_button
folder_button = self._make_action_button(
_("Open Folder"),
self.main_window.theme_manager.get_icon("search", as_path=True),
)
folder_button.clicked.connect(
lambda: self._open_executable_folder(store_exe)
)
buttons_layout.addWidget(folder_button)
elif str(game_source).lower() == "steam" and appid:
try:
compat_tool = get_steam_compat_tool(int(appid))
@@ -728,7 +496,6 @@ class DetailPageManager:
icon = self.main_window.theme_manager.get_icon("play", as_path=True)
play_button = self._make_action_button(text, icon)
play_button.setProperty("sound_event", False)
play_button.clicked.connect(lambda: self.main_window.toggleGame(exec_line, play_button))
return play_button
@@ -808,13 +575,9 @@ class DetailPageManager:
def _open_portproton_game_folder(self, exec_line: str) -> None:
"""Open folder containing the PortProton game executable."""
file_to_check = self._get_file_from_exec(exec_line)
self._open_executable_folder(file_to_check)
def _open_executable_folder(self, exe_path: str | None) -> None:
"""Open the folder containing an executable."""
if not exe_path or not os.path.exists(exe_path):
if not file_to_check or not os.path.exists(file_to_check):
return
folder_path = os.path.dirname(os.path.abspath(exe_path))
folder_path = os.path.dirname(os.path.abspath(file_to_check))
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
def _has_game_shortcut(self, game_name: str) -> bool:
@@ -913,39 +676,12 @@ class DetailPageManager:
source_kind, source_data = source
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:
self.main_window.current_running_button = play_button
self.main_window._set_running_button_stop()
def _finalize_detail_page(
self,
@@ -980,16 +716,11 @@ class DetailPageManager:
main_layout.addStretch()
self.main_window.current_exec_line = page_data["exec_line"]
cover_path = (
None
if detail_page.property("fallbackExe")
else page_data["cover_path"]
)
self._setup_detail_page_animation(
detail_page,
page_data["image_label"],
detail_page,
cover_path,
page_data["cover_path"],
)
def _populate_compact_game_layout(
@@ -1090,8 +821,8 @@ class DetailPageManager:
return None
def _is_action_button_text(self, text: str) -> bool:
"""Check if button text is a primary game action."""
return text in (_("Start"), _("Stop"), _("Install"))
"""Check if button text is Play or Stop."""
return text in (_("Start"), _("Stop"))
def _on_favorite_click(self, name: str) -> str:
"""Handle favorite toggle click."""
@@ -1318,7 +1049,7 @@ class DetailPageManager:
self.main_window.currentDetailPage = None
self._current_detail_page = None
def _game_tuple_to_data(self, game_tuple: Sequence[object]) -> dict:
def _game_tuple_to_data(self, game_tuple: tuple) -> dict:
return {
"name": game_tuple[0],
"description": game_tuple[1],
@@ -1335,7 +1066,6 @@ class DetailPageManager:
"anticheat_slug": game_tuple[13] if len(game_tuple) > 13 else "",
"ppdb_id": game_tuple[14] if len(game_tuple) > 14 else "",
"ppdb_rating": game_tuple[15] if len(game_tuple) > 15 else "",
"protondb_appid": game_tuple[16] if len(game_tuple) > 16 else game_tuple[3],
}
def _finalize_autoinstall_page(
@@ -1605,7 +1335,7 @@ class DetailPageManager:
if card is None or not card.isVisible():
return
self._clear_current_card_state(card)
card.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
card.setFocus(Qt.FocusReason.OtherFocusReason)
self.main_window.current_focused_card = card
card.update()
@@ -1630,7 +1360,7 @@ class DetailPageManager:
if not cards:
return
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
cards[0].setFocus(Qt.FocusReason.ActiveWindowFocusReason)
cards[0].setFocus(Qt.FocusReason.OtherFocusReason)
scroll_area = container.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
+31 -123
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
@@ -16,12 +16,10 @@ from portprotonqt.config import (
parse_desktop_entry,
)
from portprotonqt.logger import get_logger
from portprotonqt.animations.detail_background import DetailBackgroundAnimations
logger = get_logger(__name__)
_pixmap_relays: WeakKeyDictionary[QWidget, "_PixmapReadyRelay"] = WeakKeyDictionary()
_cover_reveal_animations: WeakKeyDictionary[QLabel, QPropertyAnimation] = WeakKeyDictionary()
_detail_backgrounds = DetailBackgroundAnimations()
class _PixmapReadyRelay(QObject):
@@ -66,7 +64,6 @@ def setup_image_loading(
cover_width,
cover_height,
lambda pixmap: relay.pixmap_ready.emit(pixmap),
app_name=str(detail_page.property("coverCacheKey") or ""),
fallback_exe=fallback_exe,
fallback_icon_path=fallback_icon_path,
)
@@ -85,7 +82,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 +96,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 +115,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 +126,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:
@@ -231,7 +169,7 @@ def _resolve_gradient_stops(theme, dark_palette: list) -> str:
- list of float: palette colors at specified positions
- list of dict/tuple/str: custom stops with position+color
"""
gradient_stops = _resolve_gradient_config(theme).get("stops")
gradient_stops = getattr(theme, "DETAIL_PAGE_GRADIENT", None)
if gradient_stops is None:
return _build_palette_stops(dark_palette)
if isinstance(gradient_stops, str):
@@ -255,47 +193,21 @@ def _resolve_gradient_stops(theme, dark_palette: list) -> str:
return ",\n".join(stop_parts)
def _resolve_gradient_config(theme: object) -> dict:
"""Resolve nested gradient settings with legacy theme compatibility."""
backgrounds = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
config = dict(backgrounds.get("gradient", {})) if isinstance(backgrounds, dict) else {}
legacy_names = {
"stops": "DETAIL_PAGE_GRADIENT",
"type": "DETAIL_PAGE_GRADIENT_TYPE",
"x1": "DETAIL_PAGE_GRADIENT_X1",
"y1": "DETAIL_PAGE_GRADIENT_Y1",
"x2": "DETAIL_PAGE_GRADIENT_X2",
"y2": "DETAIL_PAGE_GRADIENT_Y2",
"cx": "DETAIL_PAGE_GRADIENT_CX",
"cy": "DETAIL_PAGE_GRADIENT_CY",
"radius": "DETAIL_PAGE_GRADIENT_RADIUS",
"fx": "DETAIL_PAGE_GRADIENT_FX",
"fy": "DETAIL_PAGE_GRADIENT_FY",
}
for key, legacy_name in legacy_names.items():
if hasattr(theme, legacy_name):
config[key] = getattr(theme, legacy_name)
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_type = gradient_config.get("type", "linear")
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
gradient_type = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_TYPE', 'linear')
if gradient_type == 'radial':
cx = gradient_config.get("cx", 0.5)
cy = gradient_config.get("cy", 0.5)
radius = gradient_config.get("radius", 0.5)
fx = gradient_config.get("fx", 0.5)
fy = gradient_config.get("fy", 0.5)
cx = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_CX', 0.5)
cy = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_CY', 0.5)
radius = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_RADIUS', 0.5)
fx = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_FX', 0.5)
fy = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_FY', 0.5)
stylesheet = f"""
QWidget {{
@@ -306,10 +218,10 @@ def _apply_palette_stylesheet(
}}
"""
else:
x1 = gradient_config.get("x1", 0)
y1 = gradient_config.get("y1", 0)
x2 = gradient_config.get("x2", 1)
y2 = gradient_config.get("y2", 1)
x1 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_X1', 0)
y1 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_Y1', 0)
x2 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_X2', 1)
y2 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_Y2', 1)
stylesheet = f"""
QWidget {{
@@ -320,7 +232,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:
@@ -334,19 +246,11 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
"""Setup wave background overlay based on theme DETAIL_PAGE_BG_MODE."""
bg_mode = getattr(theme, "DETAIL_PAGE_BG_MODE", "gradient")
if _detail_backgrounds.setup(detail_page, dark_palette, theme):
_remove_wave_background(detail_page)
return
_detail_backgrounds.remove(detail_page)
if bg_mode not in {"waves", "static_waves"}:
if bg_mode == "gradient":
_remove_wave_background(detail_page)
return
backgrounds = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
wave_config = backgrounds.get("waves", {}) if isinstance(backgrounds, dict) else {}
if hasattr(theme, "DETAIL_PAGE_WAVES"):
wave_config = theme.DETAIL_PAGE_WAVES
wave_config = getattr(theme, "DETAIL_PAGE_WAVES", {})
state = _wave_states.get(detail_page)
if state is None:
state = {
@@ -460,7 +364,6 @@ def _apply_no_cover_style(detail_page: QWidget, theme) -> None:
try:
detail_page.setStyleSheet(theme.DETAIL_PAGE_NO_COVER_STYLE)
_remove_wave_background(detail_page)
_detail_backgrounds.remove(detail_page)
detail_page.update()
except RuntimeError:
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
@@ -705,6 +608,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):
+26 -16
View File
@@ -72,7 +72,6 @@ def create_back_button(
back_button = AutoSizeButton(_("Back"), icon=theme_manager.get_icon("back", as_path=True))
back_button.setFixedWidth(100)
back_button.setStyleSheet(theme.ADDGAME_BACK_BUTTON_STYLE)
back_button.setProperty("sound_event", "back")
back_button.clicked.connect(on_click)
parent_layout.addWidget(back_button, alignment=Qt.AlignmentFlag.AlignLeft)
return back_button
@@ -84,7 +83,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,10 +138,10 @@ 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", "portproton") or theme_manager is None:
return
icon_name = f"badge_{source}"
icon_name = "badge_steam" if source == "steam" else "badge_portproton"
icon = theme_manager.get_icon(icon_name, as_path=True)
if not icon:
return
@@ -183,7 +181,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 +191,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 +200,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 +217,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 +322,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,
@@ -349,7 +365,7 @@ def create_portproton_badge(
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://linux-gaming.ru/game/{ppdb_id}"))
lambda: QDesktopServices.openUrl(QUrl(f"https://ppdb.linux-gaming.ru/game/{ppdb_id}"))
)
return badge
@@ -402,7 +418,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)
@@ -425,11 +440,7 @@ def create_details_widget(
_add_details_description(details_layout, main_window.theme, description)
if game_info_layout:
details_layout.addWidget(
create_compact_layout_panel(
parent, main_window.theme, game_info_layout
)
)
details_layout.addLayout(game_info_layout)
if controller_support:
_add_controller_support(details_layout, main_window.theme, controller_support)
@@ -445,7 +456,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)
+11 -18
View File
@@ -128,7 +128,7 @@ class GameLaunchDialog(DraggableDialog):
class AddGameDialog(DraggableDialog):
"""Dialog for adding or editing a game."""
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None, steam_appid=None):
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
super().__init__(parent)
from portprotonqt.context_menu_manager import CustomLineEdit
self.theme = theme if theme else theme_manager.apply_theme(ui_config.get_theme())
@@ -156,9 +156,11 @@ class AddGameDialog(DraggableDialog):
name_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
layout.addRow(name_label, self.nameEdit)
exe_label = QLabel(_("Path to Executable:"))
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.exeEdit = CustomLineEdit(self, theme=self.theme)
self.exeEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.exeEdit.setVisible(steam_appid is None)
if exe_path:
self.exeEdit.setText(exe_path)
@@ -166,15 +168,12 @@ class AddGameDialog(DraggableDialog):
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
exeBrowseButton.clicked.connect(self.browseExe)
exeBrowseButton.setObjectName("exeBrowseButton")
exeBrowseButton.setVisible(steam_appid is None)
if steam_appid is None:
exe_label = QLabel(_("Path to Executable:"))
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
layout.addRow(exe_label, self.exeEdit)
empty_label = QLabel("")
empty_label.setFixedWidth(exe_label.sizeHint().width())
layout.addRow(empty_label, exeBrowseButton)
layout.addRow(exe_label, self.exeEdit)
empty_label = QLabel("")
empty_label.setFixedWidth(exe_label.sizeHint().width())
layout.addRow(empty_label, exeBrowseButton)
cover_label = QLabel(_("Custom Cover:"))
cover_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
@@ -191,9 +190,7 @@ class AddGameDialog(DraggableDialog):
coverBrowseButton.setObjectName("coverBrowseButton")
layout.addRow(cover_label, self.coverEdit)
cover_button_label = QLabel("")
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
layout.addRow(cover_button_label, coverBrowseButton)
layout.addRow(empty_label, coverBrowseButton)
self.add_to_steam_checkbox = QCheckBox()
self.add_to_menu_checkbox = QCheckBox()
@@ -213,7 +210,6 @@ class AddGameDialog(DraggableDialog):
self.add_to_steam_checkbox.setIconSize(QSize(20, 20))
self.add_to_menu_checkbox.setIconSize(QSize(20, 20))
self.add_to_desktop_checkbox.setIconSize(QSize(20, 20))
self.add_to_steam_checkbox.setVisible(steam_appid is None)
options_layout = QHBoxLayout()
options_layout.setSpacing(12)
options_layout.addWidget(self.add_to_steam_checkbox)
@@ -222,12 +218,9 @@ class AddGameDialog(DraggableDialog):
options_layout.addStretch()
options_widget = QWidget(self)
options_widget.setLayout(options_layout)
options_widget.setVisible(steam_appid is None)
options_label = QLabel(_("Add shortcut to:"))
options_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
options_label.setVisible(steam_appid is None)
if steam_appid is None:
layout.addRow(options_label, options_widget)
layout.addRow(options_label, options_widget)
current_name = game_name.strip() if game_name else self.nameEdit.text().strip()
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
@@ -1,91 +0,0 @@
"""Compatibility report dialog."""
from __future__ import annotations
from typing import Any
from PySide6.QtCore import QPoint, Qt
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPlainTextEdit,
QVBoxLayout,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.dialogs.base import DraggableDialog
from portprotonqt.localization import _
class CompatibilityReportDialog(DraggableDialog):
"""Display a readable compatibility report."""
def __init__(self, parent, theme, report: str) -> None:
super().__init__(parent)
metrics = theme.COMPATIBILITY_REPORT_DIALOG
self.setWindowTitle(_("Compatibility report"))
self.setModal(True)
self.setMinimumSize(metrics["minimum_width"], metrics["minimum_height"])
self.setStyleSheet(
theme.MAIN_WINDOW_STYLE + theme.COMPATIBILITY_REPORT_DIALOG_STYLE
)
layout = QVBoxLayout(self)
layout.setContentsMargins(*([metrics["margin"]] * 4))
layout.setSpacing(metrics["spacing"])
layout.addWidget(self._create_header())
report_view = QPlainTextEdit()
report_view.setObjectName("compatibilityReport")
report_view.setReadOnly(True)
report_view.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
report_view.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
report_view.setStyleSheet(theme.SCROLL_STYLE)
report_view.setPlainText(report)
report_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
report_view.customContextMenuRequested.connect(
lambda position: self._show_context_menu(report_view, theme, position)
)
layout.addWidget(report_view, stretch=1)
layout.addLayout(self._create_buttons(theme))
def _create_header(self) -> QFrame:
header = QFrame()
header.setObjectName("compatibilityHeader")
layout = QVBoxLayout(header)
title = QLabel(_("Compatibility report"))
title.setObjectName("compatibilityTitle")
summary = QLabel(
_("The program stopped. Compatibility details were collected automatically.")
)
summary.setObjectName("compatibilitySummary")
summary.setWordWrap(True)
layout.addWidget(title)
layout.addWidget(summary)
return header
def _create_buttons(self, theme) -> QHBoxLayout:
layout = QHBoxLayout()
layout.addStretch()
close_button = AutoSizeButton(_("Close"))
close_button.setStyleSheet(theme.ACTION_BUTTON_ACTIVE_STYLE)
close_button.clicked.connect(self.accept)
close_button.setDefault(True)
layout.addWidget(close_button)
return layout
def _show_context_menu(
self, report_view: QPlainTextEdit, theme: Any, position: QPoint
) -> None:
menu = report_view.createStandardContextMenu()
menu.setWindowFlags(
menu.windowFlags()
| Qt.WindowType.Popup
| Qt.WindowType.FramelessWindowHint
)
menu.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
menu.setStyleSheet(theme.CONTEXT_MENU_STYLE)
menu.exec(report_view.viewport().mapToGlobal(position))
+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)
+78 -236
View File
@@ -1,8 +1,6 @@
"""Proton Manager dialog for PortProtonQt."""
import os
import platform
import re
import shutil
import tempfile
import urllib.parse
@@ -12,9 +10,8 @@ from PySide6.QtWidgets import (
QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView,
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
@@ -23,9 +20,7 @@ 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,42 +28,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')
def sort_wine_entries(entries: list[dict], source_name: str) -> list[dict]:
"""Sort Wine entries, alternating CachyOS and WineLand releases."""
if source_name.lower() != 'proton_cachyos':
return sorted(entries, key=version_sort_key)
release_groups = []
for is_wineland in (False, True):
family_entries = sorted(
(
entry for entry in entries
if ('proton-cachyos-wineland-' in entry.get('name', '').lower())
== is_wineland
),
key=version_sort_key,
)
groups = []
for entry in family_entries:
release_name = re.sub(
r'-(?:x86_64|arm64|aarch64)(?:_v\d+|-wow64)?$',
'', entry.get('name', '').lower(),
)
if not groups or groups[-1][0] != release_name:
groups.append((release_name, []))
groups[-1][1].append(entry)
release_groups.append(groups)
sorted_entries = []
for index in range(max(map(len, release_groups), default=0)):
for groups in release_groups:
if index < len(groups):
sorted_entries.extend(groups[index][1])
return sorted_entries
class ProtonManager(DraggableDialog):
@@ -87,8 +46,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()
@@ -104,48 +61,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()
@@ -297,45 +212,34 @@ class ProtonManager(DraggableDialog):
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 = QPushButton(_('Open Folder'))
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 = 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)
@@ -344,47 +248,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, sort_wine_entries(filtered_entries, source_name)))
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}")
@@ -417,15 +283,6 @@ class ProtonManager(DraggableDialog):
return True
def filter_entries_by_cpu_level(self, entries, source_name):
is_arm = platform.machine().lower().startswith(('arm', 'aarch64'))
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:
@@ -474,6 +331,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', '')
@@ -483,29 +346,19 @@ class ProtonManager(DraggableDialog):
if url_filename:
entry['filename'] = url_filename
all_entries.append(entry)
all_entries = sort_wine_entries(all_entries, source_name)
table = self._create_empty_wine_tab(source_name, len(all_entries))
all_entries.sort(key=version_sort_key)
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"
@@ -643,24 +496,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
@@ -717,7 +552,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()):
@@ -731,6 +567,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
@@ -1082,20 +942,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']}"
@@ -1166,13 +1016,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):
@@ -1182,13 +1031,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):
@@ -1202,25 +1050,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)
@@ -1298,11 +1144,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
+8 -12
View File
@@ -211,11 +211,7 @@ class ExeSettingsDialog(
if self.input_manager:
self.input_manager.enable_settings_mode(self)
self.input_manager.connect_surface_signal(
'settings_dialog',
self.input_manager.gamepad_hotplug,
self._update_vkbasalt_toggle_key_visibility,
)
self.input_manager.gamepad_hotplug.connect(self._update_vkbasalt_toggle_key_visibility)
self.hints_widget, self.hints_labels = create_dialog_hints_widget(
self.theme, self.main_window, self.input_manager, context='settings'
@@ -223,12 +219,12 @@ class ExeSettingsDialog(
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('settings_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)
self.init_virtual_keyboard()
@@ -443,7 +439,7 @@ class ExeSettingsDialog(
button_layout = QHBoxLayout()
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply", as_path=True))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel", as_path=True))
self.open_ppdb_button = AutoSizeButton(_("Edit PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
self.open_ppdb_button = AutoSizeButton(_("Open PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
self.clear_ppdb_button = AutoSizeButton(_("Clear PPDB"), icon=ThemeManager().get_icon("delete", as_path=True))
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
+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)
+9 -70
View File
@@ -385,7 +385,6 @@ 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
@@ -568,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)
@@ -1232,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)
@@ -1275,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."""
-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.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)
+6 -6
View File
@@ -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):
-462
View File
@@ -1,462 +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" / "egs"
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.data_dir / "bin/legendary.version"
self.games_dir = Path.home() / "Games"
def get_legendary_path(self) -> str | None:
"""Return an available Legendary executable."""
bundled = self.data_dir / "bin/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.data_dir / "bin/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.data_dir / "bin/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", {})
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", "")),
"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))
+143 -370
View File
@@ -1,7 +1,6 @@
import os
import re
import shlex
from typing import Any
from PySide6.QtGui import (
QPainter,
@@ -13,14 +12,9 @@ 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,
QGraphicsDropShadowEffect,
QGraphicsOpacityEffect,
@@ -29,7 +23,6 @@ from PySide6.QtWidgets import (
QWidget,
QStackedLayout,
QLabel,
QSizePolicy,
)
from portprotonqt.image_utils import (
cleanup_animated_cover,
@@ -49,7 +42,6 @@ from portprotonqt.config import (
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.animations import GameCardAnimations
from portprotonqt.icon_extractor import get_exe_icon_cache_path
PROTONDB_TIERS = ("platinum", "gold", "silver", "bronze", "borked", "pending")
@@ -205,123 +197,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)
self.animations.paint_border(QPainter(self))
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.pos()):
self.clicked.emit()
super().mouseReleaseEvent(event)
def click(self) -> None:
self.clicked.emit()
class GameCard(AnimatedCard):
editShortcutRequested = Signal(str, str, str)
deleteGameRequested = Signal(str, str)
addToMenuRequested = Signal(str, str)
@@ -331,12 +211,14 @@ 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,
last_launch, formatted_playtime, protondb_tier, anticheat_status,
last_launch_ts, playtime_seconds, game_source, anticheat_slug="",
ppdb_id="", ppdb_rating="", protondb_appid="", *, select_callback, theme=None,
ppdb_id="", ppdb_rating="", *, select_callback, theme=None,
card_width=250, parent=None, context_menu_manager=None
):
super().__init__(parent)
@@ -353,7 +235,6 @@ class GameCard(AnimatedCard):
self.anticheat_slug = anticheat_slug or ""
self.ppdb_id = ppdb_id or ""
self.ppdb_rating = ppdb_rating or ""
self.protondb_appid = protondb_appid or appid
self.game_source = game_source
self.autoinstall_exe_name = ""
self.last_launch_ts = last_launch_ts
@@ -373,46 +254,27 @@ 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.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 = 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.base_extra_margin = 8 if self.list_layout else 20
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.animations = GameCardAnimations(self, self.theme)
self.animations.setup_animations()
self.shadow = QGraphicsDropShadowEffect(self)
self.shadow.setBlurRadius(self.theme.shadow_blur_radius)
@@ -422,15 +284,14 @@ class GameCard(AnimatedCard):
if self.list_layout:
self.layout_ = QHBoxLayout(self)
self.layout_.setSpacing(self.card_layout_cfg.get("spacing", 12))
self.layout_.setSpacing(12)
else:
self.layout_ = QVBoxLayout(self)
self.layout_.setSpacing(self.card_layout_cfg.get("spacing", 5))
self.layout_.setSpacing(5)
self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
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)
@@ -439,10 +300,7 @@ class GameCard(AnimatedCard):
self.coverLabel = QLabel()
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/"))
):
if self.missing_executable_path:
self.coverOpacity = QGraphicsOpacityEffect(self.coverLabel)
self.coverOpacity.setOpacity(self.theme.missing_exe_cover_opacity)
self.coverLabel.setGraphicsEffect(self.coverOpacity)
@@ -479,23 +337,9 @@ class GameCard(AnimatedCard):
config=corner_config,
parent=self.coverWidget,
)
self.steamLabel.setVisible(self.steam_visible and not self.vertical_layout)
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
self.gogLabel = SourceCorner(
icon=gog_icon,
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.steamLabel.setVisible(self.steam_visible)
if self.economy_mode:
self.steamLabel.setVisible(False)
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
self.portprotonLabel = SourceCorner(
@@ -503,9 +347,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(
@@ -551,34 +395,9 @@ class GameCard(AnimatedCard):
self.nameLabel.setContentsMargins(0, 0, 10, 0)
else:
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
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.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
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()
@@ -595,76 +414,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 = getattr(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)
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 on_cover_loaded(self, pixmap):
self.animated_cover_path = ""
self.base_pixmap = pixmap
@@ -672,13 +421,11 @@ class GameCard(AnimatedCard):
def _load_cover_image(self, cover_path: str) -> None:
if self.list_layout:
cover_size = self.card_layout_cfg.get("cover_load_size", 64)
width = cover_size
height = cover_size
width = 64
height = 64
else:
width = self.base_card_width
layout_config = getattr(self, "card_layout_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()
@@ -687,7 +434,6 @@ class GameCard(AnimatedCard):
width,
height,
self.on_cover_loaded,
app_name=str(self.appid or ""),
fallback_exe=fallback_exe,
fallback_icon_path=fallback_icon_path,
)
@@ -698,12 +444,23 @@ class GameCard(AnimatedCard):
return "", ""
if not exe_path.lower().endswith(".exe"):
return "", ""
return exe_path, get_exe_icon_cache_path(exe_path)
xdg_cache_home = os.getenv(
"XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache"),
)
icon_name = os.path.basename(self.name) or os.path.splitext(os.path.basename(exe_path))[0]
icon_path = os.path.join(
xdg_cache_home,
"PortProtonQt",
"images",
f"{icon_name}.png",
)
return exe_path, icon_path
def _set_animated_cover(self, cover_path: str, width: int, height: int) -> bool:
if not cover_path or not os.path.isfile(cover_path):
return False
radius = self.card_layout_cfg.get("cover_radius", 8 if self.list_layout else 15)
radius = 8 if self.list_layout else 15
if not set_animated_cover(self.coverLabel, cover_path, width, height, radius):
return False
self.animated_cover_path = cover_path
@@ -711,8 +468,7 @@ class GameCard(AnimatedCard):
return True
def _update_animated_cover_size(self) -> None:
default_radius = 8 if self.list_layout else 15
radius = int(self.card_layout_cfg.get("cover_radius", default_radius) * self._scale)
radius = max(8, int(10 * self._scale)) if self.list_layout else int(15 * self._scale)
update_animated_cover_size(
self.coverLabel,
self.coverLabel.width(),
@@ -747,13 +503,12 @@ class GameCard(AnimatedCard):
if self.list_layout:
target_width = self.coverLabel.width() if self.coverLabel.width() > 0 else 56
target_height = self.coverLabel.height() if self.coverLabel.height() > 0 else 56
radius = int(self.card_layout_cfg.get("cover_radius", 8) * self._scale)
radius = max(8, int(10 * self._scale))
aspect_mode = Qt.AspectRatioMode.KeepAspectRatio
else:
target_width = int(self.base_card_width * self._scale)
cover_ratio = self.card_layout_cfg.get("cover_aspect_ratio", 1.5)
target_height = int(target_width * cover_ratio)
radius = int(self.card_layout_cfg.get("cover_radius", 15) * self._scale)
target_height = int(target_width * 1.5)
radius = int(15 * self._scale)
aspect_mode = Qt.AspectRatioMode.KeepAspectRatioByExpanding
scaled_pixmap = self.base_pixmap.scaled(
target_width,
@@ -821,14 +576,12 @@ 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.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
@@ -845,8 +598,6 @@ class GameCard(AnimatedCard):
self.ppdbLabel.raise_()
self.protondbLabel.raise_()
self.portprotonLabel.raise_()
self.gogLabel.raise_()
self.egsLabel.raise_()
self.steamLabel.raise_()
except RuntimeError:
pass
@@ -860,39 +611,20 @@ class GameCard(AnimatedCard):
scaled_extra = int(self.base_extra_margin * self._scale)
self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
if self.list_layout:
row_height = max(
self.card_layout_cfg.get("min_row_height", 68),
int(self.card_layout_cfg.get("row_height", 72) * self._scale),
)
icon_size = max(
self.card_layout_cfg.get("min_cover_size", 48),
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)
row_height = max(68, int(72 * self._scale))
icon_size = max(48, int(56 * self._scale))
margin_left = 10
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.coverWidget.setContentsMargins(10, 0, 0, 0)
self.coverLabel.setFixedSize(icon_size, icon_size)
else:
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_layout_cfg.get("card_height_ratio", 1.8)
)
height_ratio = self.theme.COMPACT_CARD["height_ratio"] if small_card_mode else 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_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)
self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
self.update_cover_pixmap()
@@ -953,7 +685,7 @@ class GameCard(AnimatedCard):
pass
try:
self.shadow.setBlurRadius(int(self.theme.shadow_blur_radius * self._scale))
self.shadow.setBlurRadius(int(20 * self._scale))
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
@@ -991,24 +723,17 @@ 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.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.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)
@@ -1141,10 +866,10 @@ class GameCard(AnimatedCard):
def open_ppdb_page(self):
if self.ppdb_id:
QDesktopServices.openUrl(QUrl(f"https://linux-gaming.ru/game/{self.ppdb_id}"))
QDesktopServices.openUrl(QUrl(f"https://ppdb.linux-gaming.ru/game/{self.ppdb_id}"))
def open_protondb_report(self):
url = QUrl(f"https://www.protondb.com/app/{self.protondb_appid}")
url = QUrl(f"https://www.protondb.com/app/{self.appid}")
QDesktopServices.openUrl(url)
def open_weanticheatyet_page(self):
@@ -1198,6 +923,58 @@ 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.animations.handle_enter_event()
super().enterEvent(event)
def leaveEvent(self, event):
self.animations.handle_leave_event()
super().leaveEvent(event)
def focusInEvent(self, event):
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)
@@ -1206,31 +983,27 @@ class GameCard(AnimatedCard):
self.set_animated_cover_paused(False)
super().showEvent(event)
def click(self) -> None:
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,
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
}
self.select_callback(game_data)
super().mousePressEvent(event)
def cleanup(self):
+21 -281
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,40 +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 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, 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,15 +48,8 @@ class GameLibraryManager:
self.card_width = ui_config.get_card_width()
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._incremental_add_timer: QTimer | None = None
@@ -102,58 +69,23 @@ 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)
if isinstance(library_background, dict):
stack_layout = QGridLayout(self.gamesLibraryWidget)
stack_layout.setContentsMargins(*library_background["margins"])
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)
else:
layout = QVBoxLayout(self.gamesLibraryWidget)
self.libraryContentLayout = layout
layout = QVBoxLayout(self.gamesLibraryWidget)
layout.setSpacing(15)
# Search widget
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
layout.addWidget(searchWidget)
if self.layout_mode == "vertical":
self.libraryHeaderWidget = self._create_library_header()
layout.addWidget(self.libraryHeaderWidget)
# 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 = self.theme.GAME_CARD_HORIZONTAL
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
elif self.layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
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)
@@ -174,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()
@@ -191,125 +123,30 @@ class GameLibraryManager:
# Connect scroll event for lazy loading
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
scrollArea.horizontalScrollBar().valueChanged.connect(self.load_visible_images)
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"])
labels = (_("Game Title"), _("LAST LAUNCH"), _("TIME SPENT"), _("Library"))
for text, stretch in zip(labels, config["column_stretches"], strict=True):
label = QLabel(text)
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
label.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
label.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
header_layout.addWidget(label, stretch)
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 = self.theme.GAME_CARD_HORIZONTAL
self.gamesListLayout = QHBoxLayout()
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
elif layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout()
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.ScrollBarAsNeeded
)
self.gamesScrollArea.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
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
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()
@@ -362,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
@@ -373,29 +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
pixmap = card.coverLabel.pixmap()
if pixmap is None or pixmap.isNull():
return
library_config = self.theme.LIBRARY_BACKGROUND
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(
self.libraryBackgroundLabel, 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
@@ -433,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
@@ -458,14 +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 = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
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
@@ -480,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
@@ -577,7 +386,6 @@ class GameLibraryManager:
if added_new_card:
self.load_visible_images()
self._sync_full_library_tile()
self.force_update_cards_library()
self._cancel_incremental_add()
self._schedule_focus_first_card()
@@ -596,9 +404,7 @@ 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()
@@ -627,13 +433,6 @@ 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}
@@ -726,68 +525,8 @@ class GameLibraryManager:
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
@@ -801,7 +540,7 @@ class GameLibraryManager:
for card in self.gamesListWidget.findChildren(GameCard):
if card.isVisible() and card.isEnabled():
self._focus_first_card_after_update = False
card.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
card.setFocus(Qt.FocusReason.OtherFocusReason)
if self.gamesScrollArea is not None:
self.gamesScrollArea.ensureWidgetVisible(card, 50, 50)
return
@@ -971,6 +710,7 @@ class GameLibraryManager:
build_search_items(
games,
lambda game: game[0],
lambda game: game[1],
)
)
-720
View File
@@ -1,720 +0,0 @@
"""GOG authentication, library and gogdl process helpers."""
import hashlib
import os
import platform
import re
import shutil
import subprocess
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import orjson
import requests
from portprotonqt.localization import get_metadata_language
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
GOG_LOGIN_URL = (
"https://auth.gog.com/auth?client_id=46899977096215655&"
"redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&"
"response_type=code&layout=galaxy"
)
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_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",
"fr": "fr-FR", "hu": "hu-HU", "it": "it-IT", "ja": "ja-JP",
"ko": "ko-KR", "nl": "nl-NL", "no": "no-NO", "pl": "pl-PL",
"pt": "pt-BR", "ro": "ro-RO", "ru": "ru-RU", "sv": "sv-SE",
"th": "th-TH", "tr": "tr-TR", "uk": "uk-UA", "zh": "zh-Hans",
"zh_hant": "zh-Hant",
}
class GOGAPI:
"""Provide the local GOG backend used by the Qt UI."""
def __init__(self) -> None:
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
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.data_dir / "bin/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"
self.games_dir = Path.home() / "Games"
def get_gogdl_path(self) -> str | None:
"""Return an available gogdl executable."""
bundled = self.data_dir / "bin/gogdl"
if bundled.is_file() and os.access(bundled, os.X_OK):
return str(bundled)
return shutil.which("gogdl")
def build_command(self, arguments: list[str]) -> list[str]:
"""Build a gogdl command with private configuration paths."""
gogdl = self.get_gogdl_path()
if not gogdl:
raise FileNotFoundError("gogdl executable not found")
self.data_dir.mkdir(parents=True, exist_ok=True)
self.data_dir.chmod(0o700)
self.config_dir.mkdir(parents=True, exist_ok=True)
return [gogdl, "--auth-config-path", str(self.auth_path), *arguments]
def ensure_gogdl(self) -> str:
"""Download and verify the latest official Heroic gogdl binary."""
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.data_dir / "bin/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),
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
def _download_gogdl_asset(self, asset: dict) -> str:
target = self.data_dir / "bin/gogdl"
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("gogdl checksum verification failed")
temporary.chmod(0o755)
temporary.replace(target)
return str(target)
except Exception:
temporary.unlink(missing_ok=True)
raise
def get_environment(self) -> dict[str, str]:
"""Return the environment required by gogdl."""
environment = os.environ.copy()
environment["GOGDL_CONFIG_PATH"] = str(self.config_dir)
return environment
def authenticate(self, code: str) -> tuple[bool, str]:
"""Exchange an OAuth code through gogdl."""
self.ensure_gogdl()
result = subprocess.run(
self.build_command(["auth", "--code", code]),
env=self.get_environment(), capture_output=True, check=False,
timeout=GOGDL_AUTH_TIMEOUT,
)
if result.returncode != 0:
error = result.stderr.decode(errors="replace").strip()
logger.error("gogdl authentication failed: %s", error)
return False, error or f"gogdl exited with code {result.returncode}"
try:
data = orjson.loads(result.stdout)
except orjson.JSONDecodeError:
return False, "gogdl returned an invalid authentication response"
if not isinstance(data, dict) or data.get("error"):
return False, "GOG rejected the authorization code"
self._cache_account(data)
if self.auth_path.is_file():
self.auth_path.chmod(0o600)
return True, ""
def get_credentials(self) -> dict:
"""Return refreshed GOG credentials through gogdl."""
try:
self.ensure_gogdl()
result = subprocess.run(
self.build_command(["auth"]), env=self.get_environment(),
capture_output=True, check=False,
)
data = orjson.loads(result.stdout) if result.returncode == 0 else {}
return data if isinstance(data, dict) else {}
except (FileNotFoundError, orjson.JSONDecodeError):
return {}
def is_authenticated(self) -> bool:
"""Return whether gogdl provides usable account credentials."""
credentials = self.get_credentials()
return bool(credentials.get("access_token") and credentials.get("user_id"))
def get_account_name(self) -> str:
"""Return cached GOG account name or user ID."""
account = self._load_json(self.account_path, {})
if isinstance(account, dict) and account.get("username"):
return str(account["username"])
credentials = self._load_json(self.auth_path, {})
if isinstance(credentials, dict) and credentials.get("user_id"):
return str(credentials["user_id"])
return ""
def refresh_account_name(self) -> str:
"""Fetch and cache the current GOG account name."""
return self._cache_account(self.get_credentials())
def _cache_account(self, credentials: dict) -> str:
"""Fetch GOG account data for credentials and return its username."""
user_id = credentials.get("user_id")
access_token = credentials.get("access_token")
if not user_id or not access_token:
return ""
try:
response = requests.get(
f"https://users.gog.com/users/{user_id}",
headers={"Authorization": f"Bearer {access_token}"},
timeout=GOG_USER_TIMEOUT,
)
response.raise_for_status()
account = response.json()
except requests.RequestException as error:
logger.warning("Failed to load GOG account name: %s", error)
return ""
if not isinstance(account, dict) or not account.get("username"):
return ""
self._save_json(self.account_path, account)
return str(account["username"])
def logout(self) -> bool:
"""Remove local GOG credentials and account library cache."""
try:
self.auth_path.unlink(missing_ok=True)
except OSError as error:
logger.error("Failed to remove GOG credentials: %s", error)
return False
try:
self.account_path.unlink(missing_ok=True)
except OSError as error:
logger.warning("Failed to remove cached GOG account name: %s", error)
self.clear_library_cache()
logger.info("GOG account disconnected")
return True
def refresh_library(
self, progress_callback: Callable[[int, int], None] | None = None
) -> list[dict]:
"""Fetch and cache the user's GOG library."""
credentials = self.get_credentials()
token = credentials.get("access_token")
user_id = credentials.get("user_id")
if not token or not user_id:
return []
entries = self._get_library_entries(str(user_id), str(token))
gog_entries = [entry for entry in entries if entry.get("platform_id") == "gog"]
games = self._get_games_parallel(gog_entries, str(token), progress_callback)
self._save_json(self.library_path, games)
return games
def _get_games_parallel(
self, entries: list[dict], token: str,
progress_callback: Callable[[int, int], None] | None,
) -> list[dict]:
cached = {str(game.get("app_id")): game for game in self.load_library()}
games = []
worker_count = min(GOG_METADATA_WORKERS, os.cpu_count() or 1)
with ThreadPoolExecutor(max_workers=worker_count) as executor:
futures = {executor.submit(self._get_game, entry, token): entry for entry in entries}
for completed, future in enumerate(as_completed(futures), 1):
entry = futures[future]
app_id = str(entry.get("external_id", ""))
try:
game = future.result()
except requests.RequestException as error:
logger.warning("Failed to load GOG metadata for %s: %s", app_id, error)
game = cached.get(app_id, {})
if game:
game.setdefault("steam_appid", "")
games.append(game)
if progress_callback:
progress_callback(completed, len(entries))
return games
def load_library(self) -> list[dict]:
"""Load the cached GOG library."""
data = self._load_json(self.library_path, [])
return data if isinstance(data, list) else []
def clear_library_cache(self) -> None:
"""Remove cached GOG covers and descriptions."""
try:
self.library_path.unlink(missing_ok=True)
except OSError as error:
logger.warning("Failed to clear GOG library cache: %s", error)
def load_installed(self) -> dict[str, dict]:
"""Load installed GOG game records."""
data = self._load_json(self.installed_path, {})
return data if isinstance(data, dict) else {}
def save_installed_game(self, app_id: str, game: dict) -> None:
"""Persist one installed GOG game record."""
installed = self.load_installed()
installed[app_id] = game
self._save_json(self.installed_path, installed)
def remove_installed_game(self, app_id: str) -> None:
"""Remove one installed GOG game record."""
installed = self.load_installed()
installed.pop(app_id, None)
self._save_json(self.installed_path, installed)
def get_cached_download_sizes(self, app_id: str) -> tuple[int, int] | None:
"""Return cached download and disk sizes for a game."""
cache = self._load_json(self.sizes_path, {})
if not isinstance(cache, dict):
return None
cached = cache.get(app_id)
if not isinstance(cached, list) or len(cached) != 2:
return None
return int(cached[0]), int(cached[1])
def save_download_sizes(self, app_id: str, sizes: tuple[int, int]) -> None:
"""Cache download and disk sizes for a game."""
cached = self._load_json(self.sizes_path, {})
if not isinstance(cached, dict):
cached = {}
cached[app_id] = list(sizes)
self._save_json(self.sizes_path, cached)
def is_game_installed(
self, app_id: str, installed_games: dict[str, dict] | None = None
) -> bool:
"""Return whether a complete gogdl installation is available."""
return self.get_installed_path(app_id, installed_games) is not None
def get_installed_path(
self, app_id: str, installed_games: dict[str, dict] | None = None
) -> Path | None:
"""Return a recorded or discoverable gogdl installation path."""
records = installed_games if installed_games is not None else self.load_installed()
installed = records.get(app_id, {})
install_path = Path(str(installed.get("install_path", "")))
if (install_path / f"goggame-{app_id}.info").is_file():
return install_path
return self.find_install_path(app_id, self.games_dir)
def get_install_path(self, app_id: str, title: str) -> Path:
"""Return the default parent directory for GOG installations."""
return self.games_dir
def find_install_path(self, app_id: str, parent: Path) -> Path | None:
"""Find the directory created by gogdl for an installed game."""
metadata_name = f"goggame-{app_id}.info"
if (parent / metadata_name).is_file():
return parent
try:
return next(
(path.parent for path in parent.glob(f"*/{metadata_name}")),
None,
)
except OSError as error:
logger.warning("Failed to inspect GOG installation path %s: %s", parent, error)
return None
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)
if primary is None or install_path is None:
return None
target = install_path
parts = str(primary.get("path", "")).replace("\\", "/").split("/")
for part in parts:
exact_path = target / part
if exact_path.exists():
target = exact_path
continue
try:
target = next(
child for child in target.iterdir()
if child.name.casefold() == part.casefold()
)
except (OSError, StopIteration):
return None
return str(target) if target.is_file() else None
def ensure_launch_parameters(self, app_id: str) -> None:
"""Add GOG task arguments to the executable PPDB when absent."""
primary, install_path = self._get_primary_task(app_id)
target = self.get_launch_target(app_id)
if primary is None or install_path is None or not target:
return
arguments = str(primary.get("arguments", "")).replace("\r", " ").replace("\n", " ").strip()
if not arguments:
return
ppdb_path = Path(f"{target}.ppdb")
try:
current = ppdb_path.read_text(encoding="utf-8") if ppdb_path.exists() else ""
base_value = arguments.replace('"', "").replace("\\", "/")
safe_value = base_value.replace("$", "\\$").replace("`", "\\`")
launch_line = f'export LAUNCH_PARAMETERS="{safe_value}"'
lines = current.splitlines()
existing = next(
(line for line in lines if line.startswith("export LAUNCH_PARAMETERS=")),
"",
)
if existing:
value = existing.removeprefix('export LAUNCH_PARAMETERS="').removesuffix('"')
comparable = re.sub(r"\\+", "/", value.replace('\\"', "").replace('"', ""))
if comparable != base_value:
return
lines[lines.index(existing)] = launch_line
ppdb_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
separator = "" if not current or current.endswith("\n") else "\n"
ppdb_path.write_text(
f"{current}{separator}{launch_line}\n",
encoding="utf-8",
)
except OSError as error:
logger.warning("Failed to add GOG launch parameters to %s: %s", ppdb_path, error)
def install_support(
self, app_id: str, start_command: list[str],
process_callback: Callable[[subprocess.Popen], None] | None = None,
) -> None:
"""Install GOG support instructions through the common ISI redist."""
manifest = self._load_json(
self.config_dir / "heroic_gogdl" / "manifests" / app_id, {}
)
install_path = self.get_installed_path(app_id)
if not isinstance(manifest, dict) or not manifest.get("scriptInterpreter"):
return
if install_path is None:
return
setup_marker = install_path / (
f".gogsetup-v{GOG_SETUP_MARKER_VERSION}-{app_id}"
)
if setup_marker.is_file():
return
redist_path = self.data_dir / "redist" / "gog"
interpreter = redist_path / "__redist" / "ISI" / "scriptinterpreter.exe"
if not interpreter.is_file():
result = subprocess.run(
self.build_command([
"redist", "--ids", "ISI", "--path", str(redist_path),
]),
env=self.get_environment(), capture_output=True, check=False,
timeout=GOG_SETUP_TIMEOUT,
)
if result.returncode != 0:
error = result.stderr.decode(errors="replace").strip()
raise OSError(error or "Failed to download GOG ISI redist")
if not interpreter.is_file():
raise FileNotFoundError(f"GOG script interpreter not found: {interpreter}")
support_path = self.config_dir / "heroic_gogdl" / "gog-support" / app_id
setup_path = self.data_dir / "setup" / app_id
setup_path.parent.mkdir(parents=True, exist_ok=True)
if setup_path.is_symlink() and setup_path.resolve() != install_path.resolve():
setup_path.unlink()
if not setup_path.exists():
setup_path.symlink_to(install_path, target_is_directory=True)
if not setup_path.is_symlink():
raise OSError(f"GOG setup path is not a symbolic link: {setup_path}")
arguments = self._get_support_arguments(
app_id, manifest, setup_path, support_path
)
target = self.get_launch_target(app_id)
if not target:
raise FileNotFoundError(f"GOG launch target not found: {app_id}")
launch_parameters = " ".join(arguments).replace("$", "\\$").replace("`", "\\`")
self._write_support_ppdb(interpreter, target, launch_parameters)
self._run_support_process(
[*start_command, str(interpreter)], install_path, process_callback
)
setup_marker.touch()
@staticmethod
def _run_support_process(
command: list[str], install_path: Path,
process_callback: Callable[[subprocess.Popen], None] | None,
) -> None:
"""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)
try:
return_code = process.wait(timeout=GOG_SETUP_TIMEOUT)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
raise
if return_code != 0:
raise subprocess.CalledProcessError(return_code, command)
@staticmethod
def _write_support_ppdb(
interpreter: Path, target: str, launch_parameters: str,
) -> None:
"""Inherit game settings while replacing support launch commands."""
target_ppdb = Path(f"{target}.ppdb")
ppdb_lines = (
target_ppdb.read_text(encoding="utf-8").splitlines()
if target_ppdb.is_file() else []
)
replaced_keys = (
"export LAUNCH_PARAMETERS=", "export PW_RUN_AFTER_EXE=",
"export FILE_SHA256SUM=",
)
inherited = [
line for line in ppdb_lines
if not line.startswith(replaced_keys)
]
inherited.extend([
f'export LAUNCH_PARAMETERS="{launch_parameters}"',
])
Path(f"{interpreter}.ppdb").write_text(
"\n".join(inherited) + "\n",
encoding="utf-8",
)
def needs_support_setup(self, app_id: str) -> bool:
"""Return whether GOG setup instructions still need to run."""
install_path = self.get_installed_path(app_id)
if install_path is None:
return False
manifest = self._load_json(
self.config_dir / "heroic_gogdl" / "manifests" / app_id, {}
)
marker = install_path / f".gogsetup-v{GOG_SETUP_MARKER_VERSION}-{app_id}"
return bool(
isinstance(manifest, dict)
and manifest.get("scriptInterpreter")
and not marker.is_file()
)
@staticmethod
def _get_support_arguments(
app_id: str, manifest: dict, install_path: Path, support_path: Path
) -> list[str]:
product = next(
(
item for item in manifest.get("products", [])
if str(item.get("productId", "")) == app_id
),
{},
)
product_id = str(product.get("productId", app_id))
wine_install_path = "Z:" + str(install_path).replace("/", "\\")
wine_support_path = "Z:" + str(support_path).replace("/", "\\")
return [
"/VERYSILENT", f"/DIR={wine_install_path}", "/Language=English",
"/LANG=English", f"/ProductId={product_id}", "/galaxyclient",
f"/buildId={manifest.get('buildId', '')}",
f"/versionName={manifest.get('versionName', '')}",
"/lang-code=en-US", f"/supportDir={wine_support_path}",
"/nodesktopshorctut", "/nodesktopshortcut",
]
def _get_primary_task(self, app_id: str) -> tuple[dict | None, Path | None]:
"""Return the primary GOG launch task and installation path."""
install_path = self.get_installed_path(app_id)
if install_path is None:
return None, None
info = self._load_json(install_path / f"goggame-{app_id}.info", {})
tasks = info.get("playTasks", []) if isinstance(info, dict) else []
primary = next((task for task in tasks if task.get("isPrimary")), None)
if primary is None and tasks:
primary = tasks[0]
if not isinstance(primary, dict) or primary.get("type") != "FileTask":
return None, install_path
return primary, install_path
def _get_library_entries(self, user_id: str, token: str) -> list[dict]:
entries = []
page_token = ""
headers = {"Authorization": f"Bearer {token}"}
while True:
url = f"https://galaxy-library.gog.com/users/{user_id}/releases"
response = requests.get(url, headers=headers, params={"page_token": page_token} if page_token else {}, timeout=15)
response.raise_for_status()
data = response.json()
entries.extend(data.get("items", []))
page_token = data.get("next_page_token", "")
if not page_token:
return entries
def _get_game(self, entry: dict, token: str) -> dict:
if entry.get("platform_id") != "gog" or not entry.get("external_id"):
return {}
app_id = str(entry["external_id"])
headers = {"Authorization": f"Bearer {token}"}
if entry.get("certificate"):
headers["X-GOG-Library-Cert"] = str(entry["certificate"])
response = requests.get(
f"https://gamesdb.gog.com/platforms/gog/external_releases/{app_id}",
headers=headers, timeout=15,
)
response.raise_for_status()
data = response.json()
game = data.get("game", {})
if not game.get("visible_in_library", True):
return {}
steam_release = next(
(
release for release in game.get("releases", [])
if release.get("platform_id") == "steam"
),
{},
)
description = self._localized_value(data.get("summary"))
language = get_metadata_language().lower()
try:
product_response = requests.get(
f"https://api.gog.com/products/{app_id}",
params={
"expand": "description",
"locale": GOG_PRODUCT_LOCALES.get(language, language),
},
timeout=15,
)
product_response.raise_for_status()
product_description = product_response.json().get("description", {})
localized_lead = str(product_description.get("lead") or "")
if localized_lead:
paragraphs = re.split(
r"(?:<br\s*/?>\s*){2,}", localized_lead, flags=re.IGNORECASE
)
paragraph_count = (
2 if re.match(r"\s*<(?:b|strong)>", paragraphs[0], re.IGNORECASE)
else 1
)
description = "<br><br>".join(
paragraph.strip() for paragraph in paragraphs[:paragraph_count]
)
except requests.RequestException as error:
logger.warning(
"Failed to load localized GOG description for %s: %s",
app_id, error,
)
return {
"app_id": app_id,
"title": (
self._localized_value(data.get("title"))
or self._localized_value(game.get("title"))
or app_id
).strip(),
"description": description,
"cover": self._format_image(game.get("vertical_cover") or game.get("logo") or game.get("background")),
"steam_appid": str(steam_release.get("external_id", "")),
}
@staticmethod
def _localized_value(values: dict | None) -> str:
if not isinstance(values, dict):
return ""
language = get_metadata_language().lower()
for key in (language, language.replace("_", "-")):
if values.get(key):
return str(values[key])
localized = next(
(value for key, value in values.items() if key.lower().startswith(language)),
None,
)
return str(localized or values.get("*", ""))
@staticmethod
def _format_image(image: dict | None) -> str:
template = image.get("url_format", "") if isinstance(image, dict) else ""
return template.replace("{formatter}", "").replace("{ext}", "jpg")
@staticmethod
def parse_download_sizes(output: bytes) -> tuple[int, int]:
"""Return download and disk sizes from gogdl info output."""
data = orjson.loads(output)
sizes = data.get("size", {}) if isinstance(data, dict) else {}
languages = data.get("languages", []) if isinstance(data, dict) else []
selected = [sizes.get("*", {})]
if languages:
selected.append(sizes.get(str(languages[0]), {}))
download_size = sum(int(item.get("download_size", 0)) for item in selected)
disk_size = sum(int(item.get("disk_size", 0)) for item in selected)
return download_size, disk_size
@staticmethod
def extract_auth_code(url: str) -> str:
"""Extract the OAuth code from GOG's redirect URL."""
parsed = urlparse(url)
if parsed.netloc != "embed.gog.com" or parsed.path != "/on_login_success":
return ""
values = parse_qs(parsed.query)
return values.get("code", [""])[0]
@staticmethod
def _load_json(path: Path, fallback):
try:
return orjson.loads(path.read_bytes())
except (OSError, orjson.JSONDecodeError):
return fallback
@staticmethod
def _save_json(path: Path, data) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(orjson.dumps(data, option=orjson.OPT_INDENT_2))
+6 -67
View File
@@ -1,8 +1,5 @@
"""Unified icon extractor for NE and PE files."""
import os
import re
import shlex
import struct
import io
import pefile
@@ -16,52 +13,6 @@ 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_home,
"PortProtonQt",
"images",
"exe_icons",
f"{icon_name}.png",
)
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
"""
Generates a thumbnail for an .exe file.
@@ -75,10 +26,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
@@ -90,18 +37,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."""
@@ -111,10 +54,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):
@@ -128,10 +68,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."""
+10 -25
View File
@@ -9,7 +9,7 @@ from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraph
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
from PIL import Image, ImageQt, ImageSequence
from portprotonqt.config import ui_config
from portprotonqt.theme_manager import ThemeManager, load_theme
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()
@@ -397,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)
@@ -434,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
@@ -490,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}")
@@ -529,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:
@@ -564,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))
@@ -619,7 +605,6 @@ def round_corners(pixmap, radius):
painter.end()
return rounded
class FullscreenDialog(QDialog):
"""
Dialog for viewing images without standard controls.
@@ -705,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
-632
View File
@@ -1,632 +0,0 @@
import time
import os
import math
from typing import Protocol, cast, Any
from evdev import UInput, ecodes
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QMenu, QMessageBox, QTableWidget, QSlider
from PySide6.QtCore import Qt, QObject, Signal, Slot, QTimer, QThread
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
from portprotonqt.game_card import AnimatedCard
from portprotonqt.config import display_config, window_config
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.input_manager.constants import (
BUTTONS,
GamepadType as GamepadType,
PAD_AXIS_RIGHT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.native_gamepad import SDLGamepad
from portprotonqt.input_manager.buttons import ButtonInputMixin
from portprotonqt.input_manager.dialog_modes import DialogInputModesMixin
from portprotonqt.input_manager.dpad import DpadInputMixin
from portprotonqt.input_manager.file_explorer import FileExplorerInputMixin
from portprotonqt.input_manager.keyboard import KeyboardInputMixin
from portprotonqt.input_manager.runtime import GamepadRuntimeMixin
from portprotonqt.input_manager.settings import SettingsInputMixin
from portprotonqt.input_manager.settings_visual import SettingsVisualNavigationMixin
logger = get_logger(__name__)
class MainWindowProtocol(Protocol):
def activateFocusedWidget(self) -> None:
...
def goBackDetailPage(self, page: QWidget | None) -> None:
...
def switchTab(self, index: int) -> None:
...
def openAddGameDialog(self, exe_path: str | None = None) -> None:
...
def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None:
...
def on_slider_released(self) -> None:
...
def on_auto_slider_released(self) -> None:
...
def isActiveWindow(self) -> bool:
...
def refreshGames(self) -> None:
...
def handleSystemTableGamepadAction(self, table: QTableWidget, action: str) -> bool:
...
def handleSystemGamepadAction(self, action: str) -> bool:
...
stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget]
gamesListWidget: QWidget
autoInstallContainer: QWidget | None
currentDetailPage: QWidget | None
current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
auto_size_slider: QSlider | None
class MouseEmulationThread(QThread):
"""Thread for creating UInput virtual mouse device without blocking UI."""
finished = Signal(bool) # Emitted when done (True = success, False = failed)
def run(self):
"""Run UInput device creation in background thread."""
try:
if not os.path.exists('/dev/uinput'):
logger.error("EMUL: /dev/uinput does not exist")
self.finished.emit(False)
return
if not os.access('/dev/uinput', os.W_OK):
logger.error("EMUL: No write access to /dev/uinput")
self.finished.emit(False)
return
ui = UInput({
ecodes.EV_KEY: [ecodes.BTN_LEFT, ecodes.BTN_RIGHT],
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
}, name="Virtual DPad Mouse")
self.finished.emit(True)
# Store device in thread result
self._device = ui
except PermissionError as e:
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
self.finished.emit(False)
except Exception as ex:
logger.error(f"EMUL: Error creating virtual mouse: {ex}", exc_info=True)
self.finished.emit(False)
def get_device(self) -> UInput | None:
"""Get the created UInput device after thread finishes."""
return getattr(self, '_device', None)
class InputManager(
FileExplorerInputMixin,
DialogInputModesMixin,
SettingsInputMixin,
SettingsVisualNavigationMixin,
ButtonInputMixin,
DpadInputMixin,
KeyboardInputMixin,
GamepadRuntimeMixin,
QObject,
):
"""
Manages input from gamepads and keyboards for navigating the application interface.
Supports gamepad hotplugging, button and axis events, and keyboard shortcuts.
"""
# Signals for gamepad events
button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release)
dpad_moved = Signal(int, int, float) # Signal for D-pad movements
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
gamepad_hotplug = Signal(str) # 'add' or 'remove'
def __init__(
self,
main_window: MainWindowProtocol,
axis_deadzone: float = 0.5,
initial_axis_move_delay: float = 0.5,
repeat_axis_move_delay: float = 0.15
):
super().__init__(cast(QObject, main_window))
self._parent = main_window
self._gamepad_handling_enabled = True
self.gamepad_type = self._get_configured_gamepad_type()
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None)
self._parent.autoInstallContainer = getattr(self._parent, 'autoInstallContainer', None)
self.axis_deadzone = axis_deadzone
self.initial_axis_move_delay = initial_axis_move_delay
self.repeat_axis_move_delay = repeat_axis_move_delay
self.current_axis_delay = initial_axis_move_delay
self.last_move_time = 0.0
self.axis_moving = False
self.gamepad: SDLGamepad | None = None
self.running = True
self._is_fullscreen = display_config.get_fullscreen()
self.lt_pressed = False
self.rt_pressed = False
self.last_trigger_time = 0.0
self.trigger_cooldown = 0.2
# Mouse emulation attributes
self.mouse_emulation_enabled = True
self.ui = None
self.mouse_emulation_thread: MouseEmulationThread | None = None
self.stick_x_raw = 0
self.stick_y_raw = 0
# Axis parameters (will be filled from kernel)
self.center_x = 127 # X axis center
self.center_y = 127 # Y axis center
self.min_value = 0 # axis minimum
self.max_value = 255 # axis maximum
self.deadzone_value = 15 # deadzone from kernel (flat parameter)
self.scroll_axis_code = PAD_AXIS_RIGHT_Y
self.scroll_center = self.center_y
self.scroll_min_value = self.min_value
self.scroll_max_value = self.max_value
self.scroll_deadzone_value = self.deadzone_value
self.sensitivity = 20.0
# Dynamic attributes for different modes (declared here to satisfy type checkers)
self.winetricks_dialog: Any = None
self.settings_dialog: Any = None
self.file_explorer: Any = None
self.proton_manager_dialog: Any = None
self.appimage_update_dialog: Any = None
self._input_surfaces: list[Any] = []
self._input_surface_base_state: bool | None = None
self.scroll_accumulator = 0.0
self.scroll_sensitivity = 0.15
self.scroll_threshold = 0.2
self.last_update = time.time()
self.update_interval = 0.016 # ~60 FPS
self.emulation_active = False
self.emulation_triggered = False
self.start_held = False
self.select_held = False
self.pending_menu_fullscreen_time = 0.0
self.guide_held = False
# Variables for key combination handling
self.guide_pressed_time = 0
self.select_pressed_time = 0
self.guide_timer = QTimer(self)
self.guide_timer.setSingleShot(True)
self.guide_timer.timeout.connect(self._handle_guide_timeout)
self.guide_combination_timeout = 0.3 # 300ms timeout for combination
self.in_guide_combination_attempt = False # Flag to track if we're in a guide+select combination attempt
self._button_states: dict[int, int] = {}
self._hat_states: dict[int, tuple[int, int]] = {}
self._axis_states: dict[int, int] = {}
self._gamepad_polling_suspended = False
self._last_gamepad_check_time = 0.0
self._last_gamepad_error: str | None = None
# Focus check timer for emulation flag (runs in main thread)
self.focus_check_timer = QTimer(self)
self.focus_check_timer.timeout.connect(self._update_emulation_flag)
self.focus_check_timer.start(100) # Check every 100ms
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled:
# Initialize mouse emulation asynchronously to avoid blocking startup
QTimer.singleShot(0, self._async_enable_mouse_emulation)
# FileExplorer specific attributes
self.file_explorer = None
self.nav_timer = QTimer(self)
self.nav_timer.timeout.connect(self.handle_navigation_repeat)
self.current_direction = 0
self.last_nav_time = 0
self.initial_nav_delay = 0.1 # Initial delay before first repeat (sec)
self.repeat_nav_delay = 0.05 # Interval between repeats (sec)
self.stick_activated = False
self.stick_value = 0 # Current stick value (for smoothness)
self.dead_zone = 8000 # Stick deadzone
self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower()
# Add variables for continuous D-pad movement
self.dpad_timer = QTimer(self)
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
self.current_dpad_code = None # Tracks the current D-pad axis (e.g., ABS_HAT0X, ABS_HAT0Y)
self.current_dpad_value = 0 # Tracks the current D-pad direction value (e.g., -1, 1)
# Connect signals to slots
self.button_event.connect(
self.handle_button_slot,
Qt.ConnectionType.QueuedConnection,
)
self.dpad_moved.connect(
self.handle_dpad_slot,
Qt.ConnectionType.QueuedConnection,
)
self.toggle_fullscreen.connect(self.handle_fullscreen_slot)
# Install wheel event filter
app = QApplication.instance()
if app is not None:
app.installEventFilter(self)
self.init_gamepad()
def _async_enable_mouse_emulation(self):
"""Asynchronously enable mouse emulation to avoid blocking startup."""
logger.info("EMUL: Attempting to create UInput virtual mouse...")
self.mouse_emulation_thread = MouseEmulationThread()
self.mouse_emulation_thread.finished.connect(self._on_mouse_emulation_finished)
self.mouse_emulation_thread.start()
def _on_mouse_emulation_finished(self, success: bool):
"""Handle mouse emulation thread completion."""
if success and self.mouse_emulation_thread:
device = self.mouse_emulation_thread.get_device()
if device:
self.ui = device
self.mouse_emulation_enabled = True
logger.info("EMUL: Virtual mouse created successfully")
else:
self.mouse_emulation_enabled = False
def _update_emulation_flag(self):
"""Update emulation_active flag based on Qt app focus (main thread only)."""
# True when mouse emulation is enabled (works regardless of focus)
# The difference is whether other inputs are disabled (based on focus)
self.emulation_active = self.mouse_emulation_enabled
if not self.emulation_active:
self.emulation_triggered = False
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
"""Common navigation logic for game cards in a container."""
if container is None:
return
game_cards = [
card for card in container.findChildren(AnimatedCard)
if card.isVisible() and card.isEnabled()
]
if self._navigate_horizontal_library(game_cards, code, value):
return
focused = QApplication.focusWidget()
if game_cards and focused not in game_cards:
self._focus_grid_card(game_cards[0])
return
moved = self._navigate_card_grid(game_cards, code, value)
if not moved and code == PAD_DPAD_Y and value < 0 and focused in game_cards:
first_row = self._get_card_grid_rows(game_cards)[0]
if focused in first_row:
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
def _navigate_horizontal_library(
self, cards: list[QWidget], code: int, value: int
) -> bool:
manager = getattr(self._parent, "game_library_manager", None)
if getattr(manager, "layout_mode", "grid") not in {
"horizontal", "horizontal_top"
}:
return False
layout = getattr(manager, "gamesListLayout", None)
if code != PAD_DPAD_X or value == 0 or layout is None:
return False
ordered_cards = []
for index in range(layout.count()):
item = layout.itemAt(index)
widget = item.widget() if item is not None else None
if widget in cards:
ordered_cards.append(widget)
focused = QApplication.focusWidget()
if not ordered_cards:
return True
if focused not in ordered_cards:
self._focus_grid_card(ordered_cards[0])
return True
target_index = (ordered_cards.index(focused) + value) % len(ordered_cards)
self._focus_grid_card(ordered_cards[target_index])
return True
def _get_card_grid_rows(self, cards: list[QWidget]) -> list[list[QWidget]]:
"""Group cards into rows ordered by their visual position."""
rows = {}
y_tolerance = 10 # Allow slight variations in y-position
for card in cards:
y = card.pos().y()
for row_y in rows:
if abs(y - row_y) <= y_tolerance:
rows[row_y].append(card)
break
else:
rows[y] = [card]
sorted_rows = [row for _y, row in sorted(rows.items())]
for row_cards in sorted_rows:
row_cards.sort(key=lambda card: card.pos().x())
return sorted_rows
def _grid_navigation_target(
self, rows: list[list[QWidget]], focused: QWidget, code: int, value: int
) -> QWidget | None:
row_index = next(index for index, row in enumerate(rows) if focused in row)
if code == PAD_DPAD_X:
flat_cards = [card for row in rows for card in row]
card_index = flat_cards.index(focused) + value
return flat_cards[card_index] if 0 <= card_index < len(flat_cards) else None
target_row_index = row_index + value
if not 0 <= target_row_index < len(rows):
return None
focused_x = focused.pos().x() + focused.width() / 2
return min(
rows[target_row_index],
key=lambda card: abs(card.pos().x() + card.width() / 2 - focused_x),
)
def _focus_grid_card(self, card: QWidget) -> None:
card.setFocus(Qt.FocusReason.OtherFocusReason)
scroll_area = card.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
if isinstance(scroll_area, QScrollArea):
scroll_area.ensureWidgetVisible(card, 50, 50)
def _navigate_card_grid(self, cards: list[QWidget], code: int, value: int) -> bool:
"""Move focus through a card grid using its visual layout."""
if not cards or value == 0:
return False
rows = self._get_card_grid_rows(cards)
focused = QApplication.focusWidget()
if focused not in cards:
return False
target = self._grid_navigation_target(rows, focused, code, value)
if target is None:
return False
self._focus_grid_card(target)
return True
def handle_navigation_repeat(self):
"""Smooth movement repeat with variable speed for FileExplorer"""
try:
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
return
if self.current_direction != 0:
now = time.time()
# Dynamic interval based on stick_value
dynamic_delay = self.repeat_nav_delay / self.stick_value
if now - self.last_nav_time >= dynamic_delay:
self.file_explorer.move_selection(self.current_direction)
self.last_nav_time = now
except Exception as e:
logger.error(f"Error in navigation repeat: {e}")
def disable_mouse_emulation(self):
"""Disable mouse emulation mode (closes virtual mouse device)."""
logger.info("EMUL: Disabling mouse emulation...")
# Stop the thread if still running
thread = self.mouse_emulation_thread
if thread and thread.isRunning():
thread.wait()
self.mouse_emulation_thread = None
if self.ui:
try:
self.ui.close()
logger.info("EMUL: Virtual mouse closed")
except Exception as e:
logger.error("EMUL: Error closing virtual mouse: %s", e)
self.ui = None
self.mouse_emulation_enabled = False
self.stick_x_raw = 0
self.stick_y_raw = 0
self.scroll_accumulator = 0.0
def handle_scroll(self, raw_value):
"""Handle scrolling from right stick Y"""
if not self._is_mouse_emulation_active() or not self.ui:
return
# Normalize from center
centered_value = raw_value - self.scroll_center
if abs(centered_value) < self.scroll_deadzone_value:
self.scroll_accumulator = 0.0
return
# Normalize value (-1.0 to 1.0)
range_val = (self.scroll_max_value - self.scroll_min_value) / 2
if range_val <= 0:
return
normalized = centered_value / range_val
# Accumulate scroll
self.scroll_accumulator += normalized * self.scroll_sensitivity
# Send scroll events
while abs(self.scroll_accumulator) >= self.scroll_threshold:
scroll_step = 1 if self.scroll_accumulator > 0 else -1
self.scroll_wheel(-scroll_step)
self.scroll_accumulator -= scroll_step * self.scroll_threshold
def update_mouse_position(self):
"""Constant update of mouse position based on stick state"""
if not self.ui or not self.emulation_active:
return
# Center values
x = self.stick_x_raw - self.center_x
y = self.stick_y_raw - self.center_y
# Apply deadzone from kernel
magnitude = math.sqrt(x * x + y * y)
if magnitude < self.deadzone_value:
return
if magnitude > 0:
norm_x = x / magnitude
norm_y = y / magnitude
else:
return
# Normalize to axis range
max_range = (self.max_value - self.min_value) / 2
adjusted_magnitude = (magnitude - self.deadzone_value) / (max_range - self.deadzone_value)
adjusted_magnitude = max(0.0, min(1.0, adjusted_magnitude))
# Non-linear curve
adjusted_magnitude = math.pow(adjusted_magnitude, 1.5)
speed = adjusted_magnitude * self.sensitivity
dx = int(norm_x * speed)
dy = int(norm_y * speed)
if dx != 0 or dy != 0:
self.move_mouse(dx, dy)
def move_mouse(self, dx, dy):
"""Move system cursor"""
if self.ui:
self.ui.write(ecodes.EV_REL, ecodes.REL_X, dx)
self.ui.write(ecodes.EV_REL, ecodes.REL_Y, dy)
self.ui.syn()
def scroll_wheel(self, steps):
"""Mouse wheel scroll"""
if self.ui:
self.ui.write(ecodes.EV_REL, ecodes.REL_WHEEL, steps)
self.ui.syn()
def click_left(self):
"""Left mouse button click"""
if self.ui:
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 1)
self.ui.syn()
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 0)
self.ui.syn()
def click_right(self):
"""Right mouse button click"""
if self.ui:
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 1)
self.ui.syn()
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 0)
self.ui.syn()
@Slot(bool)
def handle_fullscreen_slot(self, enable: bool) -> None:
try:
window = self._parent
if not isinstance(window, QWidget):
return
if enable and not self._is_fullscreen:
if not window.isFullScreen():
window_config.set_geometry(window.width(), window.height())
window.showFullScreen()
self._is_fullscreen = True
elif not enable and self._is_fullscreen:
window.showNormal()
width, height = window_config.get_geometry()
if width > 0 and height > 0:
window.resize(width, height)
self._is_fullscreen = False
window_config.set_geometry(width, height)
except Exception as e:
logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
def disable_gamepad_handling(self) -> None:
"""Disable gamepad event handling."""
self._gamepad_handling_enabled = False
self.dpad_timer.stop()
self.nav_timer.stop()
def enable_gamepad_handling(self) -> None:
"""Enable gamepad event handling."""
self._gamepad_handling_enabled = True
def suspend_gamepad_polling(self) -> None:
"""Disable PPQT gamepad handling while keeping mouse emulation available."""
self._gamepad_polling_suspended = True
self._gamepad_handling_enabled = False
self.dpad_timer.stop()
self.nav_timer.stop()
def resume_gamepad_polling(self) -> None:
"""Resume SDL controller polling after the external game exits."""
self._gamepad_polling_suspended = False
self._gamepad_handling_enabled = True
self.check_gamepad()
def _handle_guide_timeout(self) -> None:
if self.guide_held:
time_since_guide = time.time() - self.guide_pressed_time
time_since_select = time.time() - self.select_pressed_time
if (self.select_pressed_time > self.guide_pressed_time and
time_since_select <= self.guide_combination_timeout and
time_since_guide <= self.guide_combination_timeout):
logger.debug("Guide + Select combination detected, refreshing game grid")
self._parent.refreshGames()
else:
logger.debug("Guide button pressed alone")
self._reset_guide_combination()
def _handle_common_ui_elements(self, button_code):
"""
Common handler for common UI elements like QMessageBox, QMenu, etc.
Returns True if the event was handled, False otherwise.
"""
# Check for popup widgets first
popup = QApplication.activePopupWidget()
if popup:
if isinstance(popup, QMessageBox):
self._handle_qmessagebox_button(popup, button_code)
SoundManager().play("click")
return True
elif isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']:
SoundManager().play("click")
if popup.activeAction():
popup.activeAction().trigger()
popup.close()
elif button_code in BUTTONS['back']:
SoundManager().play("back")
popup.close()
return True
# Check for top-level QMessageBox specifically
active = QApplication.activeWindow()
if isinstance(active, QMessageBox):
self._handle_qmessagebox_button(active, button_code)
return True
# Check for top-level message boxes (additional check)
for widget in QApplication.topLevelWidgets():
if isinstance(widget, QMessageBox) and widget.isVisible() and widget != active:
self._handle_qmessagebox_button(widget, button_code)
return True
return False # Not handled by common handler
def _handle_qmessagebox_button(self, msg_box, button_code):
"""
Unified handler for QMessageBox across all modes.
For single button dialogs, A button accepts the dialog.
For multiple button dialogs, navigate between buttons and allow selection.
"""
if button_code in BUTTONS['confirm']:
# Check if there's a focused button in the message box
focused_widget = msg_box.focusWidget()
if focused_widget:
# If a specific button is focused, click/activate it
focused_widget.click()
else:
# If no button is focused, accept with default behavior
msg_box.accept()
elif button_code in BUTTONS['back']:
# For back button, reject the dialog (typically cancels or selects cancel button)
msg_box.reject()
-377
View File
@@ -1,377 +0,0 @@
import time
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QLineEdit,
QListView,
QMenu,
QSlider,
QStackedWidget,
QTableWidget,
QWidget,
)
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.game_card import GameCard
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.input_manager.constants import (
BUTTONS,
SIZE_SLIDER_STEP,
SYSTEM_AUDIO_SECTION_INDEX,
SYSTEM_VOLUME_STEP,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class ButtonInputMixin(InputMixin):
@Slot(int, int)
def handle_button_slot(self, button_code: int, value: int) -> None:
if self._route_surface_button(button_code, value):
return
self._handle_default_button(button_code, value)
def _handle_default_button(self, button_code: int, value: int) -> None:
if value == 1 and self._handle_common_ui_elements(button_code):
return
active_window = QApplication.activeWindow()
if self._route_button_to_keyboard(active_window, button_code, value):
return
if value == 0:
return
if not self._gamepad_handling_enabled:
return
try:
app = QApplication.instance()
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
if not app or not active:
return
if self._route_button_to_text_input(active, focused, button_code):
return
if self._handle_guide_combination(button_code, time.time()):
return
if self._handle_combo_button(focused, button_code):
return
if self._handle_list_view_button(focused, button_code):
return
if self._handle_popup_menu_button(QApplication.activePopupWidget(), button_code):
return
if self._handle_active_window_button(active, button_code):
return
if self._open_focused_context_menu(focused, button_code):
return
if self._handle_system_table_button(focused, button_code):
return
if self._handle_system_quick_button(button_code):
return
self._handle_standard_button(focused, button_code, value)
except Exception as e:
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
def _open_focused_context_menu(self, focused: QWidget | None, button_code: int) -> bool:
if button_code not in BUTTONS['context_menu'] or focused is None:
return False
if isinstance(focused, QTableWidget):
current_row = focused.currentRow()
current_col = max(focused.currentColumn(), 0)
item = focused.item(current_row, current_col) if current_row >= 0 else None
if item is None and current_row >= 0:
item = focused.item(current_row, 0)
point = (
focused.visualItemRect(item).center()
if item is not None
else focused.viewport().rect().center()
)
focused.customContextMenuRequested.emit(point)
return True
if focused.contextMenuPolicy() == Qt.ContextMenuPolicy.CustomContextMenu:
focused.customContextMenuRequested.emit(focused.rect().center())
return True
if not isinstance(focused, GameCard):
return False
position = QPoint(focused.width() // 2, focused.height() // 2)
menu = focused._show_context_menu(position)
if menu:
menu.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_combo_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QComboBox):
return False
if button_code in BUTTONS['confirm']:
focused.showPopup()
return True
if button_code in BUTTONS['back'] and focused.view().isVisible():
focused.hidePopup()
return True
return False
def _handle_popup_menu_button(self, popup: QWidget | None, button_code: int) -> bool:
if not isinstance(popup, QMenu):
return False
if button_code in BUTTONS['confirm']:
action = popup.activeAction()
if action is not None and action.isEnabled():
action.trigger()
popup.close()
return True
if button_code in BUTTONS['back']:
popup.close()
return True
return False
def _route_button_to_text_input(
self, active: QWidget, focused: QWidget | None, button_code: int
) -> bool:
keyboard = (
getattr(active, 'keyboard', None)
if isinstance(active, AddGameDialog)
else getattr(self._parent, 'keyboard', None)
)
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
if keyboard:
keyboard.show_for_widget(focused)
return True
if button_code not in BUTTONS['prev_dir']:
return False
combo = focused if isinstance(focused, QComboBox) else None
if combo is None and focused is not None:
combo = self._find_parent_combo(focused)
if combo is not None and combo.isEditable():
line_edit = combo.lineEdit()
if line_edit is not None and keyboard:
line_edit.setFocus()
keyboard.show_for_widget(line_edit)
return True
current_tab = self._parent.stackedWidget.currentIndex()
search_name = 'searchEdit' if current_tab == 0 else 'autoInstallSearchLineEdit'
search_edit = getattr(self._parent, search_name, None) if current_tab in (0, 1) else None
if search_edit is None:
return False
search_edit.setFocus()
return True
def _find_parent_combo(self, widget: QWidget) -> QComboBox | None:
parent = widget.parentWidget()
while parent:
if isinstance(parent, QComboBox):
return parent
parent = parent.parentWidget()
return None
def _activate_list_view_selection(self, view: QListView) -> None:
index = view.currentIndex()
if not index.isValid():
return
combo = self._find_parent_combo(view)
if combo:
combo.setCurrentIndex(index.row())
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
return
view.activated.emit(index)
view.clicked.emit(index)
view.hide()
def _handle_list_view_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QListView):
return False
if button_code in BUTTONS['confirm']:
self._activate_list_view_selection(focused)
return True
if button_code not in BUTTONS['back']:
return False
combo = self._find_parent_combo(focused)
if combo:
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
else:
focused.clearSelection()
focused.hide()
return False
def _handle_system_table_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QTableWidget):
return False
handler = getattr(self._parent, "handleSystemTableGamepadAction", None)
if not callable(handler):
return False
routes = (
('confirm', "confirm", "click"),
('back', "back", "back"),
('prev_dir', "prev_dir", "click"),
('add_game', "add_game", "click"),
)
for button_name, action, sound in routes:
if button_code in BUTTONS[button_name] and handler(focused, action):
SoundManager().play(sound)
return True
return False
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
active.reject()
return True
if not isinstance(active, FullscreenDialog):
return False
if button_code in BUTTONS['prev_tab']:
active.show_prev()
elif button_code in BUTTONS['next_tab']:
active.show_next()
elif button_code in BUTTONS['back']:
active.close()
return True
def _handle_system_quick_button(self, button_code: int) -> bool:
handler = getattr(self._parent, "handleSystemGamepadAction", None)
if not callable(handler) or button_code not in BUTTONS['add_game']:
return False
if not handler("add_game"):
return False
SoundManager().play("open")
return True
def _handle_standard_button(
self, focused: QWidget | None, button_code: int, value: int
) -> None:
if button_code in BUTTONS['confirm']:
if not isinstance(focused, GameCard):
SoundManager().play_widget_sound(focused)
QTimer.singleShot(0, self._parent.activateFocusedWidget)
elif button_code in BUTTONS['back']:
self._handle_standard_back()
elif button_code in BUTTONS['add_game']:
if self._parent.stackedWidget.currentIndex() == 0:
SoundManager().play("open")
self._parent.openAddGameDialog()
elif button_code in BUTTONS['prev_tab']:
self._switch_visible_tab(-1)
elif button_code in BUTTONS['next_tab']:
self._switch_visible_tab(1)
elif button_code in BUTTONS['increase_size'] and value > 0:
self._adjust_active_slider(1)
elif button_code in BUTTONS['decrease_size'] and value > 0:
self._adjust_active_slider(-1)
def _handle_standard_back(self) -> None:
SoundManager().play("back")
manager = getattr(self._parent, "game_library_manager", None)
close_full_library = getattr(manager, "close_full_library", None)
if callable(close_full_library) and close_full_library():
return
if self._is_theme_store_visible():
store_stack = getattr(self._parent, "themeStoreStack", None)
detail_page = getattr(self._parent, "themeStoreDetailPage", None)
show_list = getattr(self._parent, '_show_theme_store_list', None)
if store_stack is not None and store_stack.currentWidget() == detail_page:
if callable(show_list):
show_list()
return
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
def _adjust_active_slider(self, direction: int) -> bool:
current_tab = self._parent.stackedWidget.currentIndex()
if current_tab == getattr(self._parent, "system_tab_index", -1):
return self._adjust_system_volume(direction)
if current_tab == 0:
manager = getattr(self._parent, 'game_library_manager', None)
slider = getattr(manager, 'sizeSlider', None)
callback = getattr(self._parent, 'on_slider_released', None)
elif current_tab == 1:
slider = getattr(self._parent, 'auto_size_slider', None)
callback = getattr(self._parent, 'on_auto_slider_released', None)
elif current_tab == getattr(self._parent, "theme_tab_index", -1):
slider = getattr(self._parent, 'themeStoreSizeSlider', None)
callback = getattr(self._parent, '_on_theme_store_slider_released', None)
else:
return False
if not isinstance(slider, QSlider):
return False
slider.setValue(slider.value() + direction * SIZE_SLIDER_STEP)
if callable(callback):
callback()
return True
def _adjust_system_volume(self, direction: int) -> bool:
section_stack = getattr(self._parent, "systemSectionStack", None)
slider = getattr(self._parent, "audioVolumeSlider", None)
if (
not isinstance(section_stack, QStackedWidget)
or section_stack.currentIndex() != SYSTEM_AUDIO_SECTION_INDEX
):
return False
if not isinstance(slider, QSlider):
return False
slider.setValue(slider.value() + direction * SYSTEM_VOLUME_STEP)
apply_volume = getattr(self._parent, "_applySelectedAudioVolume", None)
if callable(apply_volume):
apply_volume()
return True
def _handle_guide_combination(self, button_code: int, current_time: float) -> bool:
if button_code in BUTTONS['guide']:
self.guide_held = True
self.guide_pressed_time = current_time
self.in_guide_combination_attempt = True
self.guide_timer.start(int(self.guide_combination_timeout * 1000))
return True
if button_code not in BUTTONS['menu'] or not self.guide_held:
return False
self.select_pressed_time = current_time
if current_time - self.guide_pressed_time > self.guide_combination_timeout:
self._reset_guide_combination()
return False
self.guide_timer.stop()
logger.debug("Guide + Select combination detected, refreshing game grid")
self._parent.refreshGames()
self._reset_guide_combination()
return True
def _reset_guide_combination(self) -> None:
self.guide_held = False
self.in_guide_combination_attempt = False
self.guide_pressed_time = 0
self.select_pressed_time = 0
def _route_button_to_keyboard(
self, active_window: QWidget | None, button_code: int, value: int
) -> bool:
if isinstance(active_window, AddGameDialog):
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit):
active_window.show_keyboard_for_widget(focused)
return True
keyboard = getattr(active_window, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
dialog_keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if isinstance(dialog_keyboard, VirtualKeyboard) and dialog_keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
return False
-131
View File
@@ -1,131 +0,0 @@
from enum import Enum
from PySide6.QtCore import Qt
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
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"
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,
}
PAD_BUTTON_SOUTH = 1000
PAD_BUTTON_EAST = 1001
PAD_BUTTON_WEST = 1002
PAD_BUTTON_NORTH = 1003
PAD_BUTTON_SELECT = 1004
PAD_BUTTON_GUIDE = 1005
PAD_BUTTON_START = 1006
PAD_BUTTON_LEFT_SHOULDER = 1007
PAD_BUTTON_RIGHT_SHOULDER = 1008
PAD_AXIS_LEFT_TRIGGER = 1100
PAD_AXIS_RIGHT_TRIGGER = 1101
PAD_AXIS_LEFT_X = 1200
PAD_AXIS_LEFT_Y = 1201
PAD_AXIS_RIGHT_X = 1202
PAD_AXIS_RIGHT_Y = 1203
PAD_DPAD_X = 1300
PAD_DPAD_Y = 1301
INPUT_AXIS_SCALE = 32767
KEY_BACKSPACE = Qt.Key.Key_Backspace.value
KEY_DOWN = Qt.Key.Key_Down.value
KEY_ENTER = Qt.Key.Key_Enter.value
KEY_ESCAPE = Qt.Key.Key_Escape.value
KEY_F5 = Qt.Key.Key_F5.value
KEY_F10 = Qt.Key.Key_F10.value
KEY_F11 = Qt.Key.Key_F11.value
KEY_LEFT = Qt.Key.Key_Left.value
KEY_RETURN = Qt.Key.Key_Return.value
KEY_RIGHT = Qt.Key.Key_Right.value
KEY_UP = Qt.Key.Key_Up.value
KEY_E = ord("e")
KEY_Q = ord("q")
MOD_CTRL = 1
MOD_SHIFT = 2
SIZE_SLIDER_STEP = 10
SYSTEM_AUDIO_SECTION_INDEX = 4
SYSTEM_VOLUME_STEP = 5
BUTTONS = {
'confirm': {PAD_BUTTON_SOUTH},
'back': {PAD_BUTTON_EAST},
'add_game': {PAD_BUTTON_WEST},
'prev_dir': {PAD_BUTTON_NORTH},
'prev_tab': {PAD_BUTTON_LEFT_SHOULDER},
'next_tab': {PAD_BUTTON_RIGHT_SHOULDER},
'context_menu': {PAD_BUTTON_START},
'menu': {PAD_BUTTON_SELECT},
'guide': {PAD_BUTTON_GUIDE},
'increase_size': {PAD_AXIS_RIGHT_TRIGGER},
'decrease_size': {PAD_AXIS_LEFT_TRIGGER},
}
SDL_CONTROLLER_BUTTON_TO_PAD = {
SDL_GAMEPAD_BUTTON_SOUTH: PAD_BUTTON_SOUTH,
SDL_GAMEPAD_BUTTON_EAST: PAD_BUTTON_EAST,
SDL_GAMEPAD_BUTTON_WEST: PAD_BUTTON_WEST,
SDL_GAMEPAD_BUTTON_NORTH: PAD_BUTTON_NORTH,
SDL_GAMEPAD_BUTTON_BACK: PAD_BUTTON_SELECT,
SDL_GAMEPAD_BUTTON_GUIDE: PAD_BUTTON_GUIDE,
SDL_GAMEPAD_BUTTON_START: PAD_BUTTON_START,
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: PAD_BUTTON_LEFT_SHOULDER,
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: PAD_BUTTON_RIGHT_SHOULDER,
}
SDL_CONTROLLER_AXIS_TO_PAD = {
SDL_GAMEPAD_AXIS_LEFTX: PAD_AXIS_LEFT_X,
SDL_GAMEPAD_AXIS_LEFTY: PAD_AXIS_LEFT_Y,
SDL_GAMEPAD_AXIS_RIGHTX: PAD_AXIS_RIGHT_X,
SDL_GAMEPAD_AXIS_RIGHTY: PAD_AXIS_RIGHT_Y,
SDL_GAMEPAD_AXIS_LEFT_TRIGGER: PAD_AXIS_LEFT_TRIGGER,
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: PAD_AXIS_RIGHT_TRIGGER,
}
-711
View File
@@ -1,711 +0,0 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, cast
from PySide6.QtCore import QObject, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QListView,
QListWidget,
QMenu,
QStackedWidget,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.input_manager.constants import BUTTONS, PAD_DPAD_X, PAD_DPAD_Y
from portprotonqt.logger import get_logger
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.sound_manager import SoundManager
logger = get_logger(__name__)
@dataclass
class InputSurface:
name: str
dialog: Any
button_handler: Callable[[int, int], None]
dpad_handler: Callable[[int, int, float], None]
connections: list[tuple[Any, Callable[..., Any]]] = field(default_factory=list)
class DialogInputModesMixin(InputMixin):
# WINETRICKS MODE
def enable_winetricks_mode(self, winetricks_dialog):
"""Setup gamepad handling for WinetricksDialog"""
try:
self._setup_mode_handlers(
winetricks_dialog,
self.handle_winetricks_button,
self.handle_winetricks_dpad,
'winetricks_dialog'
)
logger.debug("Gamepad handling successfully connected for WinetricksDialog")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for Winetricks: {e}")
def disable_winetricks_mode(self):
"""Restore original main window handlers"""
try:
if self.winetricks_dialog:
self._restore_original_handlers('winetricks_dialog')
logger.debug("Gamepad handling successfully restored from Winetricks")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from Winetricks: {e}")
def handle_winetricks_button(self, button_code, value):
if self.winetricks_dialog is None or value == 0:
return
try:
# Handle common UI elements like QMessageBox, QMenu, etc.
if self._handle_common_ui_elements(button_code):
return
# Winetricks-specific button handling
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if isinstance(focused, QTableWidget):
self.handle_table_confirm(focused)
return
elif button_code in BUTTONS['add_game']: # X: Install
self.winetricks_dialog.install_selected(force=False)
elif button_code in BUTTONS['prev_dir']: # Y: Force Install
self.winetricks_dialog.install_selected(force=True)
elif button_code in BUTTONS['back']: # B: Cancel
self.winetricks_dialog.reject()
elif button_code in BUTTONS['prev_tab']: # LB
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_table()
elif button_code in BUTTONS['next_tab']: # RB
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_table()
else:
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_winetricks_button: {e}")
def handle_winetricks_dpad(self, code, value, now):
if self.winetricks_dialog is None:
return
try:
if value == 0: # Release
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
# Timer setup
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
table = self._get_current_table()
if not table or table.rowCount() == 0:
return
current_row = table.currentRow()
if code == PAD_DPAD_Y: # Up/Down
step = -1 if value < 0 else 1
new_row = current_row + step
# Skip hidden rows
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
new_row += step
# Bounds check
if new_row < 0:
new_row = current_row
if new_row >= table.rowCount():
new_row = current_row
if new_row != current_row:
table.setCurrentCell(new_row, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_winetricks_dpad: {e}")
def _get_current_table(self):
if self.winetricks_dialog:
current_container = self.winetricks_dialog.tab_widget.currentWidget()
if isinstance(current_container, QStackedWidget):
current_table = current_container.widget(1)
if isinstance(current_table, QTableWidget):
return current_table
return None
def _focus_first_row_in_current_table(self):
table = self._get_current_table()
if table and table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
def _get_navigable_menu_actions(self, menu: QMenu) -> list:
return [
action
for action in menu.actions()
if not action.isSeparator() and action.isEnabled() and action.isVisible()
]
def _navigate_menu_actions(self, menu: QMenu, direction_down: bool) -> None:
actions = self._get_navigable_menu_actions(menu)
if not actions:
return
current_action = menu.activeAction()
if current_action not in actions:
target_index = 0 if direction_down else len(actions) - 1
menu.setActiveAction(actions[target_index])
SoundManager().play("navigate")
return
current_index = actions.index(current_action)
step = 1 if direction_down else -1
next_index = (current_index + step) % len(actions)
menu.setActiveAction(actions[next_index])
SoundManager().play("navigate")
# TABLE NAVIGATION METHODS
def handle_table_navigation(self, table: QTableWidget, code: int, value: int):
"""
Handle navigation in table
Args:
table: QTableWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
row_count = table.rowCount()
if row_count <= 0:
return
current_row = table.currentRow()
if current_row < 0:
current_row = 0
table.setCurrentCell(0, 0)
if code == PAD_DPAD_Y and value != 0:
# Vertical navigation
if value > 0: # Down
new_row = min(current_row + 1, row_count - 1)
elif value < 0: # Up
new_row = max(current_row - 1, 0)
else:
return
table.setCurrentCell(new_row, table.currentColumn())
item = table.item(new_row, table.currentColumn())
if item:
table.scrollToItem(
item,
QAbstractItemView.ScrollHint.PositionAtCenter
)
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
elif code == PAD_DPAD_X and value != 0:
# Horizontal navigation
col_count = table.columnCount()
current_col = table.currentColumn()
if current_col < 0:
current_col = 0
if value < 0: # Left
new_col = max(current_col - 1, 0)
elif value > 0: # Right
new_col = min(current_col + 1, col_count - 1)
else:
return
table.setCurrentCell(table.currentRow(), new_col)
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
def handle_table_confirm(self, table: QTableWidget):
"""
Handle confirmation (e.g., A press) for table
Args:
table: QTableWidget for confirmation handling
"""
current_row = table.currentRow()
current_col = table.currentColumn()
if current_row >= 0 and current_col >= 0:
cell_widget = table.cellWidget(current_row, current_col)
if isinstance(cell_widget, QCheckBox) and cell_widget.isEnabled():
cell_widget.setChecked(not cell_widget.isChecked())
return True
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setChecked(not checkbox.isChecked())
return True
# Check if the cell contains a checkbox
item = table.item(current_row, current_col)
if item and (item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
# Toggle the checkbox state
new_state = Qt.CheckState.Checked if item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
item.setCheckState(new_state)
return True
# Call custom confirm callback if exists
callback = getattr(table, '_on_confirm_callback', None) # type: ignore
if callback and callable(callback):
callback(table, current_row, current_col)
return True
# WIDGET NAVIGATION METHODS
def setup_widget_navigation(self, widget: QWidget, navigation_type: str = "default", **kwargs):
"""
Set navigation for widget
Args:
widget: QWidget for navigation setup
navigation_type: Navigation type ('table', 'list', 'combo', 'default')
**kwargs: Additional parameters for navigation
"""
widget.installEventFilter(cast(QObject, self))
# Use direct assignment for custom navigation properties, with type ignore for pyright
widget._navigation_type = navigation_type # type: ignore
for key, value in kwargs.items():
setattr(widget, f'_{key}', value)
def handle_widget_navigation(self, widget: QWidget, code: int, value: int):
"""
Handle navigation in widget
Args:
widget: QWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
nav_type = getattr(widget, '_navigation_type', 'default') # type: ignore
if nav_type == 'table' and isinstance(widget, QTableWidget):
self.handle_table_navigation(widget, code, value)
elif nav_type == 'list' and isinstance(widget, QListWidget):
self.handle_list_navigation(widget, code, value)
elif nav_type == 'combo' and isinstance(widget, QComboBox):
self.handle_combo_navigation(widget, code, value)
else:
# Default navigation behavior
if isinstance(widget, QTableWidget):
self.handle_table_navigation(widget, code, value)
elif isinstance(widget, QListWidget):
self.handle_list_navigation(widget, code, value)
elif isinstance(widget, QComboBox):
self.handle_combo_navigation(widget, code, value)
def handle_list_navigation(self, list_widget: QListWidget, code: int, value: int):
"""
Handle navigation in list
Args:
list_widget: QListWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
if code == PAD_DPAD_Y and value != 0:
model = list_widget.model()
current_index = list_widget.currentIndex()
if model and current_index.isValid():
row_count = model.rowCount()
current_row = current_index.row()
if value > 0: # Down
next_row = min(current_row + 1, row_count - 1)
list_widget.setCurrentIndex(model.index(next_row, current_index.column()))
elif value < 0: # Up
prev_row = max(current_row - 1, 0)
list_widget.setCurrentIndex(model.index(prev_row, current_index.column()))
list_widget.scrollTo(list_widget.currentIndex(), QListView.ScrollHint.PositionAtCenter)
def handle_combo_navigation(self, combo_widget: QComboBox, code: int, value: int):
"""
Handle navigation in combo widget
Args:
combo_widget: QComboBox for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
if code == PAD_DPAD_Y and value != 0:
current_index = combo_widget.currentIndex()
if value > 0: # Down
new_index = min(current_index + 1, combo_widget.count() - 1)
elif value < 0: # Up
new_index = max(current_index - 1, 0)
else:
return
if new_index != current_index:
combo_widget.setCurrentIndex(new_index)
def _setup_mode_handlers(self, dialog_instance, button_handler, dpad_handler, dialog_attr_name):
"""Register a dialog as the active input surface."""
if not self._input_surfaces:
self._input_surface_base_state = self._gamepad_handling_enabled
previous_surface = next(
(surface for surface in self._input_surfaces if surface.name == dialog_attr_name),
None,
)
if previous_surface is not None:
for signal, callback in previous_surface.connections:
signal.disconnect(callback)
self._input_surfaces = [
surface for surface in self._input_surfaces
if surface.name != dialog_attr_name
]
surface = InputSurface(
dialog_attr_name,
dialog_instance,
button_handler,
dpad_handler,
)
self._input_surfaces.append(surface)
setattr(self, dialog_attr_name, dialog_instance)
self._gamepad_handling_enabled = True
self._reset_surface_navigation()
def _restore_original_handlers(self, dialog_attr_name):
"""Remove a dialog input surface and restore the preceding surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot restore input surface: %s is not registered", dialog_attr_name)
return
for signal, callback in surface.connections:
signal.disconnect(callback)
self._input_surfaces.remove(surface)
setattr(self, dialog_attr_name, None)
if not self._input_surfaces and self._input_surface_base_state is not None:
self._gamepad_handling_enabled = self._input_surface_base_state
self._input_surface_base_state = None
self._reset_surface_navigation()
def connect_surface_updates(
self,
dialog_attr_name: str,
callback: Callable[..., Any],
) -> None:
"""Connect a callback for the lifetime of a dialog input surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot connect input surface: %s is not registered", dialog_attr_name)
return
for signal in (self.button_event, self.dpad_moved):
signal.connect(callback)
surface.connections.append((signal, callback))
def connect_surface_signal(
self,
dialog_attr_name: str,
signal: Any,
callback: Callable[..., Any],
) -> None:
"""Connect one signal for the lifetime of a dialog input surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot connect input surface: %s is not registered", dialog_attr_name)
return
signal.connect(callback)
surface.connections.append((signal, callback))
def _reset_surface_navigation(self) -> None:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
def _route_surface_button(self, button_code: int, value: int) -> bool:
if not self._input_surfaces:
return False
self._input_surfaces[-1].button_handler(button_code, value)
return True
def _route_surface_dpad(self, code: int, value: int, current_time: float) -> bool:
if not self._input_surfaces:
return False
self._input_surfaces[-1].dpad_handler(code, value, current_time)
return True
# PROTON MANAGER SUPPORT
def enable_proton_manager_mode(self, proton_manager_dialog):
"""Setup gamepad handling for ProtonManagerDialog"""
try:
self._setup_mode_handlers(
proton_manager_dialog,
self.handle_proton_manager_button,
self.handle_proton_manager_dpad,
'proton_manager_dialog'
)
logger.debug("Gamepad handling successfully connected for ProtonManager")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for ProtonManager: {e}")
def disable_proton_manager_mode(self):
"""Restore original main window handlers"""
try:
if self.proton_manager_dialog:
self._restore_original_handlers('proton_manager_dialog')
logger.debug("Gamepad handling successfully restored from ProtonManager")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from ProtonManager: {e}")
def handle_proton_manager_button(self, button_code, value):
if self.proton_manager_dialog is None or value == 0:
return
try:
# Handle common UI elements like QMessageBox, QMenu, etc.
if self._handle_common_ui_elements(button_code):
return
# ProtonManager-specific button handling
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if isinstance(focused, QTableWidget):
current_row = focused.currentRow()
if current_row >= 0:
checkbox_widget = focused.cellWidget(current_row, 0)
if checkbox_widget:
checkbox = checkbox_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setChecked(not checkbox.isChecked())
return
elif button_code in BUTTONS['add_game']: # X: Download
self.proton_manager_dialog.download_selected()
elif button_code in BUTTONS['prev_dir']: # Y: Clear
self.proton_manager_dialog.clear_selection()
elif button_code in BUTTONS['back']: # B: Cancel/Close
# Cancel any active downloads/extractions before closing
if (self.proton_manager_dialog.current_extraction_thread and
self.proton_manager_dialog.current_extraction_thread.isRunning()) or \
(self.proton_manager_dialog.current_download_thread and
hasattr(self.proton_manager_dialog.current_download_thread, 'isRunning') and
self.proton_manager_dialog.current_download_thread.isRunning()):
# If there's an active download/extraction, cancel it
self.proton_manager_dialog.cancel_current_download()
else:
# If no active processes, just close the dialog
self.proton_manager_dialog.reject()
elif button_code in BUTTONS['prev_tab']: # LB: Previous tab
current_index = self.proton_manager_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.proton_manager_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_proton_manager_table()
elif button_code in BUTTONS['next_tab']: # RB: Next tab
current_index = self.proton_manager_dialog.tab_widget.currentIndex()
new_index = min(self.proton_manager_dialog.tab_widget.count() - 1, current_index + 1)
self.proton_manager_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_proton_manager_table()
else:
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_proton_manager_button: {e}")
def handle_proton_manager_dpad(self, code, value, now):
if self.proton_manager_dialog is None:
return
try:
if value == 0: # Release
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
# Timer setup
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
table = self._get_current_proton_manager_table()
if not table or table.rowCount() == 0:
return
current_row = table.currentRow()
if code == PAD_DPAD_Y: # Up/Down
step = -1 if value < 0 else 1
new_row = current_row + step
# Skip hidden rows
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
new_row += step
# Bounds check
if new_row < 0:
new_row = current_row
if new_row >= table.rowCount():
new_row = current_row
if new_row != current_row:
table.setCurrentCell(new_row, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_proton_manager_dpad: {e}")
def _get_current_proton_manager_table(self):
if self.proton_manager_dialog:
current_container = self.proton_manager_dialog.tab_widget.currentWidget()
if current_container:
table = current_container.findChild(QTableWidget)
return table
return None
def _focus_first_row_in_current_proton_manager_table(self):
table = self._get_current_proton_manager_table()
if table and table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
def enable_appimage_update_mode(self, appimage_update_dialog):
"""Setup gamepad handling for AppImageUpdateDialog"""
try:
self._setup_mode_handlers(
appimage_update_dialog,
self.handle_appimage_update_button,
self.handle_appimage_update_dpad,
'appimage_update_dialog'
)
logger.debug("Gamepad handling successfully connected for AppImageUpdateDialog")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for AppImageUpdateDialog: {e}")
def disable_appimage_update_mode(self):
"""Restore original main window handlers"""
try:
if self.appimage_update_dialog:
self._restore_original_handlers('appimage_update_dialog')
logger.debug("Gamepad handling successfully restored from AppImageUpdateDialog")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from AppImageUpdateDialog: {e}")
def handle_appimage_update_button(self, button_code, value):
if self.appimage_update_dialog is None or value == 0:
return
try:
if self._handle_common_ui_elements(button_code):
return
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']:
if isinstance(focused, AutoSizeButton):
focused.click()
return
elif button_code in BUTTONS['back']:
self.appimage_update_dialog.reject()
return
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_appimage_update_button: {e}")
def handle_appimage_update_dpad(self, code, value, now):
if self.appimage_update_dialog is None:
return
try:
if value == 0:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
changelog = self.appimage_update_dialog.changelog_text
buttons = self.appimage_update_dialog.action_buttons
if changelog and changelog.hasFocus():
if code == PAD_DPAD_Y and value != 0:
scrollbar = changelog.verticalScrollBar()
at_bottom = scrollbar.value() >= scrollbar.maximum()
at_top = scrollbar.value() <= scrollbar.minimum()
if value > 0 and at_bottom:
if buttons:
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
return
if value < 0 and at_top:
return
step = 40
scrollbar.setValue(scrollbar.value() + (step if value > 0 else -step))
return
if code == PAD_DPAD_Y and value != 0 and buttons:
focused = QApplication.focusWidget()
if focused in buttons:
if value < 0 and focused == buttons[0]:
if changelog:
changelog.setFocus(Qt.FocusReason.OtherFocusReason)
return
if code == PAD_DPAD_X and value != 0 and buttons:
focused = QApplication.focusWidget()
if focused in buttons:
idx = buttons.index(focused)
if value < 0:
new_idx = max(0, idx - 1)
else:
new_idx = min(len(buttons) - 1, idx + 1)
buttons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
else:
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_appimage_update_dpad: {e}")
-537
View File
@@ -1,537 +0,0 @@
import time
from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QListView,
QMenu,
QMessageBox,
QScrollArea,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton, NavLabel
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.game_card import AnimatedCard
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.input_manager.constants import (
PAD_AXIS_LEFT_X,
PAD_AXIS_LEFT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class DpadInputMixin(InputMixin):
def handle_dpad_repeat(self) -> None:
"""Handle repeated D-pad input while the D-pad is held."""
if self.current_dpad_code is not None and self.current_dpad_value != 0:
now = time.time()
if (now - self.last_move_time) >= self.current_axis_delay:
self.handle_dpad_slot(self.current_dpad_code, self.current_dpad_value, now)
self.last_move_time = now
self.current_axis_delay = self.repeat_axis_move_delay
def _get_theme_tab_focusables(self) -> list[QWidget]:
"""Return focusable widgets for the themes tab gamepad navigation."""
if self._is_theme_store_visible():
return self._get_theme_store_focusables()
widgets = []
for attr_name in (
"themesCombo",
"themeVariantCombo",
"screenshotsCarousel",
"applyButton",
"deleteThemeButton",
):
widget = getattr(self._parent, attr_name, None)
if self._is_visible_enabled_widget(widget):
widgets.append(widget)
return widgets
def _is_visible_enabled_widget(self, widget: object) -> bool:
return isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled()
def _is_theme_store_visible(self) -> bool:
content_stack = getattr(self._parent, "themeContentStack", None)
store_page = getattr(self._parent, "themeStorePage", None)
return content_stack is not None and content_stack.currentWidget() == store_page
def _get_theme_store_focusables(self) -> list[QWidget]:
store_stack = getattr(self._parent, "themeStoreStack", None)
detail_page = getattr(self._parent, "themeStoreDetailPage", None)
if store_stack is not None and store_stack.currentWidget() == detail_page:
return self._get_theme_store_detail_focusables()
widgets = []
for attr_name in ("themesCombo", "themeStoreSortCombo"):
widget = getattr(self._parent, attr_name, None)
if self._is_visible_enabled_widget(widget):
widgets.append(widget)
widgets.extend(self._get_theme_store_cards())
return widgets
def _get_theme_store_detail_focusables(self) -> list[QWidget]:
widgets = []
for attr_name in (
"themeStoreBackButton",
"themeStoreDownloadButton",
"themeStoreCarousel",
"themeStoreDarkButton",
"themeStoreLightButton",
):
widget = getattr(self._parent, attr_name, None)
if self._is_visible_enabled_widget(widget):
widgets.append(widget)
return widgets
def _get_theme_store_cards(self) -> list[QWidget]:
grid_widget = getattr(self._parent, "themeStoreGridWidget", None)
if not isinstance(grid_widget, QWidget):
return []
cards = grid_widget.findChildren(QWidget, "themeStoreCard")
return [card for card in cards if card.isVisible() and card.isEnabled()]
def _handle_theme_tab_navigation(self, code: int, value: int) -> bool:
"""Handle D-pad focus movement in themes tab."""
theme_tab_index = getattr(self._parent, "theme_tab_index", None)
if theme_tab_index is None or self._parent.stackedWidget.currentIndex() != theme_tab_index:
return False
if self._is_theme_store_visible() and self._handle_theme_store_grid_navigation(code, value):
return True
focused = QApplication.focusWidget()
if code == PAD_DPAD_X and focused in self._get_theme_carousels():
self._scroll_theme_carousel(focused, value)
return True
focusables = self._get_theme_tab_focusables()
if not focusables:
return False
focused = QApplication.focusWidget()
if focused not in focusables:
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return True
current_index = focusables.index(focused)
if code == PAD_DPAD_Y and value > 0 or code == PAD_DPAD_X and value > 0:
next_index = (current_index + 1) % len(focusables)
else:
next_index = (current_index - 1) % len(focusables)
focusables[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
self._ensure_theme_store_widget_visible(focusables[next_index])
return True
def _handle_theme_store_grid_navigation(self, code: int, value: int) -> bool:
cards = self._get_theme_store_cards()
return self._navigate_card_grid(cards, code, value)
def _ensure_theme_store_widget_visible(self, widget: QWidget) -> None:
scroll_area = getattr(self._parent, "themeStoreScrollArea", None)
if isinstance(scroll_area, QScrollArea):
scroll_area.ensureWidgetVisible(widget)
def _get_theme_carousels(self) -> list[QWidget]:
widgets = []
for attr_name in ("screenshotsCarousel", "themeStoreCarousel"):
widget = getattr(self._parent, attr_name, None)
if self._is_visible_enabled_widget(widget):
widgets.append(widget)
return widgets
def _scroll_theme_carousel(self, carousel: QWidget, value: int) -> None:
if value > 0:
scroll_right = getattr(carousel, "scroll_right", None)
if callable(scroll_right):
scroll_right()
elif value < 0:
scroll_left = getattr(carousel, "scroll_left", None)
if callable(scroll_left):
scroll_left()
def _focus_first_dialog_widget(self, dialog: QDialog) -> bool:
focusables = dialog.findChildren(
QWidget,
options=Qt.FindChildOption.FindChildrenRecursively,
)
focusables = [widget for widget in focusables if widget.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if not focusables:
return False
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_add_game_checkbox_dpad(self, dialog: AddGameDialog, code: int, value: int) -> bool:
if code not in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
return False
if code == PAD_AXIS_LEFT_X:
if abs(value) < self.dead_zone:
return True
value = 1 if value > self.dead_zone else -1
checkboxes = [
dialog.add_to_steam_checkbox,
dialog.add_to_menu_checkbox,
dialog.add_to_desktop_checkbox,
]
focused = QApplication.focusWidget()
if not isinstance(focused, QCheckBox) or focused not in checkboxes:
return False
current_index = checkboxes.index(focused)
next_index = current_index + (1 if value > 0 else -1)
if not 0 <= next_index < len(checkboxes):
return False
checkboxes[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_dialog_dpad(self, active: QWidget, code: int, value: int) -> bool:
focused = QApplication.focusWidget()
if isinstance(active, QMessageBox) and not isinstance(focused, QTableWidget):
if not focused or not active.focusWidget():
self._focus_first_dialog_widget(active)
elif code in (PAD_DPAD_X, PAD_DPAD_Y):
active.focusNextChild() if value > 0 else active.focusPreviousChild()
return True
if isinstance(active, AddGameDialog) and code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
return self._handle_add_game_checkbox_dpad(active, code, value)
if not isinstance(active, QDialog):
return False
if code not in (PAD_DPAD_X, PAD_DPAD_Y):
return False
if code == PAD_DPAD_Y and isinstance(focused, QTableWidget):
return False
if not focused or not active.focusWidget():
self._focus_first_dialog_widget(active)
return True
active.focusNextChild() if value > 0 else active.focusPreviousChild()
return True
def _handle_popup_dpad(self, popup: QWidget | None, code: int, value: int) -> bool:
if not isinstance(popup, QMenu):
return False
if code == PAD_DPAD_Y:
self._navigate_menu_actions(popup, direction_down=value > 0)
return True
def _handle_list_dpad(self, focused: QWidget | None, code: int, value: int) -> bool:
if not isinstance(focused, QListView) or code != PAD_DPAD_Y:
return False
model = focused.model()
current_index = focused.currentIndex()
if not model or not current_index.isValid():
return True
next_row = current_index.row() + (1 if value > 0 else -1)
next_row = max(0, min(next_row, model.rowCount() - 1))
focused.setCurrentIndex(model.index(next_row, current_index.column()))
focused.scrollTo(focused.currentIndex(), QListView.ScrollHint.PositionAtCenter)
return True
def _handle_fullscreen_dpad(self, active: QWidget, code: int, value: int) -> bool:
if not isinstance(active, FullscreenDialog) or code != PAD_DPAD_X:
return False
if value < 0:
active.show_prev()
else:
active.show_next()
return True
def _handle_system_section_horizontal(self, code: int, value: int, initial_press: bool) -> bool:
if code != PAD_DPAD_X or not initial_press:
return False
if self._parent.stackedWidget.currentIndex() != getattr(self._parent, "system_tab_index", -1):
return False
switch_relative = getattr(self._parent, "switchSystemSectionRelative", None)
if not callable(switch_relative) or not switch_relative(1 if value > 0 else -1):
return False
section_stack = getattr(self._parent, "systemSectionStack", None)
section_buttons = getattr(self._parent, "systemSectionButtons", [])
if section_stack is None or not section_buttons:
return True
current_index = section_stack.currentIndex()
if 0 <= current_index < len(section_buttons):
section_buttons[current_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _focus_toolbar_from_first_card(self, code: int, value: int) -> bool:
focused = QApplication.focusWidget()
current_index = self._parent.stackedWidget.currentIndex()
if code != PAD_DPAD_Y or value >= 0 or current_index not in (0, 1):
return False
if not isinstance(focused, AnimatedCard):
return False
if current_index == 0:
container = self._parent.gamesListWidget
toolbar_widgets = self._get_library_toolbar_widgets()
focus_target = toolbar_widgets[0] if toolbar_widgets else None
else:
container = self._parent.autoInstallContainer
focus_target = getattr(self._parent, 'autoInstallSearchLineEdit', None)
if container is None or focus_target is None:
return False
cards: list[QWidget] = [
card for card in container.findChildren(AnimatedCard)
if card.isVisible() and card.isEnabled()
]
if not cards or focused not in self._get_card_grid_rows(cards)[0]:
return False
focus_target.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_system_section_vertical(self, code: int, value: int) -> bool:
if code != PAD_DPAD_Y:
return False
if self._parent.stackedWidget.currentIndex() != getattr(self._parent, "system_tab_index", -1):
return False
focused = QApplication.focusWidget()
section_buttons = getattr(self._parent, "systemSectionButtons", [])
if focused not in section_buttons:
return False
if value < 0:
return True
section_stack = getattr(self._parent, "systemSectionStack", None)
focus_targets = getattr(self._parent, "systemSectionFocusTargets", [])
if section_stack is None:
return True
current_index = section_stack.currentIndex()
if 0 <= current_index < len(focus_targets):
target = focus_targets[current_index]
if target is not None and target.isVisible() and target.isEnabled():
target.setFocus(Qt.FocusReason.OtherFocusReason)
return True
@Slot(int, int, float)
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
if self._route_surface_dpad(code, value, current_time):
return
self._handle_default_dpad(code, value, current_time)
def _handle_default_dpad(self, code: int, value: int, current_time: float) -> None:
active_window = QApplication.activeWindow()
if isinstance(active_window, AddGameDialog):
keyboard = getattr(active_window, 'keyboard', None)
else:
keyboard = getattr(self._parent, 'keyboard', None)
if value == 0:
self._reset_dpad_repeat()
return
is_initial_press = not self.axis_moving
self._start_dpad_repeat(code, value, current_time)
if keyboard and keyboard.isVisible() and self._handle_keyboard_dpad(keyboard, code, value):
return
if not self._gamepad_handling_enabled:
return
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
logger.error("gamesListWidget not available yet, skipping D-pad navigation")
return
try:
self._route_dpad_navigation(code, value, is_initial_press)
except Exception as e:
logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True)
def _route_dpad_navigation(self, code: int, value: int, is_initial_press: bool) -> None:
app = QApplication.instance()
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
if not app or not active:
return
combo_popup = (
isinstance(focused, QListView)
and self._find_parent_combo(focused) is not None
)
if is_initial_press and not isinstance(focused, AnimatedCard) and not combo_popup:
SoundManager().play("navigate")
if self._handle_dialog_dpad(active, code, value):
return
if self._handle_popup_dpad(popup, code, value):
return
if self._handle_list_dpad(focused, code, value):
return
if self._handle_fullscreen_dpad(active, code, value):
return
if self._handle_system_section_horizontal(code, value, is_initial_press):
return
if isinstance(focused, QTableWidget):
self.handle_table_navigation(focused, code, value)
return
if self._focus_toolbar_from_first_card(code, value):
return
if self._handle_library_dpad(code, value):
return
if code in (PAD_DPAD_X, PAD_DPAD_Y) and self._handle_theme_tab_navigation(code, value):
return
if self._handle_system_section_vertical(code, value):
return
if self._handle_detail_page_dpad(code, value):
return
self._handle_page_vertical_dpad(code, value)
def _handle_detail_page_dpad(self, code: int, value: int) -> bool:
direction = self._normalize_dpad_direction(code, value)
if direction is None:
return False
focused = QApplication.focusWidget()
page = self._parent.stackedWidget.currentWidget()
current_detail_page = getattr(self._parent, "currentDetailPage", None)
if not isinstance(focused, AutoSizeButton) or page != current_detail_page:
return False
parent = focused.parentWidget()
if parent is None:
return False
rows = self._get_detail_button_rows(parent)
if len(rows) == 1 and len(rows[0]) <= 1:
return False
target = self._get_detail_button_target(rows, focused, direction)
if target is None:
return False
self._focus_detail_button(target)
return True
def _handle_library_dpad(self, code: int, value: int) -> bool:
if code not in (PAD_DPAD_X, PAD_DPAD_Y):
return False
current_index = self._parent.stackedWidget.currentIndex()
if current_index not in (0, 1):
return False
if self._handle_toolbar_navigation(code, value):
return True
if code == PAD_DPAD_Y and value < 0 and self._focus_tab_from_search(current_index):
return True
container = (
self._parent.gamesListWidget
if current_index == 0
else self._parent.autoInstallContainer
)
if container is not None:
self._navigate_game_cards(container, current_index, code, value)
return True
def _handle_page_vertical_dpad(self, code: int, value: int) -> bool:
if code != PAD_DPAD_Y or value == 0:
return False
focused = QApplication.focusWidget()
if focused is None:
return False
if value < 0:
focused.focusPreviousChild()
return True
if not isinstance(focused, NavLabel):
focused.focusNextChild()
return True
page = self._parent.stackedWidget.currentWidget()
focusables = page.findChildren(
QWidget,
options=Qt.FindChildOption.FindChildrenRecursively,
)
focusables = [
widget for widget in focusables
if widget.focusPolicy() & Qt.FocusPolicy.StrongFocus
]
if not focusables:
return False
focusables[0].setFocus()
return True
def _normalize_dpad_direction(self, code: int, value: int) -> tuple[int, int] | None:
if code in (PAD_DPAD_X, PAD_DPAD_Y):
return (code, value) if value != 0 else None
if code not in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y) or abs(value) < self.dead_zone:
return None
normalized_code = PAD_DPAD_X if code == PAD_AXIS_LEFT_X else PAD_DPAD_Y
return normalized_code, 1 if value > self.dead_zone else -1
def _get_detail_button_rows(self, parent: QWidget) -> list[list[AutoSizeButton]]:
buttons = parent.findChildren(
AutoSizeButton,
options=Qt.FindChildOption.FindDirectChildrenOnly,
)
buttons = [button for button in buttons if button.isVisible() and button.isEnabled()]
centers = {button: button.geometry().center() for button in buttons}
rows: list[list[AutoSizeButton]] = []
for button in sorted(buttons, key=lambda item: (centers[item].y(), centers[item].x())):
if not rows or abs(centers[button].y() - centers[rows[-1][0]].y()) > 24:
rows.append([button])
else:
rows[-1].append(button)
for row in rows:
row.sort(key=lambda item: centers[item].x())
return rows
def _get_detail_button_target(
self, rows: list[list[AutoSizeButton]], focused: AutoSizeButton,
direction: tuple[int, int],
) -> AutoSizeButton | None:
row_index = next((index for index, row in enumerate(rows) if focused in row), -1)
if row_index < 0:
return None
code, value = direction
if code == PAD_DPAD_X:
flat = [button for row in rows for button in row]
index = flat.index(focused) + value
return flat[index] if 0 <= index < len(flat) else None
target_row_index = row_index + value
if not 0 <= target_row_index < len(rows):
return None
focused_x = focused.geometry().center().x()
return min(
rows[target_row_index],
key=lambda button: abs(button.geometry().center().x() - focused_x),
)
def _focus_detail_button(self, target: AutoSizeButton) -> None:
target.setFocus(Qt.FocusReason.OtherFocusReason)
parent = target.parentWidget()
while parent and not isinstance(parent, QScrollArea):
parent = parent.parentWidget()
if isinstance(parent, QScrollArea):
parent.ensureWidgetVisible(target, 20, 20)
def _reset_dpad_repeat(self) -> None:
self.current_dpad_code = None
self.current_dpad_value = 0
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.stop()
def _start_dpad_repeat(self, code: int, value: int, current_time: float) -> None:
self.current_dpad_code = code
self.current_dpad_value = value
if self.axis_moving:
return
self.axis_moving = True
self.last_move_time = current_time
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))
def _handle_keyboard_dpad(self, keyboard: VirtualKeyboard, code: int, value: int) -> bool:
directions = {
PAD_DPAD_X: (keyboard.move_focus_left, keyboard.move_focus_right),
PAD_AXIS_LEFT_X: (keyboard.move_focus_left, keyboard.move_focus_right),
PAD_DPAD_Y: (keyboard.move_focus_up, keyboard.move_focus_down),
PAD_AXIS_LEFT_Y: (keyboard.move_focus_up, keyboard.move_focus_down),
}
actions = directions.get(code)
if actions is None:
return False
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
if abs(value) < self.dead_zone:
self._reset_dpad_repeat()
return True
value = 1 if value > self.dead_zone else -1
actions[1 if value > 0 else 0]()
return True
-317
View File
@@ -1,317 +0,0 @@
import os
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QApplication,
QDialog,
QLineEdit,
QMenu,
QPushButton,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.input_manager.constants import (
BUTTONS,
PAD_AXIS_LEFT_X,
PAD_AXIS_LEFT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.logger import get_logger
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class FileExplorerInputMixin(InputMixin):
# FILE EXPLORER MODE
def enable_file_explorer_mode(self, file_explorer):
"""Configure gamepad handling for FileExplorer"""
try:
self._setup_mode_handlers(
file_explorer,
self.handle_file_explorer_button,
self.handle_file_explorer_dpad,
'file_explorer'
)
logger.debug("Gamepad handling successfully connected for FileExplorer")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for FileExplorer: {e}")
def disable_file_explorer_mode(self):
"""Restore original handlers (return default)"""
try:
if self.file_explorer:
# Additional cleanup for file explorer
self.nav_timer.stop()
self._restore_original_handlers('file_explorer')
logger.debug("Gamepad handling successfully restored")
except Exception as e:
logger.error(f"Error restoring gamepad handlers: {e}")
def handle_file_explorer_button(self, button_code, value):
active_window = QApplication.activeWindow()
dialog_keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if isinstance(dialog_keyboard, VirtualKeyboard) and dialog_keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return
if value == 0: # Ignore releases
return
try:
# 1. Handle Popups (Menus)
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']: # A button
if popup.activeAction():
popup.activeAction().trigger()
popup.close()
return
elif button_code in BUTTONS['back']: # B button
popup.close()
return
return # Skip other handling if menu is open
active_window = QApplication.activeWindow()
if isinstance(active_window, QDialog) and active_window != self.file_explorer:
if button_code in BUTTONS['confirm']: # A button
focused_widget = QApplication.focusWidget()
if isinstance(focused_widget, QLineEdit):
keyboard = getattr(active_window, 'keyboard', None)
if not keyboard:
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard:
focused_widget.setFocus()
keyboard.show_for_widget(focused_widget)
elif isinstance(focused_widget, QPushButton):
focused_widget.click()
else:
active_window.accept()
elif button_code in BUTTONS['back']: # B button
active_window.reject()
return
# 2. Validate File Explorer state
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
logger.debug("No file explorer or file_list available")
return
focused_widget = QApplication.focusWidget()
# 3. Handle Buttons
if button_code in BUTTONS['confirm']: # A button
# Check if a drive button is focused
if isinstance(focused_widget, AutoSizeButton) and \
hasattr(self.file_explorer, 'drive_buttons') and \
focused_widget in self.file_explorer.drive_buttons:
self.file_explorer.select_drive()
return
if self.file_explorer.file_list.count() == 0:
return
selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
self.file_explorer.current_path = os.path.normpath(full_path)
self.file_explorer.update_file_list()
elif not self.file_explorer.directory_only:
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
else:
logger.debug(f"Selected item is not a directory: {full_path}")
elif button_code in BUTTONS['context_menu']: # Start button
if self.file_explorer.file_list.count() == 0:
return
current_item = self.file_explorer.file_list.currentItem()
if current_item:
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
self.file_explorer.show_folder_context_menu(item_rect.center())
elif button_code in BUTTONS['add_game']: # X button
if self.file_explorer.file_list.count() == 0:
return
selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
elif button_code in BUTTONS['back']: # B button
self.file_explorer.close()
elif button_code in BUTTONS['prev_dir']: # Y button
self.file_explorer.previous_dir()
else:
self._handle_default_button(button_code, value)
except Exception as e:
logger.error(f"Error in FileExplorer button handler: {e}")
def handle_file_explorer_dpad(self, code, value, current_time):
try:
active_window = QApplication.activeWindow()
keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if keyboard is None:
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
normalized_value = value
if code == PAD_AXIS_LEFT_X:
if abs(value) < self.dead_zone:
return
normalized_value = 1 if value > self.dead_zone else -1
if normalized_value > 0:
keyboard.move_focus_right()
elif normalized_value < 0:
keyboard.move_focus_left()
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
normalized_value = value
if code == PAD_AXIS_LEFT_Y:
if abs(value) < self.dead_zone:
return
normalized_value = 1 if value > self.dead_zone else -1
if normalized_value > 0:
keyboard.move_focus_down()
elif normalized_value < 0:
keyboard.move_focus_up()
return
# 1. Handle Popups (Menus)
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if code == PAD_DPAD_Y and value != 0:
self._navigate_menu_actions(popup, direction_down=value > 0)
return
focused_widget = QApplication.focusWidget()
if isinstance(active_window, QDialog) and active_window != self.file_explorer:
if not focused_widget or not active_window.focusWidget():
focusables = active_window.findChildren(
QWidget,
options=Qt.FindChildOption.FindChildrenRecursively
)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return
if code == PAD_DPAD_X and value != 0:
if value > 0:
active_window.focusNextChild()
elif value < 0:
active_window.focusPreviousChild()
return
if code == PAD_DPAD_Y and value != 0 and not isinstance(focused_widget, QTableWidget):
if value > 0:
active_window.focusNextChild()
elif value < 0:
active_window.focusPreviousChild()
return
# 2. Validate State
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
return
focused_widget = QApplication.focusWidget()
# 3. Handle Drive Buttons Navigation (Horizontal)
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X) and \
hasattr(self.file_explorer, 'drive_buttons') and \
self.file_explorer.drive_buttons:
if code == PAD_DPAD_X and value == 0:
return
is_drive_button = (
isinstance(focused_widget, AutoSizeButton) and
focused_widget in self.file_explorer.drive_buttons
)
if code == PAD_AXIS_LEFT_X:
if abs(value) < self.dead_zone or not is_drive_button:
return
value = 1 if value > self.dead_zone else -1
if not is_drive_button:
# Focus first drive button if not currently on one
self.file_explorer.drive_buttons[0].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
return
current_idx = self.file_explorer.drive_buttons.index(focused_widget)
next_idx = current_idx
if value < 0: # Left
next_idx = max(current_idx - 1, 0)
elif value > 0: # Right
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
if next_idx != current_idx:
self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
return
# 4. Handle Vertical Navigation (File List vs Drive Buttons)
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
# Move from buttons to list
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
if code == PAD_DPAD_Y and value > 0 and self.file_explorer.file_list.count() > 0:
self.file_explorer.file_list.setFocus()
self.file_explorer.file_list.setCurrentRow(0)
self.file_explorer.file_list.scrollToItem(self.file_explorer.file_list.currentItem())
return
# D-pad: Fixed speed
if code == PAD_DPAD_Y:
if value != 0:
self.current_direction = value
self.stick_value = 1.0
if not self.nav_timer.isActive():
self.file_explorer.move_selection(self.current_direction)
self.last_nav_time = current_time
self.nav_timer.start(int(self.initial_nav_delay * 1000))
else:
self.current_direction = 0
self.nav_timer.stop()
# Stick: Analog speed
elif code == PAD_AXIS_LEFT_Y:
if abs(value) < self.dead_zone:
if self.stick_activated:
self.current_direction = 0
self.nav_timer.stop()
self.stick_activated = False
return
normalized_value = (abs(value) - self.dead_zone) / (32768 - self.dead_zone)
speed_factor = 0.3 + (normalized_value * 0.7)
self.current_direction = -1 if value < 0 else 1
self.stick_value = speed_factor
self.stick_activated = True
if not self.nav_timer.isActive():
self.file_explorer.move_selection(self.current_direction)
self.last_nav_time = current_time
self.nav_timer.start(int(self.initial_nav_delay * 1000))
# 5. Fallback
else:
self._handle_default_dpad(code, value, current_time)
except Exception as e:
logger.error(f"Error in FileExplorer dpad handler: {e}")
-611
View File
@@ -1,611 +0,0 @@
import os
import time
from typing import cast
from shiboken6 import isValid
from PySide6.QtCore import QEvent, QObject, QPoint, Qt
from PySide6.QtGui import QKeySequence
from PySide6.QtWidgets import (
QAbstractScrollArea,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QLineEdit,
QListWidget,
QMenu,
QMessageBox,
QScrollArea,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.game_card import AnimatedCard, GameCard
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.input_manager.constants import (
BUTTONS,
KEY_BACKSPACE,
KEY_DOWN,
KEY_E,
KEY_ENTER,
KEY_ESCAPE,
KEY_F5,
KEY_F10,
KEY_F11,
KEY_LEFT,
KEY_Q,
KEY_RETURN,
KEY_RIGHT,
KEY_UP,
MOD_CTRL,
MOD_SHIFT,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class KeyboardInputMixin(InputMixin):
def handle_virtual_keyboard(self, button_code: int, value: int) -> None:
active_window = QApplication.activeWindow()
keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if keyboard is None:
keyboard = getattr(self._parent, 'keyboard', None)
if not keyboard or not isinstance(keyboard, VirtualKeyboard) or not keyboard.isVisible():
return
# Handle gamepad buttons
if button_code in BUTTONS['confirm']: # A/Cross button - confirm
if value == 1:
keyboard.activateFocusedKey()
elif button_code in BUTTONS['back']: # B/Circle button - hide keyboard
if value == 1:
keyboard.hide()
# Return focus to input field
if keyboard.current_input_widget:
keyboard.current_input_widget.setFocus()
elif button_code in BUTTONS['prev_tab']: # LB/L1 - switch layout
if value == 1:
keyboard.on_lang_click()
elif button_code in BUTTONS['next_tab']: # RB/R1 - toggle Shift
if value == 1:
keyboard.on_shift_click(not keyboard.shift_pressed)
elif button_code in BUTTONS['context_menu']: # Start button - confirm
if value == 1:
keyboard.activateFocusedKey()
elif button_code in BUTTONS['menu']: # Select button - hide keyboard
if value == 1:
keyboard.hide()
# Return focus to input field
if keyboard.current_input_widget:
keyboard.current_input_widget.setFocus()
elif button_code in BUTTONS['add_game']: # X button - Backspace (now holdable)
if value == 1:
keyboard.on_backspace_pressed()
elif value == 0:
keyboard.stop_backspace_repeat()
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
app = QApplication.instance()
if not app:
return QObject.eventFilter(cast(QObject, self), obj, event)
if event.type() == QEvent.Type.Show:
if isinstance(obj, QMenu):
SoundManager().play("open")
elif isinstance(obj, QWidget) and obj.windowType() == Qt.WindowType.Popup:
parent = obj.parent()
while isinstance(parent, QObject):
if isinstance(parent, QComboBox):
if not parent.property("_sound_activated_connected"):
parent.activated.connect(
lambda _index: SoundManager().play("toggle"),
)
parent.setProperty("_sound_activated_connected", True)
break
parent = parent.parent()
if event.type() == QEvent.Type.Wheel:
combo = obj if isinstance(obj, QComboBox) else None
parent = obj.parent() if isinstance(obj, QObject) else None
while combo is None and isinstance(parent, QObject):
if isinstance(parent, QComboBox):
combo = parent
break
parent = parent.parent()
if isinstance(combo, QComboBox) and not combo.view().isVisible():
# Find parent scroll area
scrollable = combo.parent()
while scrollable:
if isinstance(scrollable, QAbstractScrollArea):
old_focus = QApplication.focusWidget()
QApplication.sendEvent(scrollable.viewport(), event)
if old_focus:
old_focus.setFocus()
return True
scrollable = scrollable.parent()
return True
if event.type() == QEvent.Type.KeyPress:
if self._redirect_gamecard_input_to_search(event):
return True
key = self._qt_event_to_input_key(event)
if key is not None:
return self._handle_input_key_press(key, self._qt_modifiers_to_input(event))
if event.type() == QEvent.Type.KeyRelease:
key = self._qt_event_to_input_key(event)
if key is not None:
return self._handle_input_key_release(key)
if event.type() == QEvent.Type.MouseButtonPress:
button_method = getattr(event, "button", None)
button = button_method() if callable(button_method) else None
if button == Qt.MouseButton.ExtraButton1:
self._handle_back_mouse_button()
return True
if event.type() == QEvent.Type.MouseButtonRelease:
button_method = getattr(event, "button", None)
button = button_method() if callable(button_method) else None
if button == Qt.MouseButton.LeftButton:
SoundManager().play_widget_sound(obj)
# Ensure obj is a QObject
if not isinstance(obj, QObject):
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
return False
return QObject.eventFilter(cast(QObject, self), obj, event)
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
native_key = self._native_scan_to_input_key(event)
if native_key is not None:
return native_key
text_method = getattr(event, "text", None)
text = text_method() if callable(text_method) else ""
text = text if isinstance(text, str) else ""
normalized_text = text.lower()
if len(normalized_text) == 1 and normalized_text.isprintable():
return ord(normalized_text)
key_method = getattr(event, "key", None)
key_value = key_method() if callable(key_method) else 0
key = key_value if isinstance(key_value, int) else 0
name = QKeySequence(key).toString()
if len(name) == 1 and name.isprintable():
return ord(name.lower())
key_names = {
"Backspace": KEY_BACKSPACE,
"Down": KEY_DOWN,
"Enter": KEY_ENTER,
"Esc": KEY_ESCAPE,
"F5": KEY_F5,
"F10": KEY_F10,
"F11": KEY_F11,
"Left": KEY_LEFT,
"Return": KEY_RETURN,
"Right": KEY_RIGHT,
"Up": KEY_UP,
}
return key_names.get(name)
def _native_scan_to_input_key(self, event: QEvent) -> int | None:
scan_method = getattr(event, "nativeScanCode", None)
scan_value = scan_method() if callable(scan_method) else 0
scan_code = scan_value if isinstance(scan_value, int) else 0
scan_keys = {
16: KEY_Q,
18: KEY_E,
24: KEY_Q,
26: KEY_E,
}
return scan_keys.get(scan_code)
def _qt_modifiers_to_input(self, event: QEvent) -> int:
modifiers_method = getattr(event, "modifiers", None)
modifiers = modifiers_method() if callable(modifiers_method) else None
input_modifiers = 0
if isinstance(modifiers, Qt.KeyboardModifier) and modifiers & Qt.KeyboardModifier.ControlModifier:
input_modifiers |= MOD_CTRL
if isinstance(modifiers, Qt.KeyboardModifier) and modifiers & Qt.KeyboardModifier.ShiftModifier:
input_modifiers |= MOD_SHIFT
return input_modifiers
def _handle_back_mouse_button(self) -> None:
active_win = QApplication.activeWindow()
focused = self._focused_widget()
if isinstance(focused, QLineEdit):
return
if isinstance(active_win, QDialog):
SoundManager().play("back")
active_win.reject()
return
self._parent.goBackDetailPage(self._parent.currentDetailPage)
def _focused_widget(self) -> QWidget | None:
focused = QApplication.focusWidget()
if focused is None or not isValid(focused):
return None
return focused
_GAMECARD_SEARCH_TABS: dict[int, str] = {
0: 'searchEdit',
1: 'autoInstallSearchLineEdit',
}
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
focused = self._focused_widget()
if not isinstance(focused, GameCard):
return False
tab_index = self._parent.stackedWidget.currentIndex()
attr_name = self._GAMECARD_SEARCH_TABS.get(tab_index)
if attr_name is None:
return False
text_method = getattr(event, "text", None)
text = text_method() if callable(text_method) else ""
text = text if isinstance(text, str) else ""
if len(text) != 1 or not text.isprintable():
return False
search_edit = getattr(self._parent, attr_name, None)
if not isinstance(search_edit, QLineEdit) or not isValid(search_edit):
return False
search_edit.setFocus()
search_edit.insert(text)
return True
def _activate_focused_widget(self, focused: QWidget | None) -> None:
if focused is None or not isValid(focused):
return
try:
self._parent.activateFocusedWidget()
except RuntimeError as e:
logger.debug("Focused widget was deleted before activation: %s", e)
def _handle_input_key_press(self, key: int, modifiers: int) -> bool:
if self._handle_input_system_key(key, modifiers):
return True
if self._handle_input_file_explorer_key(key):
return True
if self._handle_input_text_key(key):
return True
if self._handle_input_dialog_key(key):
return True
if self._handle_input_tab_key(key):
return True
if self._handle_input_arrow_press(key):
return True
return self._handle_input_action_key(key, modifiers)
def _handle_input_system_key(self, key: int, modifiers: int) -> bool:
if key == KEY_F5:
self._parent.refreshGames()
return True
if key == KEY_Q and modifiers & MOD_CTRL:
app = QApplication.instance()
if app is not None:
app.quit()
return True
if key == KEY_F11 and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen)
return True
return False
def _handle_input_file_explorer_key(self, key: int) -> bool:
file_explorer = self.file_explorer
if not file_explorer:
return False
focused = self._focused_widget()
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
return False
if key in (KEY_RETURN, KEY_ENTER):
self._activate_file_explorer_focus()
return True
if key == KEY_BACKSPACE:
file_explorer.previous_dir()
return True
return False
def _activate_file_explorer_focus(self) -> None:
file_explorer = self.file_explorer
if file_explorer is None:
return
focused = self._focused_widget()
if (
isinstance(focused, AutoSizeButton) and
hasattr(file_explorer, 'drive_buttons') and
focused in file_explorer.drive_buttons
):
file_explorer.select_drive()
return
if file_explorer.file_list.count() > 0:
self._activate_file_explorer_item(file_explorer.file_list)
return
self._activate_focused_widget(focused)
def _activate_file_explorer_item(self, focused: QListWidget) -> None:
file_explorer = self.file_explorer
if file_explorer is None:
return
current_item = focused.currentItem()
if current_item is None and focused.count() > 0:
focused.setCurrentRow(0)
current_item = focused.currentItem()
if not current_item:
return
selected = current_item.text()
full_path = os.path.join(file_explorer.current_path, selected)
if os.path.isdir(full_path):
if selected == "../":
file_explorer.previous_dir()
else:
file_explorer.current_path = os.path.normpath(full_path)
file_explorer.update_file_list()
elif not file_explorer.directory_only:
file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
file_explorer.accept()
def _handle_input_text_key(self, key: int) -> bool:
focused = self._focused_widget()
if isinstance(focused, QLineEdit) and key in (KEY_LEFT, KEY_RIGHT):
if key == KEY_LEFT:
focused.cursorBackward(False, 1)
else:
focused.cursorForward(False, 1)
return True
return False
def _focused_editable_combo(self, focused: QWidget | None) -> bool:
if isinstance(focused, QComboBox) and focused.isEditable():
return True
parent = focused.parentWidget() if focused else None
return isinstance(parent, QComboBox) and parent.isEditable()
def _handle_input_dialog_key(self, key: int) -> bool:
active_win = QApplication.activeWindow()
focused = self._focused_widget()
if isinstance(active_win, FullscreenDialog):
return self._handle_input_fullscreen_dialog_key(active_win, key)
if key != KEY_ESCAPE:
return False
settings_dialog = self.settings_dialog
if settings_dialog is not None:
open_combo = self._get_open_settings_combo()
if open_combo:
open_combo.hidePopup()
settings_dialog.advanced_table.setFocus()
return True
if isinstance(focused, QLineEdit):
return False
if isinstance(active_win, QDialog):
active_win.reject()
return True
return False
def _handle_input_fullscreen_dialog_key(self, active_win: FullscreenDialog, key: int) -> bool:
if key in (KEY_ESCAPE, KEY_RETURN, KEY_ENTER, KEY_BACKSPACE):
active_win.close()
return True
if key == KEY_LEFT:
active_win.show_prev()
return True
if key == KEY_RIGHT:
active_win.show_next()
return True
return False
def _handle_input_tab_key(self, key: int) -> bool:
if key not in (KEY_LEFT, KEY_RIGHT):
return False
focused = self._focused_widget()
active = QApplication.activeWindow()
if self.file_explorer or isinstance(active, QMessageBox | QDialog):
return False
if isinstance(focused, AnimatedCard | QLineEdit | QTableWidget | AutoSizeButton | QCheckBox):
return False
return self._switch_visible_tab(-1 if key == KEY_LEFT else 1)
def _focus_tab_from_search(self, current_index: int) -> bool:
focused = self._focused_widget()
search_edit = (
getattr(self._parent, 'searchEdit', None)
if current_index == 0
else getattr(self._parent, 'autoInstallSearchLineEdit', None)
)
if focused is not search_edit:
return False
tab_button = self._parent.tabButtons.get(current_index)
if tab_button is None or not tab_button.isVisible():
return False
tab_button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _get_library_toolbar_widgets(self) -> list[QWidget]:
widgets = []
for attr_name in (
"quickLaunchButton",
"addGameButton",
"searchEdit",
"refreshButton",
"deleteMissingExeButton",
"libraryControlsButton",
):
widget = getattr(self._parent, attr_name, None)
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
widgets.append(widget)
return widgets
def _get_library_filter_widgets(self) -> list[QWidget]:
controls_widget = getattr(self._parent, "libraryControlsWidget", None)
if not isinstance(controls_widget, QWidget) or not controls_widget.isVisible():
return []
widgets = []
for attr_name in (
"gamesSortCombo",
"gamesDisplayCombo",
"onlyInstalledCheckBox",
"gamesBadgeViewCombo",
):
widget = getattr(self._parent, attr_name, None)
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
widgets.append(widget)
return widgets
def _focus_library_filter_controls(self) -> bool:
controls_button = getattr(self._parent, "libraryControlsButton", None)
if not isinstance(controls_button, AutoSizeButton):
return False
if not controls_button.isChecked():
controls_button.setChecked(True)
toggle_controls = getattr(self._parent, "_toggle_library_controls", None)
if callable(toggle_controls):
toggle_controls()
filter_widgets = self._get_library_filter_widgets()
if not filter_widgets:
return False
filter_widgets[0].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _focus_first_game_card(self, container: QWidget | None) -> bool:
if container is None:
return False
for card in container.findChildren(AnimatedCard):
if card.isVisible() and card.isEnabled():
card.setFocus(Qt.FocusReason.OtherFocusReason)
scroll_area = container.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
if isinstance(scroll_area, QScrollArea):
scroll_area.ensureWidgetVisible(card, 50, 50)
return True
return False
def _focus_first_library_card(self) -> bool:
container = getattr(self._parent, "gamesListWidget", None)
return self._focus_first_game_card(container)
def _get_autoinstall_toolbar_widgets(self) -> list[QWidget]:
widgets = []
for attr_name in ("autoInstallSearchLineEdit", "autoInstallRefreshButton"):
widget = getattr(self._parent, attr_name, None)
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
widgets.append(widget)
return widgets
def _focus_first_autoinstall_card(self) -> bool:
container = getattr(self._parent, "autoInstallContainer", None)
return self._focus_first_game_card(container)
def _handle_toolbar_navigation(self, code: int, value: int) -> bool:
current_index = self._parent.stackedWidget.currentIndex()
if value == 0 or current_index not in (0, 1):
return False
if current_index == 0:
toolbar_widgets = self._get_library_toolbar_widgets()
focus_first_card = self._focus_first_library_card
else:
toolbar_widgets = self._get_autoinstall_toolbar_widgets()
focus_first_card = self._focus_first_autoinstall_card
focused = self._focused_widget()
filter_widgets = self._get_library_filter_widgets() if current_index == 0 else []
if focused in filter_widgets:
if code == PAD_DPAD_X:
widget_index = filter_widgets.index(cast(QWidget, focused))
next_index = widget_index + (1 if value > 0 else -1)
if 0 <= next_index < len(filter_widgets):
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
if code != PAD_DPAD_Y:
return False
if value < 0:
controls_button = getattr(self._parent, "libraryControlsButton", None)
if isinstance(controls_button, QWidget):
controls_button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
return focus_first_card()
if focused not in toolbar_widgets:
return False
if code == PAD_DPAD_X:
widget_index = toolbar_widgets.index(cast(QWidget, focused))
next_index = widget_index + (1 if value > 0 else -1)
if 0 <= next_index < len(toolbar_widgets):
toolbar_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
if code != PAD_DPAD_Y:
return False
if value > 0:
controls_button = getattr(self._parent, "libraryControlsButton", None)
if focused is controls_button and self._focus_library_filter_controls():
return True
return focus_first_card()
tab_button = self._parent.tabButtons.get(current_index)
if tab_button is not None and tab_button.isVisible():
tab_button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _switch_visible_tab(self, step: int) -> bool:
idx = self._parent.stackedWidget.currentIndex()
visible = [i for i, btn in self._parent.tabButtons.items() if btn.isVisible()]
visible.sort()
if not visible:
return False
try:
current_pos = visible.index(idx)
except ValueError:
current_pos = 0
new_idx = visible[(current_pos + step) % len(visible)]
self._parent.switchTab(new_idx)
self._parent.tabButtons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_input_arrow_press(self, key: int) -> bool:
dpad = {
KEY_UP: (PAD_DPAD_Y, -1),
KEY_DOWN: (PAD_DPAD_Y, 1),
KEY_LEFT: (PAD_DPAD_X, -1),
KEY_RIGHT: (PAD_DPAD_X, 1),
}.get(key)
if dpad is None:
return False
self.dpad_moved.emit(dpad[0], dpad[1], time.time())
return True
def _handle_input_action_key(self, key: int, modifiers: int) -> bool:
focused = self._focused_widget()
if isinstance(focused, GameCard) and key == KEY_F10 and modifiers & MOD_SHIFT:
pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos)
return True
if key in (KEY_RETURN, KEY_ENTER):
if isinstance(focused, QTableWidget):
self.handle_table_confirm(focused)
else:
self._activate_focused_widget(focused)
return True
elif key in (KEY_ESCAPE, KEY_BACKSPACE):
return self._handle_input_back_key(focused)
elif key == KEY_E and not isinstance(focused, QLineEdit):
if self._parent.stackedWidget.currentIndex() == 0:
self._parent.openAddGameDialog()
return True
return False
def _handle_input_back_key(self, focused: QWidget | None) -> bool:
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
return False
self._handle_standard_back()
return True
def _handle_input_key_release(self, key: int) -> bool:
if key in (KEY_UP, KEY_DOWN):
self.dpad_moved.emit(PAD_DPAD_Y, 0, time.time())
return True
elif key in (KEY_LEFT, KEY_RIGHT):
self.dpad_moved.emit(PAD_DPAD_X, 0, time.time())
return True
return False
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more