Compare commits

..
Author SHA1 Message Date
Boria138 e1b5bb3b01 feat(steamplay): use common PortProton launch path
Code check / Check code (pull_request) Failing after 2m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 11:47:22 +05:00
Boria138 b8e06201e2 chore(locale): update
Code check / Check code (push) Successful in 2m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 00:35:38 +05:00
Boria138 7f8c31fbb8 fix: correct Wine dropdown actions and tooltips
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 00:34:52 +05:00
Boria138 c4d28f8f72 chore: hide Steam CEF shortcut success dialog
Code check / Check code (push) Successful in 2m25s
2026-10-21 00:02:06 +05:00
Boria138 06c3bf3ff4 docs: update changelog and agent guidelines 2026-10-21 00:00:27 +05:00
Boria138 45341a35a9 feat: import games from folders 2026-10-20 23:59:40 +05:00
Boria138 6c0da73df4 fix: preserve matched Steam games and covers on lookup errors 2026-10-20 23:56:02 +05:00
Boria138 43f9219ecd perf: use orjson for cache output and PPDB responses 2026-10-20 23:54:42 +05:00
Boria138 635038c7c4 perf: read executable metadata with pefile before exiftool 2026-10-20 23:53:59 +05:00
Boria138 c5326efc48 chore(locale): update
Code check / Check code (push) Successful in 2m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-20 19:00:07 +05:00
Boria138 a27cbf57d2 feat: add card multiselection and bulk actions
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-20 18:59:38 +05:00
Boria138 6fdcfb212e fix: fall back to English for unsupported locales
Code check / Check code (push) Successful in 2m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-20 12:11:34 +05:00
Boria138 78bb8391bf feat: install and apply themes via drag and drop
Code check / Check code (push) Successful in 2m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-20 12:01:55 +05:00
Boria138 2bb47c0d0e Revert "feat: open virtual keyboard by clicking search icons"
Code check / Check code (push) Successful in 2m18s
This reverts commit 4ef0184c81.
2026-10-19 23:11:33 +05:00
Boria138 4ef0184c81 feat: open virtual keyboard by clicking search icons
Code check / Check code (push) Successful in 2m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-19 23:04:55 +05:00
Boria138 1eb9800a30 fix: preserve text input focus when hovering game cards
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-19 23:02:35 +05:00
Boria138 2caab288dd Revert "build(appimage): pin sharun to working 2.2.7"
Code check / Check code (pull_request) Successful in 2m13s
Package Build - AppImage, Arch, Fedora, Debian / changes (pull_request) Successful in 17s
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (43) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (44) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (45) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (rawhide) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Arch Package (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Debian Package (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build AppImage (pull_request) Canceled after 0s
Code check / Check code (push) Successful in 2m11s
This reverts commit e21bb09081.

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-19 14:18:45 +05:00
Boria138 e80773c601 chore(localization): update
Code check / Check code (push) Failing after 23m54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-19 10:51:27 +05:00
Boria138 4b42d19bea feat: apply exe and user.conf settings automatically
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-19 10:48:24 +05:00
Boria138 d04f8fdc9e chore: make Cyrillic path warning optional
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-19 10:30:40 +05:00
Boria138 40a3cacc36 feat: add PPDB download to crash report dialog
Code check / Check code (push) Successful in 12m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-18 20:43:44 +05:00
Boria138 d54c84f723 chore(locale): update
Code check / Check code (push) Failing after 21m28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-18 20:17:15 +05:00
Boria138 2cfbe08323 fix: cancel game launch when paths contain Cyrillic
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-18 20:14:14 +05:00
Boria138 d6684691a4 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-18 20:09:00 +05:00
Boria138 0bf87e172d Merge branch 'weblate' 2026-10-18 20:06:53 +05:00
Boria138 07bfa9611f feat: warn about Cyrillic paths before game launch
Code check / Check code (push) Successful in 32m57s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-18 20:05:47 +05:00
Boria138 1f4ee890cf test: remove redundant checks
Code check / Check code (push) Failing after 10m0s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-18 11:45:14 +05:00
Boria138 8feb58c5e0 test: replace timing thresholds in library loading
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-18 11:45:13 +05:00
Boria138 f47ff16e97 feat: add logind/elogind sleep inhibition fallback
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-18 11:45:13 +05:00
Renovate Bot 47f8faeaf2 chore(deps): update archlinux:base-devel docker digest to 0616799
Code check / Check code (push) Successful in 2m31s
2026-10-18 06:05:54 +00:00
Renovate Bot 2085fff8c3 chore(deps): update debian docker tag to trixie-20261005
Code check / Check code (pull_request) Successful in 32m50s
Package Build - AppImage, Arch, Fedora, Debian / changes (pull_request) Successful in 17s
Package Build - AppImage, Arch, Fedora, Debian / Build AppImage (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (43) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (44) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (45) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (rawhide) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Arch Package (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Debian Package (pull_request) Skipped
Code check / Check code (push) Successful in 32m52s
2026-10-18 00:02:59 +00:00
Codeberg Translateandadem e8ddd4372e Translated using Weblate (Russian)
Currently translated at 100.0% (798 of 798 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-10-17 23:27:15 +00:00
Codeberg Translateandrogerio-mattos 1bfacb887b Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (798 of 798 strings)

Co-authored-by: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/
Translation: PortProtonQt/PortProtonQt
2026-10-17 23:27:15 +00:00
Gitea Actions 461527eb0c chore: update steam apps list 2026-10-16T16:11:53Z 2026-10-16 16:11:54 +00:00
Boria138 eb3d507f9d fix: avoid deleted Qt objects in keyboard filter
Code check / Check code (push) Successful in 2m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-16 12:57:29 +05:00
Boria138 d501dfcecf fix: skip AppImage update checks in silent mode
Code check / Check code (push) Canceled after 1m55s
2026-10-16 11:56:34 +05:00
Boria138 e21bb09081 build(appimage): pin sharun to working 2.2.7
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-16 11:56:34 +05:00
Boria138 fa8e8478e5 fix: avoid keyboard filter recursion on shutdown
Code check / Check code (push) Successful in 2m38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-15 23:55:02 +05:00
Boria138 51b78b506c fix: stop continuous repainting in exe settings
Code check / Check code (push) Successful in 32m49s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-15 22:23:10 +05:00
Boria138 1776dd9e94 fix: prevent initial setup racing silent launches
Code check / Check code (push) Canceled after 0s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-15 20:34:18 +05:00
80 changed files with 24907 additions and 2571 deletions

No files matched your search

+10
View File
@@ -31,6 +31,11 @@ jobs:
image: archlinux:base-devel
options: --privileged --device /dev/fuse
steps:
- name: Configure Arch mirror
run: |
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
@@ -69,6 +74,11 @@ jobs:
image: archlinux:base-devel
steps:
- name: Configure Arch mirror
run: |
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
run: |
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
+12 -24
View File
@@ -22,37 +22,25 @@ jobs:
name: Check code
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
steps:
- name: Rank Arch mirrors
- name: Configure Arch mirror
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Install system dependencies
- name: Install CI dependencies
run: |
pacman -Syu --noconfirm --needed git nodejs npm mesa libarchive libpulse sdl3 pyside6 qt6-svg qt6-imageformats uv
pacman -Scc --noconfirm
pacman -Syu --noconfirm --needed git nodejs npm
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Sync dependencies into venv
run: uv sync --all-extras --dev
- name: Activate .venv & run pre-commit
- name: Install project and check dependencies
shell: bash
run: |
source .venv/bin/activate
pre-commit run --show-diff-on-failure --color=always --all-files
source build-aux/PKGBUILD-git
pacman -S --noconfirm --needed "${depends[@]}" python-qrcode libpulse mesa pre-commit pyright python-pytest python-pytest-mock
pacman -Scc --noconfirm
- name: Run pre-commit
run: pre-commit run --show-diff-on-failure --color=always --all-files
+7 -31
View File
@@ -80,24 +80,12 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
options: --privileged --device /dev/fuse
steps:
- name: Rank Arch mirrors
- name: Configure Arch mirror
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
@@ -207,24 +195,12 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
steps:
- name: Rank Arch mirrors
- name: Configure Arch mirror
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
@@ -290,7 +266,7 @@ jobs:
needs: changes
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
container:
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
image: debian:trixie-20261005@sha256:913f6706df59a68922d1dd08f78c2476560a8d367897200a6005b00e5f67c2d5
options: --privileged
steps:
+3
View File
@@ -9,6 +9,9 @@ jobs:
renovate:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
env:
UV_PYTHON_INSTALL_MIRROR: https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone/
UV_DEFAULT_INDEX: https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+2 -2
View File
@@ -24,7 +24,7 @@ repos:
hooks:
- id: pyright
name: pyright
entry: uv run pyright
entry: pyright
language: system
types_or: [python, pyi]
require_serial: true
@@ -62,7 +62,7 @@ repos:
hooks:
- id: pytest
name: pytest
entry: uv run pytest tests/ -x -q --tb=line
entry: pytest tests/ -x -q --tb=line
language: system
types: [python]
files: ^(tests/|portprotonqt/)
+5 -2
View File
@@ -419,7 +419,7 @@ tests/
├── test_cli.py # normalize_launch_path, URL/resolution parsing
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
├── test_input_manager.py # Gamepad input navigation and focus regressions
├── test_input_manager.py # Gamepad input navigation, dialog keyboards, and focus regressions
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
├── test_boot_animation.py # Theme video inheritance, fullscreen boot rendering, gamepad skip, loading transitions, and shutdown
├── test_main_window.py # Main window data processing and callback regressions
@@ -433,9 +433,10 @@ tests/
├── 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_exe_metadata.py # PE version strings, shell output, and ExifTool fallback
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
├── test_shortcuts.py # Desktop shortcut creation, paths with spaces, .desktop entry
├── test_shortcuts.py # Desktop shortcuts, folder import, edited exe paths, and gamepad navigation
├── 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)
@@ -1057,6 +1058,8 @@ PortProtonQt/
## Dependencies
- Use the existing `orjson` dependency instead of standard-library `json` for faster JSON parsing and serialization. For formatted output, use `orjson.OPT_INDENT_2`.
### Core
| Package | Purpose | License |
+26
View File
@@ -7,13 +7,39 @@
### Added
- Добавлен массовый импорт игр из выбранной папки
- Добавлен режим множественного выделения карточек через контекстное меню: ЛКМ и кнопка A геймпада переключают выделение, Ctrl+клик позволяет выделять карточки напрямую
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
- Добавлено предупреждение о кириллице в путях к игре или PortProton
- Добавлен резервный запрет сна через logind/elogind при недоступности ScreenSaver и портала, в том числе для дистрибутивов без systemd
### Changed
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
- После добавления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог об успешном добавлении
- В скриптах PortProton ускорено чтение метаданных exe с помощью pefile
- Разбор ответа PPDB в диалоге отчёта и запись форматированного JSON-кеша переведены на orjson
- Настройки exe и `user.conf` применяются автоматически при изменении
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
### Fixed
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
- Наведение на карточки больше не перехватывает фокус из строки поиска при вводе текста
- Исправлена ошибка форматирования даты последнего запуска и времени игры при системной локали `C` или `POSIX`
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
- Исправлена гонка при запуске игры с `--silent`
### Contributors
- @adem4ik
- @rogerio-mattos
---
@@ -943,13 +943,13 @@ get_and_set_reg_file () {
esac
name_block=${name_block//\\/\\\\\\\\}
if [[ -n $name_for_new_block ]] ; then
find_block=$(grep -n "\[$name_block\]" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/$name_for_new_block.reg")
find_block=$(grep -n "\[$name_block\]" "${WINEPREFIX}/$name_for_new_block.reg")
else
find_block=$(grep -n "\[$name_block\]" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/"*.reg)
find_block=$(grep -n "\[$name_block\]" "${WINEPREFIX}/"*.reg)
fi
if [[ -n $find_block ]] ; then
if [[ -n $name_for_new_block ]] ; then
find_file="${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/$name_for_new_block.reg"
find_file="${WINEPREFIX}/$name_for_new_block.reg"
find_line=${find_block//:*/}
else
find_file=${find_block//:*/}
@@ -974,8 +974,8 @@ get_and_set_reg_file () {
if [[ $name_add_or_del == --add ]] ; then
if [[ -z $find_block ]] ; then
if [[ -n $name_for_new_block ]] ; then
sed -i '$a\\n'\["$name_block"\] "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/$name_for_new_block.reg"
find_file="${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/$name_for_new_block.reg"
sed -i '$a\\n'\["$name_block"\] "${WINEPREFIX}/$name_for_new_block.reg"
find_file="${WINEPREFIX}/$name_for_new_block.reg"
find_line=$(wc -l "$find_file" | awk -F" " '{print $1}')
find_line=$(( find_line - 1 ))
else
@@ -1199,6 +1199,23 @@ init_wine_ver () {
if [[ -d "${WINEDIR}"/share/espeak-ng-data ]] ; then
export ESPEAK_DATA_PATH="${WINEDIR}"/share/
fi
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
if [[ -x "${STEAM_COMPAT_TOOL_PATHS%%:*}/proton" ]] ; then
PROTON_RUNNER="${STEAM_COMPAT_TOOL_PATHS%%:*}/proton"
elif [[ "${PW_WINE_USE:-}" = /* && -x "${PW_WINE_USE}/proton" ]] ; then
PROTON_RUNNER="${PW_WINE_USE}/proton"
elif [[ -x "${PORT_DATA_PATH}/data/dist/${PW_WINE_USE^^}/proton" ]] ; then
PROTON_RUNNER="${PORT_DATA_PATH}/data/dist/${PW_WINE_USE^^}/proton"
elif [[ -x "${WINEDIR}/proton" ]] ; then
PROTON_RUNNER="${WINEDIR}/proton"
elif [[ -x "${WINEDIR%/files}/proton" ]] ; then
PROTON_RUNNER="${WINEDIR%/files}/proton"
else
fatal "$PW_WINE_USE not found!"
fi
export PROTON_RUNNER
fi
return 0
}
@@ -1857,6 +1874,10 @@ check_dirs_and_files_in_pfx () {
}
pw_init_db () {
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
return 0
fi
[[ -n "${PW_DEFAULT_WINE_USE:-}" ]] && export PW_WINE_USE="${PW_DEFAULT_WINE_USE}"
[[ -n "${PW_DEFAULT_PREFIX_NAME:-}" ]] && export PW_PREFIX_NAME="${PW_DEFAULT_PREFIX_NAME}"
[[ -n "${PW_DEFAULT_VULKAN_USE:-}" ]] && export PW_VULKAN_USE="${PW_DEFAULT_VULKAN_USE}"
@@ -2272,9 +2293,14 @@ manage_user_conf_value () {
use_exiftool () {
if [[ ! -f "${PW_TMPFS_PATH}/exiftool.tmp" ]] \
|| ! grep -q "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|| ! grep -Fqx -- "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
then
echo "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
printf '%s\n' "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
if [[ "${1,,}" == *.exe ]] \
&& python_module exe_metadata "$1" >> "${PW_TMPFS_PATH}/exiftool.tmp"
then
return 0
fi
if ! command -v exiftool &>/dev/null \
|| ! timeout 3 exiftool "$1" &>> "${PW_TMPFS_PATH}/exiftool.tmp"
then
@@ -2428,9 +2454,8 @@ pw_specialk_set_ini_value () {
[[ -f "$ini_file" ]] && sed -i '/./,$!d' "$ini_file"
if grep -q "^\[$section\]" "$ini_file" ; then
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" \
| grep -q "^[[:space:]]*$key[[:space:]]*=" ; then
sed -i "/^\[$section\]/,/^\[/{s/^[[:space:]]*$key[[:space:]]*=.*/$key=$value/}" "$ini_file"
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" | grep -q "^$key=" ; then
sed -i "/^\[$section\]/,/^\[/{s/^$key=.*/$key=$value/}" "$ini_file"
else
sed -i "/^\[$section\]/a $key=$value" "$ini_file"
fi
@@ -2523,171 +2548,6 @@ pw_specialk_sync_files () {
}
pw_reshade_sync_files () {
local root="${PW_PLUGINS_PATH}/reshade_windows" runtime detect arch dll highest ogl vulkan source
local manifest_reg package installed installed_arch installed_version extract_dir reshade_exe reshade_font font_win screenshot_path backup
local PATH_TO_GAME="${PATH_TO_GAME:-}"
runtime="$root/reshade/${PW_RESHADE_VER}_Addon"
reshade_exe="${PW_RESHADE_EXE:-$PW_EXE_FILE}"
if [[ ! -f "$reshade_exe" ]] ; then
print_warning "ReShade executable not found: $reshade_exe. Using $PW_EXE_FILE."
reshade_exe="$PW_EXE_FILE"
fi
if [[ "$PW_USE_RESHADE" == 1 ]] ; then
detect=$(python_module graphics_detector "$reshade_exe")
file "$reshade_exe" | grep -qE 'x86-64|PE32\+' && arch=64 || arch=32
highest=$(echo "$detect" | grep '^HIGHEST_DX=' | cut -d= -f2)
ogl=$(echo "$detect" | grep '^USES_OGL=' | cut -d= -f2)
vulkan=$(echo "$detect" | grep '^USES_VULKAN=' | cut -d= -f2)
source=$(echo "$detect" | grep '^SOURCE=' | cut -d= -f2-)
[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] && vulkan=false
fi
if [[ -n "$PW_RESHADE_EXE" ]] ; then
PATH_TO_GAME="$(dirname "$(realpath "$reshade_exe")")"
fi
if [[ "$PW_USE_RESHADE" == 1 ]] \
&& [[ ! -f "$runtime/ReShade32.dll" || ! -f "$runtime/ReShade32.json" \
|| ! -f "$runtime/ReShade64.dll" || ! -f "$runtime/ReShade64.json" ]] ; then
try_download "https://reshade.me/downloads/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
"${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
create_new_dir "$runtime"
unpack "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
"$runtime" silent || fatal "Failed to extract ReShade."
try_remove_file "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
fi
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3dcompiler_47.dll" ]] ; then
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/d3dcompiler_47.dll" "$root/d3dcompiler_47.dll"
fi
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3d8.dll" ]] ; then
try_download "https://github.com/crosire/d3d8to9/releases/download/v${PW_D3D8TO9_VER}/d3d8.dll" "$root/d3d8.dll"
fi
if [[ "$PW_USE_RESHADE" == 1 && ! -d "$root/shaders/Shaders" ]] ; then
try_remove_dir "$root/shaders"
create_new_dir "$root/shaders/Shaders"
create_new_dir "$root/shaders/Textures"
for package in reshade_shaders sweetfx_shaders immerse_shaders astrayfx_shaders prod80_shaders retroarch_shaders ; do
extract_dir="${PORT_DATA_PATH}/data/tmp/$package"
if [[ "$package" == immerse_shaders ]] ; then
try_download "https://github.com/martymcmodding/iMMERSE/archive/refs/heads/main.tar.gz" \
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
else
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/${package}.tar.gz" \
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
fi
try_remove_dir "$extract_dir"
create_new_dir "$extract_dir"
unpack "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" "$extract_dir" \
silent || fatal "Failed to extract $package."
find "$extract_dir" -type d -iname Shaders -exec cp -rf '{}/.' "$root/shaders/Shaders/" \;
find "$extract_dir" -type d -iname Textures -exec cp -rf '{}/.' "$root/shaders/Textures/" \;
try_remove_dir "$extract_dir"
try_remove_file "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
done
fi
if [[ "$PW_USE_RESHADE" == 1 && "$vulkan" == true ]] ; then
dll="Vulkan${arch}"
elif [[ "$PW_USE_RESHADE" == 1 ]] ; then
case "$highest" in
"DirectX 10"|"DirectX 11"|"DirectX 12") dll=dxgi.dll ;;
"DirectX 8"|"DirectX 9") dll=d3d9.dll ;;
"DirectDraw") return 0 ;;
*) [[ "$ogl" == true ]] && dll=opengl32.dll || dll=dxgi.dll ;;
esac
fi
case "$PW_RESHADE_API" in
vulkan) vulkan=true; dll="Vulkan${arch}" ;;
dxgi.dll|d3d9.dll|opengl32.dll) vulkan=false; dll="$PW_RESHADE_API" ;;
esac
if [[ -f "$PATH_TO_GAME/reshade_version" ]] \
&& { [[ "$PW_USE_RESHADE" != 1 ]] \
|| [[ "$(<"$PATH_TO_GAME/reshade_version")" != "${PW_RESHADE_VER}_Addon"$'\n'"$dll" ]]; } ; then
installed_version=$(sed -n '1p' "$PATH_TO_GAME/reshade_version")
installed=$(sed -n '2p' "$PATH_TO_GAME/reshade_version")
if [[ "$installed" == Vulkan32 || "$installed" == Vulkan64 ]] ; then
installed_arch=${installed#Vulkan}
manifest_reg="Z:${root//\//\\}\\reshade\\${installed_version}\\ReShade${installed_arch}.json"
manifest_reg=${manifest_reg//\\/\\\\\\\\}
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
"$manifest_reg" "" "" user
if [[ "$installed_arch" == 32 ]] ; then
get_and_set_reg_file --delete 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' \
"$manifest_reg" "" "" system
else
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
"$manifest_reg" "" "" system
fi
fi
try_remove_file "$PATH_TO_GAME/$installed"
if [[ -L "$PATH_TO_GAME/${installed}.b" \
&& $(readlink -f "$PATH_TO_GAME/${installed}.b") == "$root/reshade/"* ]] ; then
unlink "$PATH_TO_GAME/${installed}.b"
fi
[[ -e "$PATH_TO_GAME/${installed}.b" ]] \
&& mv -f "$PATH_TO_GAME/${installed}.b" "$PATH_TO_GAME/$installed"
[[ -L "$PATH_TO_GAME/d3dcompiler_47.dll" ]] && unlink "$PATH_TO_GAME/d3dcompiler_47.dll"
[[ -L "$PATH_TO_GAME/d3d8.dll" ]] && unlink "$PATH_TO_GAME/d3d8.dll"
[[ -e "$PATH_TO_GAME/d3d8.dll.b" ]] \
&& mv -f "$PATH_TO_GAME/d3d8.dll.b" "$PATH_TO_GAME/d3d8.dll"
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
try_remove_file "$PATH_TO_GAME/ReShade.ini"
try_remove_file "$PATH_TO_GAME/ReShadePreset.ini"
try_remove_file "$PATH_TO_GAME/ReShade.log"
try_remove_file "$PATH_TO_GAME/reshade_version"
fi
[[ "$PW_USE_RESHADE" != 1 ]] && return 0
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
try_force_link_dir "$root/shaders" "$PATH_TO_GAME/ReShade_shaders"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "EffectSearchPaths" '.\\ReShade_shaders\\Shaders'
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "TextureSearchPaths" '.\\ReShade_shaders\\Textures'
if [[ -n "$PW_RESHADE_FONT" ]] ; then
reshade_font=$(fc-match --format='%{file}' "$PW_RESHADE_FONT")
else
reshade_font=/usr/share/fonts/ttf/dejavu/DejaVuSans.ttf
fi
if [[ -f "$reshade_font" ]] ; then
font_win="Z:${reshade_font//\//\\\\}"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "Font" "$font_win"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "EditorFont" "$font_win"
elif [[ -n "$PW_RESHADE_FONT" ]] ; then
print_warning "ReShade font not found: $PW_RESHADE_FONT"
fi
screenshot_path='.\\'
if [[ -n "$PW_RESHADE_SCREENSHOT_PATH" ]] ; then
create_new_dir "$PW_RESHADE_SCREENSHOT_PATH"
screenshot_path="Z:${PW_RESHADE_SCREENSHOT_PATH//\//\\\\}"
fi
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyScreenshot" "$PW_RESHADE_SCREENSHOT_KEY"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyOverlay" "$PW_RESHADE_OVERLAY_KEY"
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "SCREENSHOT" "SavePath" "$screenshot_path"
if [[ "$vulkan" == true ]] ; then
manifest_reg="Z:${runtime//\//\\}\\ReShade${arch}.json"
manifest_reg=${manifest_reg//\\/\\\\\\\\}
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 user
if [[ "$arch" == 32 ]] ; then
get_and_set_reg_file --add 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
else
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
fi
var_winedlloverride_update "vulkan-1=n,b"
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
return 0
fi
[[ ! -f "$PATH_TO_GAME/reshade_version" \
&& -e "$PATH_TO_GAME/$dll" && ! -e "$PATH_TO_GAME/${dll}.b" \
&& ( ! -L "$PATH_TO_GAME/$dll" \
|| $(readlink -f "$PATH_TO_GAME/$dll") != "$root/reshade/"* ) ]] \
&& mv -f "$PATH_TO_GAME/$dll" "$PATH_TO_GAME/${dll}.b"
try_force_link_file "$runtime/ReShade${arch}.dll" "$PATH_TO_GAME/$dll"
try_force_link_file "$root/d3dcompiler_47.dll" "$PATH_TO_GAME/d3dcompiler_47.dll"
var_winedlloverride_update "d3dcompiler_47=n;${dll%.*}=n,b"
if [[ "$highest" == "DirectX 8" && "$dll" == d3d9.dll ]] ; then
[[ -e "$PATH_TO_GAME/d3d8.dll" && ! -e "$PATH_TO_GAME/d3d8.dll.b" ]] && mv -f "$PATH_TO_GAME/d3d8.dll" "$PATH_TO_GAME/d3d8.dll.b"
try_force_link_file "$root/d3d8.dll" "$PATH_TO_GAME/d3d8.dll"
var_winedlloverride_update "d3d8=n,b"
fi
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
}
pw_prepare_gamescope_launch () {
[[ "$PW_USE_NATIVE_WAYLAND" == "1" ]] && export PW_GAMESCOPE="0"
@@ -2806,15 +2666,27 @@ pw_enable_gamemode_basic () {
}
start_portproton () {
pw_check_and_download_plugins
if [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
try_remove_dir "${PORT_WINE_TMP_PATH}/libs${PW_LIBS_VER}"
else
pw_check_and_download_runtime
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
PATH_TO_GAME="$(dirname "$(realpath "${PW_EXE_FILE}")")"
export PATH_TO_GAME
export PORTWINE_DB_FILE="${PW_EXE_FILE}.ppdb"
[[ -f "${PORTWINE_DB_FILE}" ]] && source "${PORTWINE_DB_FILE}"
unset PW_PREFIX_NAME
export WINEPREFIX="${STEAM_COMPAT_DATA_PATH}/pfx"
fi
pw_check_and_download_dxvk_and_vkd3d
pw_check_and_download_plugins
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
export PW_USE_RUNTIME="0"
export PW_USE_SUPPLIED_DXVK_VKD3D="1"
elif [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
try_remove_dir "${PORT_WINE_TMP_PATH}/libs${PW_LIBS_VER}"
pw_check_and_download_dxvk_and_vkd3d
else
pw_check_and_download_runtime
pw_check_and_download_dxvk_and_vkd3d
fi
pw_skip_get_info
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
@@ -2830,7 +2702,9 @@ start_portproton () {
export PW_LOCALE_SELECT="disabled"
fi
WINEPREFIX="$(readlink -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}")"
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
WINEPREFIX="$(readlink -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}")"
fi
export WINEPREFIX
pw_get_pfx () {
@@ -2867,7 +2741,7 @@ start_portproton () {
return 0
}
pw_get_pfx
[[ "${PW_STEAMPLAY:-}" != "1" ]] && pw_get_pfx
var_winedlloverride_update "${PW_MUST_WINEDLLOVERRIDES}"
pw_list_install_dll_in_prefix
@@ -2983,53 +2857,55 @@ start_portproton () {
print_info "The prefix has been updated."
}
if [[ ! -d "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/drive_c/windows ]] \
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/userdef.reg ]] \
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/system.reg ]] \
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/user.reg ]]
then
pw_clear_pfx
check_dirs_and_files_in_pfx
if [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
pw_wineboot -r
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
elif [[ "${PW_CHECK_AUTOINSTALL}" == 1 ]] \
&& [[ "${DISABLE_CP_DEFPFX}" != 1 ]] ; then
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
pw_wineboot -r
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
elif [[ "${PW_CHECK_AUTOINSTALL}" == 1 ]] \
&& [[ "${DISABLE_CP_DEFPFX}" == 1 ]] ; then
pw_wineboot -i
elif [[ "${DISABLE_CP_DEFPFX}" == "1" ]]; then
pw_wineboot -i
else
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
pw_wineboot -r
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
fi
get_and_set_reg_file --add 'Control Panel\Desktop' 'LogPixels' 'REG_DWORD' "96" "user"
else
check_dirs_and_files_in_pfx
if [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver" ]] \
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c/windows/system32/shell32.dll" ]] \
|| ! grep "${PW_WINE_USE}" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver" &>/dev/null
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
if [[ ! -d "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/drive_c/windows ]] \
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/userdef.reg ]] \
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/system.reg ]] \
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/user.reg ]]
then
pw_wineboot -r
pw_clear_pfx
check_dirs_and_files_in_pfx
if [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
pw_wineboot -r
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
elif [[ "${PW_CHECK_AUTOINSTALL}" == 1 ]] \
&& [[ "${DISABLE_CP_DEFPFX}" != 1 ]] ; then
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
pw_wineboot -r
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
elif [[ "${PW_CHECK_AUTOINSTALL}" == 1 ]] \
&& [[ "${DISABLE_CP_DEFPFX}" == 1 ]] ; then
pw_wineboot -i
elif [[ "${DISABLE_CP_DEFPFX}" == "1" ]]; then
pw_wineboot -i
else
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
pw_wineboot -r
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
fi
get_and_set_reg_file --add 'Control Panel\Desktop' 'LogPixels' 'REG_DWORD' "96" "user"
else
check_dirs_and_files_in_pfx
if [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver" ]] \
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c/windows/system32/shell32.dll" ]] \
|| ! grep "${PW_WINE_USE}" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver" &>/dev/null
then
pw_wineboot -r
fi
fi
if [[ -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" ]] \
&& grep -q "based_on" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
then export PW_BASE_PFX="$(awk -F'based_on_' '$2 != "" {print $2}' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log")"
else export PW_BASE_PFX="none"
fi
rm -f "${PORT_DATA_PATH}"/data/prefixes/*/drive_c/users/*/Desktop/*.lnk
echo "${PW_WINE_USE}" > "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver"
fi
if [[ -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" ]] \
&& grep -q "based_on" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
then export PW_BASE_PFX="$(awk -F'based_on_' '$2 != "" {print $2}' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log")"
else export PW_BASE_PFX="none"
fi
rm -f "${PORT_DATA_PATH}"/data/prefixes/*/drive_c/users/*/Desktop/*.lnk
echo "${PW_WINE_USE}" > "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver"
pw_set_sync_runtime_vars
# export WINE_SIMULATE_ASYNC_READ=1
@@ -3123,7 +2999,6 @@ start_portproton () {
pw_optiscaler_sync_files
pw_specialk_sync_files
pw_reshade_sync_files
if [[ "${PW_USE_RAY_TRACING}" == "1" ]] ; then
var_vkd3d_config_update dxr
@@ -3135,8 +3010,13 @@ start_portproton () {
fi
if [[ "${PW_USE_NVAPI_AND_DLSS}" == "1" ]] ; then
export DXVK_ENABLE_NVAPI="1"
if echo "$LSPCI_VGA" | grep -i -q 'nvidia' ; then
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
export PROTON_FORCE_NVAPI="1"
export PROTON_HIDE_NVIDIA_GPU="0"
else
export DXVK_ENABLE_NVAPI="1"
fi
if [[ "${PW_STEAMPLAY:-}" != "1" ]] && echo "$LSPCI_VGA" | grep -i -q 'nvidia' ; then
FIND_NVNGX="$(dirname $(find /usr/* -type f -name "nvngx.dll" 2>/dev/null | head -n 1 | awk '{print $1}'))"
if [[ -n "$FIND_NVNGX" ]] ; then
try_copy_file_with_checksums "${FIND_NVNGX}/nvngx.dll" "${WINEPREFIX}/drive_c/windows/system32/nvngx.dll"
@@ -3145,7 +3025,7 @@ start_portproton () {
export NVIDIA_WINE_DLL_DIR="${FIND_NVNGX}"
fi
fi
else
elif [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
export DXVK_ENABLE_NVAPI="0"
fi
@@ -3159,9 +3039,11 @@ start_portproton () {
else export WINE_ALLOW_XIM="0"
fi
if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]
then export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="1"
else export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="0"
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]
then export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="1"
else export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="0"
fi
fi
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
@@ -3264,16 +3146,17 @@ start_portproton () {
export PW_USE_US_LAYOUT="0"
fi
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
D3D_EXTRAS_LIBS="d3dcompiler_33 d3dcompiler_34 d3dcompiler_35 d3dcompiler_36 d3dcompiler_37
d3dcompiler_38 d3dcompiler_39 d3dcompiler_40 d3dcompiler_41 d3dcompiler_42 d3dcompiler_43 d3dcompiler_46
d3dcompiler_47 d3dx10_33 d3dx10_34 d3dx10_35 d3dx10_36 d3dx10_37 d3dx10_38 d3dx10_39 d3dx10_40 d3dx10_41
d3dx10_42 d3dx10_43 d3dx10 d3dx11_42 d3dx11_43 d3dx9_24 d3dx9_25 d3dx9_26 d3dx9_27 d3dx9_28 d3dx9_29 d3dx9_30
d3dx9_31 d3dx9_32 d3dx9_33 d3dx9_34 d3dx9_35 d3dx9_36 d3dx9_37 d3dx9_38 d3dx9_39 d3dx9_40 d3dx9_41 d3dx9_42 d3dx9_43"
if [[ "${PW_USE_D3D_EXTRAS}" == "1" ]] ; then
if [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" ]] \
|| [[ -z "$(grep d3dx9 "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log")" ]]
if [[ ! -f "${WINEPREFIX}/winetricks.log" ]] \
|| [[ -z "$(grep d3dx9 "${WINEPREFIX}/winetricks.log")" ]]
then
echo "d3dx9" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
echo "d3dx9" >> "${WINEPREFIX}/winetricks.log"
fi
# try link d3d extras libs
PATH_TO_D3DEXTRAS="${PW_PLUGINS_PATH}/d3d_extras"
@@ -3281,8 +3164,8 @@ start_portproton () {
try_force_link_file "${PATH_TO_D3DEXTRAS}/x64/${d3d_extras_from_plugins}.dll" "${WINEPREFIX}/drive_c/windows/system32/${d3d_extras_from_plugins}.dll"
try_force_link_file "${PATH_TO_D3DEXTRAS}/x32/${d3d_extras_from_plugins}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${d3d_extras_from_plugins}.dll"
var_winedlloverride_update "${d3d_extras_from_plugins}=n"
if [[ -z "$(grep "${d3d_extras_from_plugins}" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log")" ]] ; then
echo "${d3d_extras_from_plugins}" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
if [[ -z "$(grep "${d3d_extras_from_plugins}" "${WINEPREFIX}/winetricks.log")" ]] ; then
echo "${d3d_extras_from_plugins}" >> "${WINEPREFIX}/winetricks.log"
fi
done
else
@@ -3782,6 +3665,7 @@ start_portproton () {
else
var_winedlloverride_update "wineopenxr,vrclient,vrclient_x64,openvr_api_dxvk="
fi
fi
if [[ "${PW_USE_SHADER_CACHE}" == "1" ]] ; then
create_new_dir "${PW_VULKAN_DIR}/gl_shader_cache"
@@ -3855,59 +3739,65 @@ start_portproton () {
fi
export LSFGVK_EXPERIMENTAL_PRESENT_MODE="$MESA_VK_WSI_PRESENT_MODE"
#run_winetricks_from_db
if [[ -n "${PW_MUST_HAVE_DLL}" ]] ; then
PW_DLL_INSTALL="$(echo "${PW_MUST_HAVE_DLL} ${PW_DLL_INSTALL}" | awk '{ for(i=1;i<=NF;i++){a[$i]++} }END{ for(i in a){printf("%s ",i)} }' )"
export PW_DLL_INSTALL
fi
if [[ -n "${PW_DLL_INSTALL}" ]] ; then
export PW_DLL_NEED_INSTALL=""
export USE_WT_FROM_DB=0
if [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" ]] ; then
touch "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
#run_winetricks_from_db
if [[ -n "${PW_MUST_HAVE_DLL}" ]] ; then
PW_DLL_INSTALL="$(echo "${PW_MUST_HAVE_DLL} ${PW_DLL_INSTALL}" | awk '{ for(i=1;i<=NF;i++){a[$i]++} }END{ for(i in a){printf("%s ",i)} }' )"
export PW_DLL_INSTALL
fi
for need_install_dll_to_pfx in ${PW_DLL_INSTALL} ; do
if [[ "${need_install_dll_to_pfx}" == vcrun201[5-9] ]] \
|| [[ "${need_install_dll_to_pfx}" == vcrun2022 ]] ; then
need_install_dll_to_pfx="vcrun2022"
sed -i '/vcrun2015/d' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
sed -i '/vcrun2017/d' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
sed -i '/vcrun2019/d' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
if [[ -n "${PW_DLL_INSTALL}" ]] ; then
export PW_DLL_NEED_INSTALL=""
export USE_WT_FROM_DB=0
if [[ ! -f "${WINEPREFIX}/winetricks.log" ]] ; then
touch "${WINEPREFIX}/winetricks.log"
fi
grep "${need_install_dll_to_pfx}" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" &>/dev/null
if [[ "$?" == "1" ]] ; then
if [[ -z "${PW_DLL_NEED_INSTALL}" ]]
then export PW_DLL_NEED_INSTALL="${need_install_dll_to_pfx}"
else export PW_DLL_NEED_INSTALL="${need_install_dll_to_pfx} ${PW_DLL_NEED_INSTALL}"
for need_install_dll_to_pfx in ${PW_DLL_INSTALL} ; do
if [[ "${need_install_dll_to_pfx}" == vcrun201[5-9] ]] \
|| [[ "${need_install_dll_to_pfx}" == vcrun2022 ]] ; then
need_install_dll_to_pfx="vcrun2022"
sed -i '/vcrun2015/d' "${WINEPREFIX}/winetricks.log"
sed -i '/vcrun2017/d' "${WINEPREFIX}/winetricks.log"
sed -i '/vcrun2019/d' "${WINEPREFIX}/winetricks.log"
fi
export USE_WT_FROM_DB=1
grep "${need_install_dll_to_pfx}" "${WINEPREFIX}/winetricks.log" &>/dev/null
if [[ "$?" == "1" ]] ; then
if [[ -z "${PW_DLL_NEED_INSTALL}" ]]
then export PW_DLL_NEED_INSTALL="${need_install_dll_to_pfx}"
else export PW_DLL_NEED_INSTALL="${need_install_dll_to_pfx} ${PW_DLL_NEED_INSTALL}"
fi
export USE_WT_FROM_DB=1
fi
done
if [[ "${USE_WT_FROM_DB}" == "1" ]] ; then
update_winetricks
echo "START WINETRICKS..." >> "${PW_TMPFS_PATH}/update_pfx_log"
echo "Try to install DLL in prefix: ${PW_DLL_NEED_INSTALL}" >> "${PW_TMPFS_PATH}/update_pfx_log"
print_info "Try to install DLL in prefix: ${PW_DLL_NEED_INSTALL}"
${pw_runtime} LD_LIBRARY_PATH="${PW_LD_LIBRARY_PATH}" GST_PLUGIN_SYSTEM_PATH_1_0="" \
"${PORT_WINE_TMP_PATH}/winetricks" -q -f ${PW_DLL_NEED_INSTALL} | tee -a "${PW_TMPFS_PATH}/update_pfx_log"
wait_wineserver
kill_portwine
fi
done
if [[ "${USE_WT_FROM_DB}" == "1" ]] ; then
update_winetricks
echo "START WINETRICKS..." >> "${PW_TMPFS_PATH}/update_pfx_log"
echo "Try to install DLL in prefix: ${PW_DLL_NEED_INSTALL}" >> "${PW_TMPFS_PATH}/update_pfx_log"
print_info "Try to install DLL in prefix: ${PW_DLL_NEED_INSTALL}"
${pw_runtime} LD_LIBRARY_PATH="${PW_LD_LIBRARY_PATH}" GST_PLUGIN_SYSTEM_PATH_1_0="" \
"${PORT_WINE_TMP_PATH}/winetricks" -q -f ${PW_DLL_NEED_INSTALL} | tee -a "${PW_TMPFS_PATH}/update_pfx_log"
wait_wineserver
kill_portwine
fi
fi
if [[ -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/system.reg" ]] \
&& ! grep -iq "Microsoft Windows $PW_WINDOWS_VER" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/system.reg"
if [[ -f "${WINEPREFIX}/system.reg" ]] \
&& ! grep -iq "Microsoft Windows $PW_WINDOWS_VER" "${WINEPREFIX}/system.reg"
then
if [[ -n $PW_WINDOWS_VER ]] \
&& [[ $(echo "$PW_WINDOWS_VER" | sed 's/.*/\L&/') == "xp" ]]
then
export PW_WINDOWS_VER="xp64"
fi
${pw_runtime} LD_LIBRARY_PATH="${PW_LD_LIBRARY_PATH}:${WINE_LIBRARY_PATH}" GST_PLUGIN_SYSTEM_PATH_1_0="" \
"${WINELOADER}" winecfg -v $(echo "win${PW_WINDOWS_VER}" | sed 's/.*/\L&/')
wait_wineserver
kill_portwine
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
"${PROTON_RUNNER}" run winecfg -v "$(echo "win${PW_WINDOWS_VER}" | sed 's/.*/\L&/')"
else
${pw_runtime} LD_LIBRARY_PATH="${PW_LD_LIBRARY_PATH}:${WINE_LIBRARY_PATH}" GST_PLUGIN_SYSTEM_PATH_1_0="" \
"${WINELOADER}" winecfg -v "$(echo "win${PW_WINDOWS_VER}" | sed 's/.*/\L&/')"
wait_wineserver
kill_portwine
fi
echo "Set to win${PW_WINDOWS_VER}"
fi
@@ -4191,154 +4081,54 @@ pw_launch_wrapper () {
}
export -f pw_launch_wrapper
steamplay_launch () {
pw_check_and_download_plugins
if [[ -n "${PW_EXE_FILE:-}" ]]; then
local proton_runner
local steamplay_ld_library_path
cd "$(dirname "${PW_EXE_FILE}")"
export PATH_TO_GAME="$(pwd)"
export PORTWINE_DB_FILE="${PW_EXE_FILE}.ppdb"
[[ -f "${PORTWINE_DB_FILE}" ]] && source "${PORTWINE_DB_FILE}"
export PW_USE_SUPPLIED_DXVK_VKD3D="1"
steamplay_ld_library_path="${LD_LIBRARY_PATH}:${STEAM_RUNTIME_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
[[ -d "${HOME}/.local/share/Steam/ubuntu12_32" ]] && steamplay_ld_library_path="${steamplay_ld_library_path}:${HOME}/.local/share/Steam/ubuntu12_32"
[[ -d "${HOME}/.local/share/Steam/ubuntu12_64" ]] && steamplay_ld_library_path="${steamplay_ld_library_path}:${HOME}/.local/share/Steam/ubuntu12_64"
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
unset MANGOHUD_CONFIG
export MANGOHUD_CONFIGFILE="$PATH_TO_GAME/MangoHud.conf"
print_info "Custom MangoHud.conf in use: $MANGOHUD_CONFIGFILE"
elif [[ "${PW_MANGOHUD_USER_CONF}" == 1 ]] ; then
pw_select_mangohud_user_conf
fi
[[ "${PW_VKBASALT_USER_CONF}" == 1 ]] && pw_select_vkbasalt_user_conf
pw_optiscaler_sync_files
pw_specialk_sync_files
PORT_WINE_PREFIX="$STEAM_COMPAT_DATA_PATH/pfx"
pw_prepare_gamescope_launch
if check_gamescope_session ; then
export PW_GAMEMODERUN_SLR=""
elif [[ "$PW_USE_GAMEMODE" = "1" ]] \
&& [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]
then
pw_enable_gamemode_basic
fi
pw_enable_idle_inhibit
pw_set_sync_runtime_vars
# Mangohud, VkBasalt, LSFG
if [[ "$PW_USE_SYSTEM_VK_LAYERS" == "1" ]] ; then
unset PW_VK_LAYER_PATH
else
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
fi
pw_mangohud_check
pw_vkbasalt_check
pw_lsfg_vk_check
export VK_ADD_IMPLICIT_LAYER_PATH="${PW_VK_LAYER_PATH}"
export VK_ADD_LAYER_PATH="${PW_VK_LAYER_PATH}"
export VK_INSTANCE_LAYERS="${PW_VK_INSTANCE_LAYERS}"
[[ $PW_LOG != 1 ]] && debug_timer --start -s "PW_TIME_IN_GAME"
if [[ "${PW_WINE_USE,,}" != *proton* ]] ; then
fatal "Steamplay don\`t work with ${PW_WINE_USE}!"
else
init_wine_ver
fi
check_proton_runner () {
if [[ -x "${STEAM_COMPAT_TOOL_PATHS%%:*}/proton" ]] ; then
proton_runner="${STEAM_COMPAT_TOOL_PATHS%%:*}/proton"
elif [[ "${PW_WINE_USE:-}" = /* ]] && [[ -x "${PW_WINE_USE}/proton" ]] ; then
proton_runner="${PW_WINE_USE}/proton"
elif [[ -x "${PORT_DATA_PATH}/data/dist/${PW_WINE_USE^^}/proton" ]] ; then
proton_runner="${PORT_DATA_PATH}/data/dist/${PW_WINE_USE^^}/proton"
export PW_WINE_USE="${PW_WINE_USE^^}"
elif [[ -x "${WINEDIR}/proton" ]] ; then
proton_runner="${WINEDIR}/proton"
elif [[ -x "${WINEDIR%/files}/proton" ]] ; then
proton_runner="${WINEDIR%/files}/proton"
else
return 1
fi
return 0
}
if ! check_proton_runner ; then
gui_proton_downloader "${PW_WINE_USE}"
init_wine_ver
if ! check_proton_runner ; then
fatal "$PW_WINE_USE not found!"
fi
fi
pw_reshade_sync_files
# Winetricks
if [[ -n "${PW_DLL_INSTALL:-}" ]] ; then
update_winetricks
PATH="${PATH}:${PW_PLUGINS_PATH}/portable/bin" LD_LIBRARY_PATH="${steamplay_ld_library_path}" \
"${PORT_WINE_TMP_PATH}/winetricks" -q ${PW_DLL_INSTALL}
fi
# Virtual Desktop
if [[ "${PW_VIRTUAL_DESKTOP}" == "1" ]] ; then
if [[ "${PW_SCREEN_RESOLUTION:-}" != *x* ]] ; then
if [[ -f "${PW_TMPFS_PATH}/xrandr.tmp" ]] ; then
PW_SCREEN_RESOLUTION="$(sed -rn 's/^.*primary.* ([0-9]+x[0-9]+).*$/\1/p' "${PW_TMPFS_PATH}/xrandr.tmp")"
fi
[[ "${PW_SCREEN_RESOLUTION:-}" != *x* ]] && PW_SCREEN_RESOLUTION="1920x1080"
fi
"${proton_runner}" "run" reg add "HKEY_CURRENT_USER\\Software\\Wine\\Explorer\\Desktops" /v "PortProton" /d "${PW_SCREEN_RESOLUTION}" /f &>/dev/null
"${proton_runner}" "run" reg add "HKEY_CURRENT_USER\\Software\\Wine\\Explorer" /v "Desktop" /d "PortProton" /f &>/dev/null
print_info "Steam Play virtual desktop enabled in registry: ${PW_SCREEN_RESOLUTION}"
else
"${proton_runner}" "run" reg delete "HKEY_CURRENT_USER\\Software\\Wine\\Explorer" /v "Desktop" /f &>/dev/null
"${proton_runner}" "run" reg delete "HKEY_CURRENT_USER\\Software\\Wine\\Explorer\\Desktops" /v "PortProton" /f &>/dev/null
print_info "Steam Play virtual desktop disabled in registry"
fi
print_info "$proton_runner in use."
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
pw_launch_wrapper \
${PW_RUN_GAMESCOPE} \
env \
LD_LIBRARY_PATH="${steamplay_ld_library_path}" \
LD_PRELOAD="${LD_PRELOAD:+$LD_PRELOAD:}${PW_LD_PRELOAD}" \
${PW_ADD_VAR_SLR} \
${PW_GAMEMODERUN_SLR} \
${PW_TASKSET_SLR} \
"${proton_runner}" waitforexitandrun "${PW_EXE_FILE}" "$@"
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$?"
if [[ $PW_LOG != 1 ]] && [[ -n $START_PW_TIME_IN_GAME ]] ; then
debug_timer --end -s "PW_TIME_IN_GAME"
PW_TIME_IN_GAME=$(( PW_TIME_IN_GAME / 1000 ))
search_desktop_file
fi
fi
}
portwine_launch () {
start_portproton
unset PW_VD_TMP
if [[ "${PW_VIRTUAL_DESKTOP}" == "1" ]] ; then
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
if [[ "${PW_VIRTUAL_DESKTOP}" == "1" ]] ; then
[[ "${PW_SCREEN_RESOLUTION:-}" != *x* ]] && PW_SCREEN_RESOLUTION="1920x1080"
get_and_set_reg_file --add 'Software\Wine\Explorer\Desktops' 'PortProton' 'REG_SZ' "${PW_SCREEN_RESOLUTION}" "user"
get_and_set_reg_file --add 'Software\Wine\Explorer' 'Desktop' 'REG_SZ' "PortProton" "user"
else
get_and_set_reg_file --delete 'Software\Wine\Explorer' 'Desktop' '' '' "user"
get_and_set_reg_file --delete 'Software\Wine\Explorer\Desktops' 'PortProton' '' '' "user"
fi
elif [[ "${PW_VIRTUAL_DESKTOP}" == "1" ]] ; then
PW_VD_TMP=(explorer "/desktop=PortProton,${PW_SCREEN_RESOLUTION}")
fi
[[ $PW_LOG != 1 ]] && debug_timer --start -s "PW_TIME_IN_GAME"
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
if [[ "${PW_STEAMPLAY:-}" == "1" ]] \
&& [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]]
then
printf -v PROTON_REMOTE_DEBUG_CMD '%q' "${PW_RUN_AFTER_EXE}"
export PROTON_REMOTE_DEBUG_CMD
export PRESSURE_VESSEL_FILESYSTEMS_RW="${PRESSURE_VESSEL_FILESYSTEMS_RW:+$PRESSURE_VESSEL_FILESYSTEMS_RW:}$(dirname "${PW_RUN_AFTER_EXE}")"
fi
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
local steamplay_ld_library_path
cd "${PATH_TO_GAME}" || fatal "${PATH_TO_GAME} not found"
steamplay_ld_library_path="${LD_LIBRARY_PATH}:${STEAM_RUNTIME_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
${PW_RUN_GAMESCOPE} \
env \
LD_LIBRARY_PATH="${steamplay_ld_library_path}" \
LD_PRELOAD="${LD_PRELOAD:+$LD_PRELOAD:}${PW_LD_PRELOAD}" \
VK_ADD_IMPLICIT_LAYER_PATH="${PW_VK_LAYER_PATH}" \
VK_ADD_LAYER_PATH="${PW_VK_LAYER_PATH}" \
VK_INSTANCE_LAYERS="${PW_VK_INSTANCE_LAYERS}" \
${PW_ADD_VAR_SLR} \
${PW_GAMEMODERUN_SLR} \
${PW_INHIBIT_SLR} \
${PW_TASKSET_SLR} \
"${PROTON_RUNNER}" waitforexitandrun "${PW_EXE_FILE}" "$@"
return $?
fi
if [[ -n "${LAUNCH_URI}" ]] ; then
export WINEFSYNC="1"
pw_run start /high /b "${LAUNCH_URI}" &
@@ -4499,9 +4289,7 @@ gui_edit_db () {
KEY_EDIT_DB_GUI=$RANDOM
DISABLE_EDIT_DB_LIST=""
PW_EDIT_DB_LIST=(PW_MANGOHUD PW_MANGOHUD_USER_CONF PW_VKBASALT PW_VKBASALT_USER_CONF
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK PW_USE_RESHADE
PW_RESHADE_EXE PW_RESHADE_API PW_RESHADE_FONT PW_RESHADE_SCREENSHOT_PATH
PW_RESHADE_SCREENSHOT_KEY PW_RESHADE_OVERLAY_KEY
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK
PW_USE_ESYNC PW_USE_FSYNC PW_USE_NTSYNC PW_USE_RAY_TRACING PW_USE_NVAPI_AND_DLSS PW_USE_OPTISCALER
PW_USE_LS_FRAME_GEN PW_WINE_FULLSCREEN_FSR PW_HIDE_NVIDIA_GPU PW_VIRTUAL_DESKTOP PW_USE_TERMINAL
PW_USE_GAMEMODE PW_USE_INHIBIT_SLEEP PW_USE_D3D_EXTRAS PW_FIX_VIDEO_IN_GAME PW_REDUCE_PULSE_LATENCY
@@ -4535,10 +4323,6 @@ gui_edit_db () {
DISABLE_EDIT_DB_LIST+=" PW_VKBASALT PW_VKBASALT_USER_CONF PW_USE_RAY_TRACING PW_USE_OBS_VKCAPTURE PW_USE_GALLIUM_ZINK PW_USE_WINED3D_VULKAN"
fi
[[ "${PW_USE_SPECIALK}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_DGVOODOO2"
[[ "${PW_USE_RESHADE}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"
if [[ "${PW_USE_OPTISCALER}" == "1" || "${PW_USE_SPECIALK}" == "1" ]] ; then
DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"
fi
[[ ! -e "/dev/ntsync" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_NTSYNC"
+14 -5
View File
@@ -198,10 +198,11 @@ try_remove_file "${PW_TMPFS_PATH}/update_pfx_log"
[[ ! -f "$PORT_WINE_TMP_PATH/statistics" ]] && touch "$PORT_WINE_TMP_PATH/statistics"
if [[ -n "${STEAM_COMPAT_DATA_PATH:-}" ]]; then
steamplay_launch "${@:2}"
exit
export PW_STEAMPLAY="1"
export WINEPREFIX="${STEAM_COMPAT_DATA_PATH}/pfx"
else
unset WINEPREFIX
fi
unset WINEPREFIX
# choose mirror
if [[ -z "$MIRROR" ]] \
@@ -580,7 +581,11 @@ Usage examples:
exit 0
;;
--launch)
portwine_launch
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
portwine_launch "${@:3}"
else
portwine_launch
fi
stop_portproton
;;
--stop)
@@ -594,7 +599,11 @@ Usage examples:
;;
*)
if [[ -f "$PW_EXE_FILE" ]] ; then
portwine_launch
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
portwine_launch "${@:2}"
else
portwine_launch
fi
stop_portproton
else
fatal "File not found: $PW_EXE_FILE"
-9
View File
@@ -16,8 +16,6 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
export PW_SPECIALK_VER="SK_26_5_15"
export PW_RESHADE_VER="6.8.0"
export PW_D3D8TO9_VER="1.15.1"
export PW_GALLIUM_NINE_VER="0.10"
export DOTPFX_VER="48v9"
export DEFPFX_VER="v1"
@@ -35,13 +33,6 @@ check_variables PROTON_USE_XALIA "0"
check_variables PW_USE_RUNTIME "1"
check_variables PW_USE_D7VK "0"
check_variables PW_USE_SPECIALK "0"
check_variables PW_USE_RESHADE "0"
check_variables PW_RESHADE_API "auto"
check_variables PW_RESHADE_EXE ""
check_variables PW_RESHADE_FONT ""
check_variables PW_RESHADE_SCREENSHOT_PATH ""
check_variables PW_RESHADE_SCREENSHOT_KEY "44,0,0,0"
check_variables PW_RESHADE_OVERLAY_KEY "36,0,0,0"
check_variables PW_USE_ESYNC "0"
check_variables PW_USE_FSYNC "0"
check_variables PW_USE_NTSYNC "0"
+2 -2
View File
@@ -421,7 +421,7 @@
},
{
"normalized_name": "call of duty black ops cold war",
"status": "Broken",
"status": "Running",
"slug": "call-of-duty-black-ops-cold-war"
},
{
@@ -1286,7 +1286,7 @@
},
{
"normalized_name": "nikke goddess of victory",
"status": "Broken",
"status": "Running",
"slug": "nikke"
},
{
Binary file not shown.
+20228 -796
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
+205 -10
View File
@@ -384,6 +384,16 @@
"normalized_name": "automobilista 2",
"overall_rating": "gold"
},
{
"id": 8729,
"normalized_name": "autonauts",
"overall_rating": "platinum"
},
{
"id": 8640,
"normalized_name": "avatar frontiers of pandora",
"overall_rating": "platinum"
},
{
"id": 3568,
"normalized_name": "avowed",
@@ -404,6 +414,11 @@
"normalized_name": "ayasa shadows of silence",
"overall_rating": "platinum"
},
{
"id": 43802,
"normalized_name": "ball x pit",
"overall_rating": "platinum"
},
{
"id": 2352,
"normalized_name": "biomutant",
@@ -429,6 +444,11 @@
"normalized_name": "bad day l.a.",
"overall_rating": "platinum"
},
{
"id": 116179,
"normalized_name": "baldo the guardian owls",
"overall_rating": "platinum"
},
{
"id": 27439,
"normalized_name": "baldur's gate dark alliance ii",
@@ -534,6 +554,11 @@
"normalized_name": "battlefield v",
"overall_rating": "platinum"
},
{
"id": 6953,
"normalized_name": "beacon pines",
"overall_rating": "platinum"
},
{
"id": 3003,
"normalized_name": "bendy and the dark revival",
@@ -919,6 +944,11 @@
"normalized_name": "call of juarez",
"overall_rating": "silver"
},
{
"id": 128972,
"normalized_name": "captain contraption's chocolate factory",
"overall_rating": "platinum"
},
{
"id": 2554,
"normalized_name": "carx street",
@@ -989,6 +1019,16 @@
"normalized_name": "clair obscur expedition 33",
"overall_rating": "platinum"
},
{
"id": 1389,
"normalized_name": "clicker heroes",
"overall_rating": "platinum"
},
{
"id": 95679,
"normalized_name": "clive barker's hellraiser revival",
"overall_rating": "platinum"
},
{
"id": 137209,
"normalized_name": "clive barker's jericho",
@@ -1599,6 +1639,11 @@
"normalized_name": "doomsday × attack on titan",
"overall_rating": "platinum"
},
{
"id": 22403,
"normalized_name": "down in bermuda",
"overall_rating": "platinum"
},
{
"id": 137816,
"normalized_name": "downtown run",
@@ -1789,6 +1834,11 @@
"normalized_name": "everspace 2",
"overall_rating": "platinum"
},
{
"id": 32237,
"normalized_name": "echo weaver",
"overall_rating": "platinum"
},
{
"id": 135061,
"normalized_name": "eclipse",
@@ -1829,6 +1879,11 @@
"normalized_name": "enclave",
"overall_rating": "platinum"
},
{
"id": 142352,
"normalized_name": "end of abyss",
"overall_rating": "platinum"
},
{
"id": 1499,
"normalized_name": "enshrouded",
@@ -1899,6 +1954,21 @@
"normalized_name": "expeditions a mudrunner game",
"overall_rating": "platinum"
},
{
"id": 144749,
"normalized_name": "eye of the beholder",
"overall_rating": "platinum"
},
{
"id": 144750,
"normalized_name": "eye of the beholder ii the legend of darkmoon",
"overall_rating": "platinum"
},
{
"id": 144751,
"normalized_name": "eye of the beholder iii assault on myth drannor",
"overall_rating": "platinum"
},
{
"id": 2102,
"normalized_name": "f.e.a.r.",
@@ -1914,6 +1984,11 @@
"normalized_name": "f.e.a.r. perseus mandate",
"overall_rating": "gold"
},
{
"id": 6674,
"normalized_name": "fatal frame / project zero maiden of black water",
"overall_rating": "silver"
},
{
"id": 122228,
"normalized_name": "final fantasy xvi",
@@ -1924,6 +1999,11 @@
"normalized_name": "foundry",
"overall_rating": "platinum"
},
{
"id": 6470,
"normalized_name": "fpv kamikaze drone",
"overall_rating": "platinum"
},
{
"id": 25751,
"normalized_name": "front mission 1st remake",
@@ -1939,6 +2019,11 @@
"normalized_name": "ftl faster than light",
"overall_rating": "platinum"
},
{
"id": 57304,
"normalized_name": "futa runner big dick",
"overall_rating": "platinum"
},
{
"id": 2088,
"normalized_name": "fable anniversary",
@@ -1977,7 +2062,7 @@
{
"id": 3730,
"normalized_name": "fallout tactics brotherhood of steel",
"overall_rating": "gold"
"overall_rating": "platinum"
},
{
"id": 1586,
@@ -2012,7 +2097,7 @@
{
"id": 1509,
"normalized_name": "far cry 4",
"overall_rating": "broken"
"overall_rating": "silver"
},
{
"id": 112335,
@@ -2049,6 +2134,11 @@
"normalized_name": "fearmonium",
"overall_rating": "platinum"
},
{
"id": 142741,
"normalized_name": "femboy futa house",
"overall_rating": "platinum"
},
{
"id": 3557,
"normalized_name": "final fantasy iii (3d remake)",
@@ -2397,7 +2487,7 @@
{
"id": 134441,
"normalized_name": "hard truck 2 king of the road",
"overall_rating": "gold"
"overall_rating": "platinum"
},
{
"id": 2141,
@@ -2430,8 +2520,13 @@
"overall_rating": "platinum"
},
{
"id": 135348,
"id": 116886,
"normalized_name": "headquarters world war ii",
"overall_rating": null
},
{
"id": 95965,
"normalized_name": "hearts of iron iv",
"overall_rating": "platinum"
},
{
@@ -2479,6 +2574,11 @@
"normalized_name": "heroes of science and fiction",
"overall_rating": "platinum"
},
{
"id": 1930,
"normalized_name": "heroes of might & magic iii hd",
"overall_rating": "platinum"
},
{
"id": 3035,
"normalized_name": "hi fi rush",
@@ -2779,6 +2879,11 @@
"normalized_name": "kiborg",
"overall_rating": "platinum"
},
{
"id": 144768,
"normalized_name": "kamikaze strike fpv drone",
"overall_rating": "platinum"
},
{
"id": 118598,
"normalized_name": "kane & lynch 2 dog days",
@@ -3154,6 +3259,11 @@
"normalized_name": "maid of the dead",
"overall_rating": "platinum"
},
{
"id": 117468,
"normalized_name": "maneater",
"overall_rating": "platinum"
},
{
"id": 3619,
"normalized_name": "manhunt",
@@ -3344,6 +3454,11 @@
"normalized_name": "mirror's edge catalyst",
"overall_rating": "gold"
},
{
"id": 73042,
"normalized_name": "miss!don't affect my rank",
"overall_rating": "platinum"
},
{
"id": 71287,
"normalized_name": "modern warships",
@@ -3389,6 +3504,11 @@
"normalized_name": "mudrunner",
"overall_rating": "platinum"
},
{
"id": 2747,
"normalized_name": "mutant year zero road to eden",
"overall_rating": "platinum"
},
{
"id": 2061,
"normalized_name": "my summer car",
@@ -3684,6 +3804,11 @@
"normalized_name": "overgrowth",
"overall_rating": "platinum"
},
{
"id": 121097,
"normalized_name": "overlord",
"overall_rating": "platinum"
},
{
"id": 23742,
"normalized_name": "overthrown",
@@ -3869,6 +3994,11 @@
"normalized_name": "praetorians hd remaster",
"overall_rating": "platinum"
},
{
"id": 5719,
"normalized_name": "praey for the gods",
"overall_rating": "platinum"
},
{
"id": 83824,
"normalized_name": "prehistorik",
@@ -3884,6 +4014,11 @@
"normalized_name": "prey",
"overall_rating": "platinum"
},
{
"id": 7107,
"normalized_name": "prince of persia the lost crown",
"overall_rating": "platinum"
},
{
"id": 3646,
"normalized_name": "prince of persia the sands of time",
@@ -3940,9 +4075,9 @@
"overall_rating": "platinum"
},
{
"id": 134787,
"id": 28860,
"normalized_name": "quartet",
"overall_rating": "platinum"
"overall_rating": null
},
{
"id": 8765,
@@ -4344,6 +4479,11 @@
"normalized_name": "star wars zero company",
"overall_rating": "platinum"
},
{
"id": 1673,
"normalized_name": "star wars battlefront ii",
"overall_rating": "platinum"
},
{
"id": 116330,
"normalized_name": "star wars empire at war gold pack",
@@ -4524,6 +4664,11 @@
"normalized_name": "shadow gambit the cursed crew",
"overall_rating": "platinum"
},
{
"id": 9068,
"normalized_name": "shadow man",
"overall_rating": "platinum"
},
{
"id": 1745,
"normalized_name": "shadow of the tomb raider",
@@ -4614,6 +4759,11 @@
"normalized_name": "sleeping dogs",
"overall_rating": "platinum"
},
{
"id": 3137,
"normalized_name": "sniper elite",
"overall_rating": "platinum"
},
{
"id": 1624,
"normalized_name": "sniper elite 3",
@@ -4654,6 +4804,11 @@
"normalized_name": "sonic heroes",
"overall_rating": "platinum"
},
{
"id": 2065,
"normalized_name": "sonic mania",
"overall_rating": "platinum"
},
{
"id": 4357,
"normalized_name": "sorcerer king rivals",
@@ -4894,6 +5049,11 @@
"normalized_name": "stoneshard",
"overall_rating": "platinum"
},
{
"id": 144782,
"normalized_name": "stranger things 3 the game",
"overall_rating": "platinum"
},
{
"id": 142281,
"normalized_name": "stranglehold",
@@ -5039,6 +5199,11 @@
"normalized_name": "tron 2.0",
"overall_rating": "platinum"
},
{
"id": 3256,
"normalized_name": "tails of iron",
"overall_rating": "platinum"
},
{
"id": 2892,
"normalized_name": "tainted grail the fall of avalon",
@@ -5129,6 +5294,11 @@
"normalized_name": "the alters",
"overall_rating": "platinum"
},
{
"id": 144742,
"normalized_name": "the amazing spider man (2012)",
"overall_rating": "bronze"
},
{
"id": 1873,
"normalized_name": "the ascent",
@@ -5164,6 +5334,11 @@
"normalized_name": "the callisto protocol",
"overall_rating": "platinum"
},
{
"id": 144752,
"normalized_name": "the chronicles of riddick assault on dark athena",
"overall_rating": "gold"
},
{
"id": 6140,
"normalized_name": "the crust",
@@ -5254,6 +5429,11 @@
"normalized_name": "the gold river project",
"overall_rating": "platinum"
},
{
"id": 95161,
"normalized_name": "the imperial gatekeeper",
"overall_rating": "platinum"
},
{
"id": 122565,
"normalized_name": "the invincible",
@@ -5267,7 +5447,7 @@
{
"id": 1898,
"normalized_name": "the last remnant",
"overall_rating": "silver"
"overall_rating": "platinum"
},
{
"id": 117639,
@@ -5674,6 +5854,11 @@
"normalized_name": "tropico 2 pirate cove",
"overall_rating": "gold"
},
{
"id": 131874,
"normalized_name": "tropico 5",
"overall_rating": "platinum"
},
{
"id": 87300,
"normalized_name": "truck driver",
@@ -5797,7 +5982,7 @@
{
"id": 143163,
"normalized_name": "vk play",
"overall_rating": "bronze"
"overall_rating": "platinum"
},
{
"id": 2997,
@@ -6044,6 +6229,11 @@
"normalized_name": "whiskerwood",
"overall_rating": "platinum"
},
{
"id": 107512,
"normalized_name": "wild west pioneers",
"overall_rating": "platinum"
},
{
"id": 116847,
"normalized_name": "will rock",
@@ -6067,7 +6257,7 @@
{
"id": 138835,
"normalized_name": "witcher 3 wild hunt",
"overall_rating": "platinum"
"overall_rating": "gold"
},
{
"id": 6964,
@@ -6109,6 +6299,11 @@
"normalized_name": "world of tanks",
"overall_rating": "platinum"
},
{
"id": 1335,
"normalized_name": "world of tanks blitz",
"overall_rating": "platinum"
},
{
"id": 131549,
"normalized_name": "world of warcraft",
@@ -6277,7 +6472,7 @@
{
"id": 10633,
"normalized_name": "калибр",
"overall_rating": "platinum"
"overall_rating": "silver"
},
{
"id": 138882,
Binary file not shown.
+3 -1
View File
@@ -4,6 +4,7 @@ import warnings
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
from PySide6.QtWidgets import QApplication, QLineEdit
from portprotonqt.logger import get_logger
from portprotonqt.config import ui_config
@@ -167,7 +168,8 @@ class GameCardAnimations:
"""Handle mouse enter event animations."""
self.game_card._hovered = True
self.game_card.hoverChanged.emit(self.game_card.name, True)
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
if not isinstance(QApplication.focusWidget(), QLineEdit):
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
if not self.thickness_anim:
self.setup_animations()
+18 -9
View File
@@ -313,6 +313,13 @@ def main():
# Launch game via PortProton without GUI
env_vars = os.environ.copy()
from portprotonqt.steam_api import get_steam_runtime_library_paths
steam_runtime_paths = get_steam_runtime_library_paths()
if steam_runtime_paths:
runtime_path = env_vars.get("STEAM_RUNTIME_LIBRARY_PATH", "")
env_vars["STEAM_RUNTIME_LIBRARY_PATH"] = os.pathsep.join(
path for path in [runtime_path, *steam_runtime_paths] if path
)
cmd = start_sh + [exe_path] + parsed_args.launch_args
try:
subprocess.run(cmd, env=env_vars)
@@ -807,13 +814,14 @@ def main():
return
QMessageBox.warning(window, _("Error"), _("Failed to update AppImage."))
window.appimageUpdateWorker = AppImageUpdateWorker("check")
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
window.appimageUpdateWorker.finished.connect(
lambda: setattr(window, "appimageUpdateWorker", None)
)
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
if not is_silent_launch:
window.appimageUpdateWorker = AppImageUpdateWorker("check")
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
window.appimageUpdateWorker.finished.connect(
lambda: setattr(window, "appimageUpdateWorker", None)
)
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."""
@@ -900,14 +908,15 @@ def main():
finally:
self.finished.emit()
if start_sh:
# Initial setup can stop Wine and must not race a silent game launch.
if start_sh and not silent_game_request:
worker = InitialCommandWorker(start_sh)
window.initialCommandWorker = worker
worker.finished.connect(
lambda: setattr(window, "initialCommandWorker", None)
)
worker.start()
else:
elif not start_sh:
logger.warning("PortProton start command not available, skipping initial command")
# --- Cleanup ---
+1 -2
View File
@@ -1,5 +1,4 @@
"""Cache configuration and management."""
import json
import shutil
import threading
import time
@@ -118,7 +117,7 @@ class CacheManager:
temp_file = cache_file.with_name(f".{cache_file.name}.{time.time_ns()}.tmp")
try:
if pretty:
content = json.dumps(data, indent=2).encode("utf-8")
content = orjson.dumps(data, option=orjson.OPT_INDENT_2)
else:
content = orjson.dumps(data)
with _CACHE_FILE_LOCK:
+9
View File
@@ -519,6 +519,15 @@ class UIConfig(BaseConfig):
validate_bool(enabled, "crash_reports_enabled")
self._save_value("crash_reports_enabled", enabled, "bool")
def get_disable_cyrillic_path_check(self) -> bool:
"""Get Cyrillic launch path check setting."""
return self._get_bool("disable_cyrillic_path_check", False)
def set_disable_cyrillic_path_check(self, enabled: bool) -> None:
"""Set Cyrillic launch path check setting."""
validate_bool(enabled, "disable_cyrillic_path_check")
self._save_value("disable_cyrillic_path_check", enabled, "bool")
def get_disable_alt_i586_dependency_check(self) -> bool:
"""Get ALT i586 dependency check setting."""
return self._get_bool("disable_alt_i586_dependency_check", False)
+118 -8
View File
@@ -4,8 +4,8 @@ import shutil
import tempfile
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess, QThread
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
from portprotonqt.localization import _
from portprotonqt.config import (
create_desktop_file,
@@ -68,6 +68,14 @@ class ContextMenuManager:
self.theme = theme
self.theme_manager = ThemeManager()
self.game_library_manager = game_library_manager
self._batch_action = False
self.card_selection_mode = False
self._card_actions: list[QAction] = []
self._card_action_menus: list[QMenu] = []
self._card_actions_stopping = False
self._card_action_timer = QTimer(parent)
self._card_action_timer.setSingleShot(True)
self._card_action_timer.timeout.connect(self._run_next_card_action)
self.update_game_grid = game_library_manager.update_game_grid
self.signals = ContextMenuSignals()
self.signals.show_warning_dialog.connect(
@@ -449,7 +457,92 @@ class ContextMenuManager:
return self.theme_manager.get_colored_icon_path(icon_name, color)
return None
def show_context_menu(self, game_card, pos: QPoint):
def clear_card_selection(self) -> None:
self.card_selection_mode = False
for card in self.game_library_manager.game_card_cache.values():
if card.batch_selected:
card.batch_selected = False
card.update()
def start_card_selection(self, game_card: "GameCard") -> None:
self.clear_card_selection()
self.card_selection_mode = True
game_card.batch_selected = True
game_card.update()
game_card.setFocus(Qt.FocusReason.OtherFocusReason)
def _start_card_actions(self, actions: list[QAction], menus: list[QMenu]) -> None:
if self._batch_action or self._card_actions_stopping:
return
self.clear_card_selection()
self._batch_action = True
self._card_actions = list(reversed(actions))
self._card_action_menus = menus
for menu in menus:
menu.setParent(self.parent, menu.windowFlags())
self._run_next_card_action()
def _run_next_card_action(self) -> None:
if not self._batch_action:
return
jobs = [getattr(self.parent, name, None) for name in (
"egs_process", "gog_process", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker",
)]
jobs.extend(getattr(self.parent, "gog_support_workers", []))
for job in jobs:
if job is None or not isinstance(job, (QProcess, QThread)):
continue
job.finished.connect(lambda *args: self._card_action_timer.start() if not self._card_actions_stopping else None)
if ((isinstance(job, QProcess) and job.state() == QProcess.ProcessState.NotRunning)
or (isinstance(job, QThread) and not job.isRunning())):
self._card_action_timer.start()
return
if self._card_actions_stopping or not self._card_actions:
self._batch_action = False
for menu in self._card_action_menus:
menu.deleteLater()
self._card_action_menus = []
self._card_actions = []
return
self._card_actions.pop().trigger()
self._card_action_timer.start()
def stop_card_actions(self) -> None:
self._card_actions_stopping = True
self._card_action_timer.stop()
self._card_actions = []
self._batch_action = False
for menu in self._card_action_menus:
menu.deleteLater()
self._card_action_menus = []
def _show_card_selection_menu(self, menu: QMenu, game_card: "GameCard", pos: QPoint) -> None:
grouped: dict[str, list[QAction]] = {}
menus: list[QMenu] = []
for card in self.game_library_manager.game_card_cache.values():
if not card.batch_selected:
continue
card_menu = self.show_context_menu(card, pos, build_only=True)
if card_menu is None:
continue
card_menu.setParent(menu, card_menu.windowFlags())
menus.append(card_menu)
for action in card_menu.actions():
if not action.isSeparator() and action.isEnabled():
grouped.setdefault(action.text(), []).append(action)
for text, actions in grouped.items():
action = menu.addAction(actions[0].icon(), text)
action.setEnabled(not self._batch_action)
action.triggered.connect(
lambda checked=False, actions=actions: self._start_card_actions(actions, menus)
)
menu.addSeparator()
cancel_action = menu.addAction(_("Cancel"))
cancel_action.triggered.connect(self.clear_card_selection)
menu.setActiveAction(next(action for action in menu.actions() if action.isEnabled() and not action.isSeparator()))
menu.popup(game_card.mapToGlobal(pos))
def show_context_menu(self, game_card, pos: QPoint, build_only: bool = False):
"""
Show the context menu for a game card at the specified position.
@@ -463,6 +556,16 @@ class ContextMenuManager:
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
if not build_only:
if self.card_selection_mode or game_card.batch_selected:
self._show_card_selection_menu(menu, game_card, pos)
return
self.clear_card_selection()
selection_action = menu.addAction(_("Select multiple"))
selection_action.triggered.connect(lambda: self.start_card_selection(game_card))
menu.setActiveAction(selection_action)
menu.addSeparator()
# For non-Steam games, check if exe exists
if game_card.game_source == "portproton":
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
@@ -471,6 +574,8 @@ class ContextMenuManager:
# Show only "Delete from PortProton" if no valid exe
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
if build_only:
return menu
menu.popup(game_card.mapToGlobal(pos))
return
@@ -709,6 +814,9 @@ class ContextMenuManager:
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
)
if build_only:
return menu
# Set focus to the first menu item
actions = menu.actions()
if actions:
@@ -1054,6 +1162,9 @@ class ContextMenuManager:
self.game_library_manager.remove_game_incremental(game_name, card_exec_line or resolved_exec_line)
def delete_game(self, game_name, exec_line):
if self._batch_action:
self._delete_game_without_confirm(game_name, exec_line)
return
msg_box = QMessageBox(self.parent)
msg_box.setIcon(QMessageBox.Icon.Question)
msg_box.setWindowTitle(_("Confirm Deletion"))
@@ -1623,16 +1734,13 @@ class ContextMenuManager:
try:
success, message = add_to_steam(game_name, exec_line, cover_path)
if success:
if self._batch_action:
return
if "restart" in message.lower():
self.signals.show_info_dialog.emit(
_("Success"),
_("'{game_name}' was added to Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
)
else:
self.signals.show_info_dialog.emit(
_("Success"),
_("'{game_name}' was added to Steam.").format(game_name=game_name)
)
else:
self.signals.show_warning_dialog.emit(
_("Error"),
@@ -1654,6 +1762,8 @@ class ContextMenuManager:
def on_remove_from_steam_result(result: tuple[bool, str]):
success, message = result
if success:
if self._batch_action:
return
if "restart" in message.lower():
self.signals.show_info_dialog.emit(
_("Success"),
+432 -3
View File
@@ -1,23 +1,33 @@
"""Base dialogs for PortProtonQt."""
import os
import re
from pathlib import Path
from collections import deque
from functools import partial
import tempfile
from typing import cast, TYPE_CHECKING
from typing import Any, cast, TYPE_CHECKING
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
QProgressBar, QSizePolicy, QWidget, QCheckBox
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication
)
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
import psutil
import orjson
import pefile
if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow
from portprotonqt.dialogs.file_explorer import FileExplorer
from portprotonqt.config import (
LAUNCH_FILE_EXTENSIONS,
THEMED_LAUNCH_ICON_NAMES,
create_desktop_file,
extract_exec_target_path,
get_portproton_location,
parse_desktop_entry,
ui_config,
)
from portprotonqt.logger import get_logger
@@ -28,6 +38,7 @@ from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.localization import _
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS, set_animated_cover
from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
logger = get_logger(__name__)
theme_manager = ThemeManager()
@@ -125,9 +136,229 @@ class GameLaunchDialog(DraggableDialog):
super().reject()
class GameFolderScanWorker(QThread):
"""Find executable files recursively and report scanned directory counts."""
progressChanged = Signal(object)
def __init__(self, folder: str, parent: QObject) -> None:
super().__init__(parent)
self.folder = folder
self.games: list[tuple[str, str]] = []
self.errors: list[str] = []
MAX_SCAN_DEPTH = 5
MAX_SCAN_DIRECTORIES = 400
GAME_SUBDIRECTORIES = frozenset((
"bin", "bin32", "bin64", "binaries", "win32", "win64", "x64", "x86", "game", "disccontentpc", "api",
))
def run(self) -> None:
for directory, paths in self._find_executables(self.folder).items():
if self.isInterruptionRequested():
return
try:
executable = max(paths, key=lambda path: self._executable_score(path, directory))
self.games.append((self._game_name(executable, directory), executable))
except OSError as error:
self._record_error(error)
self.games.sort(key=lambda game: game[1].casefold())
def _executable_score(self, executable: str, directory: str) -> tuple[int, int, int]:
name = Path(executable).stem.casefold()
folder = re.sub(r"[\W_]", "", os.path.basename(directory).casefold())
key = re.sub(r"[\W_]", "", name)
match = int(key == folder) + int(bool(key and folder and (key in folder or folder in key)))
launcher = name.endswith(("launcher", "redirector")) or name in ("play", "start", "run")
depth = len(Path(executable).relative_to(directory).parts)
return match - int(launcher), -depth, os.path.getsize(executable)
def _executable_name(self, executable: str) -> str:
try:
with pefile.PE(executable, fast_load=True) as pe:
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
tables = [table for block in getattr(pe, "FileInfo", []) for info in block
for table in getattr(info, "StringTable", [])]
metadata = {key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
for table in tables for key, value in table.entries.items()}
except pefile.PEFormatError:
return ""
except OSError as error:
logger.warning("Failed to read executable metadata %s: %s", executable, error)
return ""
for field in ("ProductName", "FileDescription"):
value = metadata.get(field, "").strip()
if value and not re.fullmatch(
r"(?i)(?:BootstrapPackagedGame|game|application|launcher|.*launcher.*|.*redistributable.*)", value,
):
return value
return ""
def _game_name(self, executable: str, directory: str) -> str:
name = ""
for info in sorted(Path(directory).glob("goggame-*.info")):
try:
data = orjson.loads(info.read_bytes().removeprefix(b"\xef\xbb\xbf"))
if (isinstance(data, dict) and isinstance(data.get("name"), str)
and data.get("gameId") == data.get("rootGameId")):
name = data["name"]
break
except OSError as error:
logger.warning("Failed to read game metadata %s: %s", info, error)
except ValueError as error:
logger.warning("Invalid game metadata %s: %s", info, error)
if not name:
name = self._executable_name(executable) or Path(directory).name
name = re.sub(r"(?i)[._ ]+(?:v?\d+(?:[._]\d+)+|build[._ ]?\d+).*$", "", name)
name = name.replace(".", " ")
name = re.sub(r"[\\/\r\n]", " ", name).replace(":", " - ")
return " ".join(name.replace("_", " ").split()).strip(" .") or Path(executable).stem
def _record_error(self, error: OSError) -> None:
logger.warning("Failed to scan game folder: %s", error)
self.errors.append(str(error))
def _is_runtime_directory(self, path: str) -> bool:
name = os.path.basename(path).casefold()
parent = os.path.basename(os.path.dirname(path)).casefold()
runtime = r"(?:i386-windows|x86_64-windows|mono|monobleedingedge|wine-mono.*|jre[\d._-]*|jdk[\d._-]*|java-runtime.*|_?commonredist|__installer)"
if re.fullmatch(runtime, name) or name in ("$recycle.bin", "system volume information", "config.msi", "msdownld.tmp"):
return True
if name == "windows" and parent == "drive_c":
return True
if name in ("windows media player", "internet explorer", "windows nt") and parent.startswith("program files"):
return True
if "proton" in name or name.startswith("wine"):
return os.path.isfile(os.path.join(path, "proton")) or os.path.isdir(os.path.join(path, "lib", "wine"))
return False
def _find_executables(self, folder: str) -> dict[str, list[str]]:
games: dict[str, list[str]] = {}
pending = deque([] if self._is_runtime_directory(folder) else [(folder, 0, folder)])
visited: dict[str, int] = {}
completed, total = 0, len(pending)
ignored = r"(?i)(?:unins.*|setup.*|install.*|.*redist.*|dxsetup.*|directx.*|dotnet.*|.*crash.*|.*report.*|.*prereq.*|.*updater?.*|.*config.*|.*settings.*|.*helper.*|.*benchmark.*|.*-win(?:32|64)-shipping|.*easyanticheat.*|.*battleye.*|oalinst|overlay_test.*|.*[-_](?:setup|installer).*|.*errormonitor|pluginhost|.*_api)"
skipped = {"engine", "melonloader", "mods", "plugins", "userdata", "userlibs", "reshade_shaders",
"assets", "data", "resources", "res", "res_mods", "replays", "movies", "cache", "logs"}
while pending:
if self.isInterruptionRequested():
return {}
root, depth, branch = pending.popleft()
label = _("Scanning: {folder}").format(folder=root)
self.progressChanged.emit((completed, total, label))
try:
with os.scandir(root) as entries:
entries_list = sorted(entries, key=lambda entry: entry.name.casefold())
files = [entry.path for entry in entries_list if entry.name.casefold().endswith(".exe")
and not re.fullmatch(ignored, entry.name[:-len(".exe")])
and not entry.is_symlink() and entry.is_file(follow_symlinks=False)]
directory = root
while directory != folder and Path(directory).name.casefold() in self.GAME_SUBDIRECTORIES:
directory = os.path.dirname(directory)
if files:
games.setdefault(directory, []).extend(files)
for entry in entries_list:
name = entry.name.casefold()
if (depth >= self.MAX_SCAN_DEPTH or entry.is_symlink() or not entry.is_dir(follow_symlinks=False)
or name.startswith((".", "$")) or name in skipped or name.endswith("_data")
or re.fullmatch(ignored, name) or self._is_runtime_directory(entry.path)):
continue
if files and root != folder and name not in self.GAME_SUBDIRECTORIES:
continue
owner = entry.path if root == folder or Path(root).name.casefold() == "common" else branch
if visited.get(owner, 0) >= self.MAX_SCAN_DIRECTORIES:
self._record_error(OSError(f"Game folder scan directory limit reached: {owner}"))
break
visited[owner] = visited.get(owner, 0) + 1
next_depth = 0 if owner == entry.path or name in ("steamapps", "common") else depth + 1
pending.append((entry.path, next_depth, owner))
total += 1
except OSError as error:
self._record_error(error)
completed += 1
self.progressChanged.emit((completed, total, label))
return games
class GameFolderImportWorker(QThread):
"""Create selected shortcuts without overwriting existing entries."""
progressChanged = Signal(object)
def __init__(self, games: list[tuple[str, str]], parent: QObject) -> None:
super().__init__(parent)
self.games = games
self.added = 0
self.skipped = 0
self.errors: list[str] = []
def run(self) -> None:
try:
location = get_portproton_location()
if not location:
raise FileNotFoundError("PortProton directory not found")
existing = set()
with os.scandir(location) as entries:
paths = [entry.path for entry in entries if entry.name.endswith(".desktop") and entry.is_file()]
for index, path in enumerate(paths):
if self.isInterruptionRequested():
return
self.progressChanged.emit((index, len(paths), _("Checking existing shortcuts: {path}").format(path=path)))
entry = parse_desktop_entry(path)
target = extract_exec_target_path(entry.get("Exec", "")) if entry else None
if target:
existing.add(os.path.realpath(target))
for index, (name, executable) in enumerate(self.games):
if self.isInterruptionRequested():
return
self.progressChanged.emit((index, len(self.games), _("Adding: {name}").format(name=name)))
self._import_game(name, executable, existing)
self.progressChanged.emit((index + 1, len(self.games), _("Adding: {name}").format(name=name)))
except OSError as error:
logger.warning("Failed to import game folder: %s", error)
self.errors.append(str(error))
def _import_game(self, name: str, executable: str, existing: set[str]) -> None:
target = os.path.realpath(executable)
if target in existing:
self.skipped += 1
return
created = False
desktop_path = ""
try:
name = " ".join(re.sub(r"[\\/]", " ", name).split()).strip(" .")
result = create_desktop_file(executable, name)
if result is None:
raise OSError(_("Failed to create shortcut: {name}").format(name=name))
content, desktop_path, icon_path = result
if os.path.exists(desktop_path):
self.skipped += 1
return
if not os.path.exists(icon_path) and not generate_thumbnail(executable, icon_path, size=128):
content = content.replace(f"Icon={icon_path}\n", "Icon=\n")
with open(desktop_path, "x", encoding="utf-8") as desktop:
created = True
desktop.write(content)
os.chmod(desktop_path, 0o755)
existing.add(target)
self.added += 1
except FileExistsError:
self.skipped += 1
except OSError as error:
logger.warning("Failed to import '%s': %s", executable, error)
self.errors.append(f"{name}: {error}")
if created:
try:
os.unlink(desktop_path)
except OSError as cleanup_error:
logger.warning("Failed to remove incomplete shortcut: %s", cleanup_error)
class AddGameDialog(DraggableDialog):
"""Dialog for adding or editing a game."""
gamesAdded = Signal()
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None, steam_appid=None):
super().__init__(parent)
from portprotonqt.context_menu_manager import CustomLineEdit
@@ -135,6 +366,9 @@ class AddGameDialog(DraggableDialog):
self.edit_mode = edit_mode
self.last_exe_path = exe_path
self.last_cover_path = cover_path
refresh_games = getattr(parent, "refreshGames", None)
if callable(refresh_games):
self.gamesAdded.connect(refresh_games)
self.downloader = Downloader(max_workers=4)
self.setWindowTitle(_("Edit Shortcut") if edit_mode else _("Add a shortcut"))
@@ -338,6 +572,12 @@ class AddGameDialog(DraggableDialog):
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
if not self.edit_mode:
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
folder_button.setObjectName("gamepadAddButton")
folder_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
folder_button.clicked.connect(partial(self._select_game_folder, file_explorer))
file_explorer.button_layout.insertWidget(0, folder_button)
parent_widget = self.parentWidget()
if parent_widget:
@@ -351,6 +591,195 @@ class AddGameDialog(DraggableDialog):
except Exception as e:
logger.error(f"Error in browseExe: {e}")
def _select_game_folder(self, explorer: "FileExplorer") -> None:
folder = explorer.current_path
item = explorer.file_list.currentItem()
if item and item.text() != "../":
selected = os.path.normpath(os.path.join(folder, item.text()))
if os.path.isdir(selected):
folder = selected
self._game_folder_root = folder
explorer.accept()
self._start_folder_worker(GameFolderScanWorker(folder, self.parentWidget() or self))
def _start_folder_worker(self, worker: GameFolderScanWorker | GameFolderImportWorker) -> None:
owner = cast(Any, self.parentWidget() or self)
workers = getattr(owner, "_gameFolderWorkers", [])
owner._gameFolderWorkers = workers
workers.append(worker)
progress = QProgressDialog(_("Processing games…"), _("Cancel"), 0, 1, self)
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setMinimumDuration(0)
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
progress.setAutoClose(False)
progress.setAutoReset(False)
progress.setValue(0)
progress.canceled.connect(worker.requestInterruption)
worker.progressChanged.connect(partial(self._update_folder_progress, progress), Qt.ConnectionType.QueuedConnection)
worker.finished.connect(partial(self._finish_folder_worker, worker, progress), Qt.ConnectionType.QueuedConnection)
progress.show()
worker.start()
def _update_folder_progress(self, progress: QProgressDialog, state: tuple[int, int, str]) -> None:
if progress.wasCanceled():
return
completed, total, current = state
progress.setLabelText(_("Processed {completed}/{total}\n{current}").format(
completed=completed, total=total, current=current,
))
progress.setRange(0, max(total, 1))
progress.setValue(completed)
def _finish_folder_worker(self, worker: QThread, progress: QProgressDialog) -> None:
from PySide6.QtWidgets import QMessageBox
owner = self.parentWidget() or self
workers = getattr(owner, "_gameFolderWorkers", [])
cancelled = progress.wasCanceled()
progress.close()
progress.deleteLater()
worker.deleteLater()
if worker not in workers:
return
workers.remove(worker)
if isinstance(worker, GameFolderImportWorker):
if worker.added:
self.gamesAdded.emit()
if cancelled:
return
if worker.errors:
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
self.reject()
elif isinstance(worker, GameFolderScanWorker) and not cancelled:
if worker.errors:
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
if worker.games:
self._show_folder_games(worker.games)
else:
QMessageBox.information(self, _("Information"), _("No games found in the selected folder."))
def _create_folder_game_row(
self, layout: QVBoxLayout, game: tuple[str, str],
) -> tuple[QLineEdit, QLineEdit, QCheckBox]:
from portprotonqt.context_menu_manager import CustomLineEdit
name, executable = game
content = layout.parentWidget()
row = QHBoxLayout()
checkbox = QCheckBox(content)
checkbox.setObjectName("gameFolderExecutable")
checkbox.setAccessibleName(executable)
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
checkbox.setChecked(True)
title = CustomLineEdit(name, content, theme=self.theme)
title.setAccessibleName(_("Name:"))
title.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
path = CustomLineEdit(executable, content, theme=self.theme)
path.setObjectName("gameFolderPath")
path.setAccessibleName(_("Path to Executable:"))
path.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("folder", as_path=True))
browse.setAutoDefault(False)
browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
browse.clicked.connect(partial(self._browse_folder_exe, path))
path_row = QHBoxLayout()
path_row.addWidget(path)
path_row.addWidget(browse)
details = QVBoxLayout()
details.addWidget(title)
details.addLayout(path_row)
row.addWidget(checkbox)
row.addLayout(details)
layout.addLayout(row)
QWidget.setTabOrder(checkbox, title)
QWidget.setTabOrder(title, path)
QWidget.setTabOrder(path, browse)
return title, path, checkbox
def _browse_folder_exe(self, field: QLineEdit) -> None:
from portprotonqt.dialogs.file_explorer import FileExplorer
explorer = FileExplorer(
field.window(), theme=self.theme, file_filter=(".exe",),
initial_path=os.path.dirname(os.path.expanduser(field.text().strip())),
)
explorer.file_signal.file_selected.connect(field.setText)
explorer.exec()
field.setFocus(Qt.FocusReason.OtherFocusReason)
explorer.deleteLater()
def _accept_folder_games(
self, dialog: QDialog, rows: list[tuple[QLineEdit, QLineEdit, QCheckBox]],
) -> None:
from PySide6.QtWidgets import QMessageBox
for title, path, checkbox in rows:
if not checkbox.isChecked():
continue
if not title.text().strip():
QMessageBox.warning(dialog, _("Error"), _("Game name and executable path are required"))
title.setFocus(Qt.FocusReason.OtherFocusReason)
return
executable = os.path.expanduser(path.text().strip())
if not os.path.isfile(executable):
QMessageBox.warning(dialog, _("Error"), _("Executable not found: {0}").format(executable))
path.setFocus(Qt.FocusReason.OtherFocusReason)
return
path.setText(executable)
dialog.accept()
def _scroll_folder_focus(self, scroll: QScrollArea, _old: QWidget | None, focused: QWidget | None) -> None:
if focused is not None and scroll.isAncestorOf(focused):
scroll.ensureWidgetVisible(focused)
def _show_folder_games(self, games: list[tuple[str, str]]) -> None:
dialog = cast(Any, DraggableDialog(self))
dialog.setWindowTitle(_("Select games"))
dialog.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
dialog.resize(*self.theme.GAME_FOLDER_IMPORT_DIALOG_SIZE)
layout = QVBoxLayout(dialog)
scroll = QScrollArea(dialog)
scroll.setWidgetResizable(True)
content = QWidget(scroll)
rows_layout = QVBoxLayout(content)
rows = [self._create_folder_game_row(rows_layout, game) for game in games]
rows_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
buttons = QHBoxLayout()
apply_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply", as_path=True))
apply_button.setObjectName("gamepadAddButton")
cancel_button = AutoSizeButton(_("Close"), icon=theme_manager.get_icon("cancel", as_path=True))
for button in (apply_button, cancel_button):
button.setAutoDefault(False)
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
buttons.addWidget(button)
apply_button.clicked.connect(partial(self._accept_folder_games, dialog, rows))
cancel_button.clicked.connect(dialog.reject)
layout.addLayout(buttons)
main_window = self.parentWidget()
input_manager = getattr(main_window, "input_manager", None)
hints, labels = create_dialog_hints_widget(self.theme, main_window, input_manager, context='folder_import')
layout.addWidget(hints)
update_dialog_hints(labels, main_window, input_manager, theme_manager, ui_config.get_theme())
dialog.keyboard = VirtualKeyboard(dialog, theme=self.theme)
dialog.finished.connect(dialog.keyboard.hide)
if rows:
QTimer.singleShot(0, rows[0][2].setFocus)
app = cast(QApplication, QApplication.instance())
scroll_focus = partial(self._scroll_folder_focus, scroll)
app.focusChanged.connect(scroll_focus)
try:
accepted = dialog.exec() == QDialog.DialogCode.Accepted
finally:
app.focusChanged.disconnect(scroll_focus)
if accepted:
selected = [(title.text().strip(), path.text().strip()) for title, path, checkbox in rows
if checkbox.isChecked()]
if selected:
self._start_folder_worker(GameFolderImportWorker(selected, self.parentWidget() or self))
dialog.deleteLater()
def onExeSelected(self, file_path):
"""Handle file selection in FileExplorer."""
self.exeEdit.setText(file_path)
+91 -1
View File
@@ -3,8 +3,14 @@
from __future__ import annotations
from typing import Any
import os
from PySide6.QtCore import QPoint, Qt
import orjson
from PySide6.QtCore import QIODevice, QPoint, QSaveFile, Qt, QUrl
from PySide6.QtNetwork import (
QNetworkAccessManager, QNetworkDiskCache, QNetworkProxy, QNetworkReply, QNetworkRequest,
)
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -14,8 +20,12 @@ from PySide6.QtWidgets import (
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.config import CACHE_DIR, proxy_config, ui_config
from portprotonqt.dialogs.base import DraggableDialog
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
class CompatibilityReportDialog(DraggableDialog):
@@ -70,6 +80,11 @@ class CompatibilityReportDialog(DraggableDialog):
def _create_buttons(self, theme) -> QHBoxLayout:
layout = QHBoxLayout()
layout.addStretch()
self.ppdb_button = AutoSizeButton(_("Download") + " PPDB")
self.ppdb_button.setStyleSheet(theme.ACTION_BUTTON_STYLE)
self.ppdb_button.hide()
self.ppdb_button.clicked.connect(self._download_ppdb)
layout.addWidget(self.ppdb_button)
close_button = AutoSizeButton(_("Close"))
close_button.setStyleSheet(theme.ACTION_BUTTON_ACTIVE_STYLE)
close_button.clicked.connect(self.accept)
@@ -77,6 +92,81 @@ class CompatibilityReportDialog(DraggableDialog):
layout.addWidget(close_button)
return layout
def check_ppdb(self, executable: str) -> None:
if ui_config.get_auto_download_ppdb() or not os.path.isfile(executable):
return
self.ppdb_path = executable + ".ppdb"
self.ppdb_network = QNetworkAccessManager(self)
self.finished.connect(self.ppdb_network.deleteLater)
self.ppdb_network.setTransferTimeout(
QNetworkRequest.TransferTimeoutConstant.DefaultTransferTimeoutConstant.value
)
cache = QNetworkDiskCache(self.ppdb_network)
cache.setCacheDirectory(str(CACHE_DIR / "ppdb_lookup"))
self.ppdb_network.setCache(cache)
proxies = proxy_config.get_proxy() or {}
proxy_url = QUrl(proxies.get("https") or proxies.get("http") or "")
if proxy_url.host():
proxy_type = (QNetworkProxy.ProxyType.Socks5Proxy
if proxy_url.scheme().startswith("socks")
else QNetworkProxy.ProxyType.HttpProxy)
self.ppdb_network.setProxy(QNetworkProxy(
proxy_type, proxy_url.host(), max(proxy_url.port(), 0),
proxy_url.userName(), proxy_url.password(),
))
url = QUrl("https://linux-gaming.ru/api/lookup/exe/")
url.setPath(url.path() + os.path.basename(executable))
reply = self.ppdb_network.get(QNetworkRequest(url))
reply.finished.connect(lambda: self._ppdb_lookup_finished(reply))
def _ppdb_lookup_finished(self, reply: QNetworkReply) -> None:
reply.deleteLater()
if reply.error() != QNetworkReply.NetworkError.NoError:
logger.warning("PPDB lookup failed: %s", reply.errorString())
return
try:
metadata = orjson.loads(bytes(reply.readAll().data()))
except ValueError as error:
logger.warning("Invalid PPDB metadata: %s", error)
return
if not isinstance(metadata, dict):
return
url = metadata.get("ppdb_url")
if not isinstance(url, str):
return
self.ppdb_url = QUrl(url)
if self.ppdb_url.scheme() in {"https", "http"} and self.ppdb_url.host():
self.ppdb_button.show()
def _download_ppdb(self) -> None:
self.ppdb_button.setEnabled(False)
self.ppdb_button.setText(_("Downloading…"))
request = QNetworkRequest(self.ppdb_url)
request.setAttribute(
QNetworkRequest.Attribute.CacheLoadControlAttribute,
QNetworkRequest.CacheLoadControl.AlwaysNetwork,
)
reply = self.ppdb_network.get(request)
reply.finished.connect(lambda: self._ppdb_download_finished(reply))
def _ppdb_download_finished(self, reply: QNetworkReply) -> None:
reply.deleteLater()
self.ppdb_button.setText(_("Download") + " PPDB")
self.ppdb_button.setEnabled(True)
if reply.error() != QNetworkReply.NetworkError.NoError:
logger.warning("PPDB download failed: %s", reply.errorString())
self.ppdb_button.setText(_("Download process failed"))
return
destination = QSaveFile(self.ppdb_path)
data = reply.readAll()
if (data.isEmpty() or not destination.open(QIODevice.OpenModeFlag.WriteOnly)
or destination.write(data) != data.size() or not destination.commit()):
logger.warning("Failed to save PPDB: %s", destination.errorString())
self.ppdb_button.setText(_("Download process failed"))
return
self.ppdb_button.setText(_("Downloading Complete"))
self.ppdb_button.setEnabled(False)
def _show_context_menu(
self, report_view: QPlainTextEdit, theme: Any, position: QPoint
) -> None:
+7 -3
View File
@@ -25,7 +25,12 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
dialog_actions = []
if context == 'file_explorer':
if context == 'folder_import':
dialog_actions = [
("add_game", _("Apply")),
("back", _("Close")),
]
elif context == 'file_explorer':
dialog_actions = [
("confirm", _("Open")),
("add_game", _("Select Dir")),
@@ -45,10 +50,9 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
elif context == 'settings':
dialog_actions = [
("confirm", _("Toggle")),
("add_game", _("Save")),
("prev_dir", _("Search")),
("context_menu", _("Menu")),
("back", _("Cancel")),
("back", _("Close")),
("prev_tab", _("Prev Tab")),
("next_tab", _("Next Tab")),
]
+9 -13
View File
@@ -59,7 +59,7 @@ class ProtonManager(DraggableDialog):
self.main_window = None
parent_widget = self.parent()
while parent_widget:
if hasattr(parent_widget, 'input_manager'):
if hasattr(parent_widget, 'get_button_icon'):
self.main_window = parent_widget
break
parent_widget = parent_widget.parent()
@@ -69,6 +69,14 @@ class ProtonManager(DraggableDialog):
if self.input_manager:
self.enable_proton_manager_mode()
if self.main_window:
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('proton_manager_dialog', update_hints)
update_hints()
@staticmethod
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
@@ -278,18 +286,6 @@ class ProtonManager(DraggableDialog):
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.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
)
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
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
+162 -65
View File
@@ -29,6 +29,8 @@ from PySide6.QtWidgets import (
QLineEdit,
QMenu,
QMessageBox,
QSlider,
QPushButton,
QStackedWidget,
QTabWidget,
QTableWidget,
@@ -55,7 +57,6 @@ from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update
from portprotonqt.dialogs.proton_manager import show_proton_manager
from portprotonqt.dialogs.settings_mangohud import MANGOHUD_ENV_KEYS, MangoHudSettingsMixin
from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, GamescopeSettingsMixin
from portprotonqt.dialogs.settings_reshade import RESHADE_ENV_KEYS, ReShadeSettingsMixin
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
from portprotonqt.localization import _, format_setting_name_for_display
from portprotonqt.logger import get_logger
@@ -86,8 +87,6 @@ DEFAULT_WINE_SETTING_KEYS = {
'PW_PREFIX_NAME': 'PW_DEFAULT_PREFIX_NAME',
'PW_VULKAN_USE': 'PW_DEFAULT_VULKAN_USE',
}
def _normalize_prefix_directories(prefixes_dir):
if not os.path.isdir(prefixes_dir):
return
@@ -152,7 +151,6 @@ class ExeSettingsDialog(
DraggableDialog,
MangoHudSettingsMixin,
GamescopeSettingsMixin,
ReShadeSettingsMixin,
VkBasaltSettingsMixin,
):
"""Dialog for configuring executable-specific settings."""
@@ -202,6 +200,12 @@ class ExeSettingsDialog(
_normalize_prefix_directories(prefixes_dir)
self.prefix_options = get_available_prefix_options(self.portproton_path)
self.saving = False
self.settings_loading = True
self.close_after_save = False
self.save_timer = QTimer(self)
self.save_timer.setSingleShot(True)
self.save_timer.timeout.connect(self.apply_changes)
self.current_settings = {}
self.value_widgets = {}
self.original_values = {}
@@ -211,7 +215,6 @@ class ExeSettingsDialog(
self.advanced_settings_by_key = {}
self.init_mangohud_state()
self.init_gamescope_state()
self.init_reshade_state()
self.init_vkbasalt_state()
self.blocked_keys = set()
self.numa_nodes = {}
@@ -337,8 +340,6 @@ class ExeSettingsDialog(
self.mangohud_tab_layout = QVBoxLayout(self.mangohud_tab)
self.vkbasalt_tab = QWidget()
self.vkbasalt_tab_layout = QVBoxLayout(self.vkbasalt_tab)
self.reshade_tab = QWidget()
self.reshade_tab_layout = QVBoxLayout(self.reshade_tab)
self.gamescope_tab = QWidget()
self.gamescope_tab_layout = QVBoxLayout(self.gamescope_tab)
@@ -346,7 +347,6 @@ class ExeSettingsDialog(
self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
self.tab_widget.addTab(self.mangohud_tab, "MangoHud")
self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt")
self.tab_widget.addTab(self.reshade_tab, "ReShade")
if self.gamescope_available:
self.tab_widget.addTab(self.gamescope_tab, "Gamescope")
@@ -470,7 +470,6 @@ class ExeSettingsDialog(
self.setup_mangohud_tab()
self.setup_vkbasalt_tab()
self.setup_reshade_tab()
if self.gamescope_available:
self.setup_gamescope_tab()
@@ -481,7 +480,6 @@ class ExeSettingsDialog(
self.gamepad_tooltip.setStyleSheet(self.theme.TOOLTIP_STYLE)
self.gamepad_tooltip.setVisible(False)
self.gamepad_tooltip.setParent(self)
self.gamepad_tooltip.setWindowFlags(Qt.WindowType.ToolTip)
self.gamepad_tooltip.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.gamepad_tooltip.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.gamepad_tooltip_timer = QTimer(self)
@@ -489,25 +487,20 @@ class ExeSettingsDialog(
self.gamepad_tooltip_timer.timeout.connect(lambda: self.gamepad_tooltip.setVisible(False))
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.cancel_button = AutoSizeButton(_("Close"), icon=ThemeManager().get_icon("cancel", as_path=True))
self.open_ppdb_button = AutoSizeButton(_("Edit 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)
self.open_ppdb_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.clear_ppdb_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.apply_button.installEventFilter(self)
self.cancel_button.installEventFilter(self)
self.open_ppdb_button.installEventFilter(self)
self.clear_ppdb_button.installEventFilter(self)
button_layout.addWidget(self.apply_button)
button_layout.addWidget(self.cancel_button)
button_layout.addWidget(self.open_ppdb_button)
button_layout.addWidget(self.clear_ppdb_button)
self.main_layout.addLayout(button_layout)
self.apply_button.clicked.connect(self.apply_changes)
self.cancel_button.clicked.connect(self.reject)
self.open_ppdb_button.clicked.connect(self.open_ppdb_file)
self.clear_ppdb_button.clicked.connect(self.clear_ppdb_file)
@@ -515,6 +508,7 @@ class ExeSettingsDialog(
def load_current_settings(self):
"""Load available toggles and current settings."""
self.settings_loading = True
self.settings_container.setCurrentIndex(0)
self.advanced_container.setCurrentIndex(0)
@@ -566,7 +560,6 @@ class ExeSettingsDialog(
or key in MANGOHUD_ENV_KEYS
or key in GAMESCOPE_ENV_KEYS
or key in VKBASALT_ENV_KEYS
or key in RESHADE_ENV_KEYS
or key in TOGGLE_BOOL_KEYS
):
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
@@ -587,6 +580,11 @@ class ExeSettingsDialog(
"PW_USE_GALLIUM_NINE",
"PW_USE_SUPPLIED_DXVK_VKD3D",
"PW_USE_INHIBIT_SLEEP",
"PW_FIX_VIDEO_IN_GAME",
"PW_USE_D7VK",
"PW_USE_WINE_DXGI",
"PW_USE_WINED3D_VULKAN",
"PW_USE_GALLIUM_ZINK",
})
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
for key in self.toggle_settings:
@@ -601,8 +599,6 @@ class ExeSettingsDialog(
self.current_settings[key] = ''
for key in VKBASALT_ENV_KEYS:
self.current_settings[key] = ''
for key in RESHADE_ENV_KEYS:
self.current_settings[key] = ''
elif not self.user_conf:
self.current_settings.setdefault('PW_MANGOHUD', '0')
self.current_settings.setdefault('PW_VKBASALT', '0')
@@ -631,13 +627,14 @@ class ExeSettingsDialog(
self.populate_advanced()
self.populate_mangohud()
self.populate_vkbasalt()
self.populate_reshade()
if self.gamescope_available:
self.populate_gamescope()
self.populate_favorites(select_tab=True)
self.settings_container.setCurrentIndex(1)
self.advanced_container.setCurrentIndex(1)
self.settings_loading = False
self._connect_autosave()
def open_ppdb_file(self):
"""Open the PPDB file for the current executable."""
@@ -724,8 +721,8 @@ class ExeSettingsDialog(
self.user_conf_changes = [
change for change in self.user_conf_changes if change.split('=', 1)[1]
]
self.saving = True
self.close_after_user_conf_changes = False
self.apply_button.setEnabled(False)
self._apply_next_user_conf_change()
def populate_table(self):
@@ -872,13 +869,19 @@ class ExeSettingsDialog(
prefix_line_edit = combo.lineEdit()
if prefix_line_edit is not None:
prefix_line_edit.setPlaceholderText(_("Enter prefix name"))
combo.highlighted.connect(
lambda row, c=combo: self._on_combo_highlighted(row, c),
combo.view().setMouseTracking(True)
combo.view().installEventFilter(self)
combo.view().selectionModel().currentChanged.connect(
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
)
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
combo.highlighted.connect(
lambda row, c=combo: self._on_combo_highlighted(row, c),
combo.view().setMouseTracking(True)
combo.view().installEventFilter(self)
combo.view().selectionModel().currentChanged.connect(
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
)
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
current_raw = current.get(setting['key'], '') if self.user_conf else (
current.get(setting['key'], setting['default'])
@@ -900,7 +903,9 @@ class ExeSettingsDialog(
current_val = reverse_map[current_raw]
current_val_text = current_val if isinstance(current_val, str) else ''
if current_val_text and current_val_text not in setting['options']:
if setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE') and current_val_text == _('Download Wine/Proton...'):
current_val_text = setting['options'][0]
if current_val_text and combo.findText(current_val_text) < 0:
combo.addItem(current_val_text)
combo.setCurrentText(current_val_text)
@@ -1213,8 +1218,8 @@ class ExeSettingsDialog(
Qt.WidgetAttribute.WA_TranslucentBackground
)
combo.setEditable(source_widget.isEditable())
for index in range(source_widget.count()):
combo.addItem(source_widget.itemText(index), source_widget.itemData(index))
combo.setInsertPolicy(source_widget.insertPolicy())
combo.setModel(source_widget.model())
combo.setCurrentText(source_widget.currentText())
combo.setEnabled(source_widget.isEnabled())
settings_combo_style = getattr(self.theme, "SETTINGS_TABLE_COMBOBOX_STYLE", "")
@@ -1224,9 +1229,14 @@ class ExeSettingsDialog(
+ self.theme.SCROLL_STYLE
)
combo.currentTextChanged.connect(source_widget.setCurrentText)
combo.highlighted.connect(
lambda row_idx, c=combo: self._on_combo_highlighted(row_idx, c),
source_widget.currentTextChanged.connect(combo.setCurrentText)
combo.activated.connect(source_widget.activated.emit)
combo.view().setMouseTracking(True)
combo.view().installEventFilter(self)
combo.view().selectionModel().currentChanged.connect(
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
)
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
self.favorites_table.setCellWidget(row, 1, combo)
def _add_favorite_line_edit(self, row: int, source_widget: QLineEdit) -> None:
@@ -1262,9 +1272,7 @@ class ExeSettingsDialog(
def _on_combo_highlighted(self, row: int, combo: QComboBox) -> None:
view = combo.view()
view.installEventFilter(self)
if not self.isVisible() or not combo.isVisible() or not view.isVisible():
self.show_gamepad_tooltip(show=False)
return
index = view.model().index(row, 0)
if not index.isValid():
@@ -1280,25 +1288,31 @@ class ExeSettingsDialog(
if text_width <= item_rect.intersected(view.viewport().rect()).width():
self.show_gamepad_tooltip(show=False)
return
text_rect = fm.boundingRect(0, 0, 480, 1000, Qt.TextFlag.TextWordWrap, text)
w = min(500, text_rect.width() + 25)
h = min(300, text_rect.height() + 25)
self.gamepad_tooltip.setText(text)
self.gamepad_tooltip.setWordWrap(False)
w = min(500, self.gamepad_tooltip.sizeHint().width())
self.gamepad_tooltip.setWordWrap(True)
h = min(300, self.gamepad_tooltip.heightForWidth(w))
item_center_y = view.viewport().mapToGlobal(item_rect.center()).y()
combo_right_x = combo.mapToGlobal(combo.rect().topRight()).x()
pos = QPoint(combo_right_x + 4, item_center_y - h // 2)
screen = QGuiApplication.screenAt(pos) or QGuiApplication.primaryScreen()
screen = combo.screen()
if screen:
ar = screen.availableGeometry()
ar = screen.availableGeometry().intersected(self.rect().translated(self.mapToGlobal(QPoint())))
w, h = min(w, ar.width()), min(h, ar.height())
if pos.x() + w > ar.right():
pos.setX(ar.right() - w)
pos.setX(combo.mapToGlobal(combo.rect().topLeft()).x() - w)
if pos.x() < ar.left():
pos.setX(ar.left())
if pos.y() + h > ar.bottom():
pos.setY(ar.bottom() - h)
if pos.y() < ar.top():
pos.setY(ar.top())
self.gamepad_tooltip.setText(text)
self.gamepad_tooltip.hide()
self.gamepad_tooltip.setFixedSize(w, h)
self.gamepad_tooltip.move(pos)
self.gamepad_tooltip.move(self.mapFromGlobal(pos))
self.gamepad_tooltip.setVisible(True)
self.gamepad_tooltip.raise_()
self.gamepad_tooltip_timer.start(max(2500, min(12000, 1500 + len(text) * 30)))
def _open_wine_manager_from_combo(self, index: int, combo: QComboBox, key: str) -> None:
@@ -1330,6 +1344,7 @@ class ExeSettingsDialog(
) if setting['key'] == 'PW_WINE_USE')
if self.user_conf:
options = [_('Default')] + options
combo.blockSignals(True)
combo.clear()
combo.addItems(options)
if current_text and current_text not in options:
@@ -1337,6 +1352,7 @@ class ExeSettingsDialog(
combo.insertSeparator(combo.count())
combo.addItem(_('Download Wine/Proton...'), 'download_wine')
combo.setCurrentText(current_text)
combo.blockSignals(False)
def _select_checkbox_row(self, widget: QCheckBox) -> bool:
for (row, column), checkbox in self.value_widgets.items():
@@ -1404,11 +1420,61 @@ class ExeSettingsDialog(
self._filter_mangohud_settings(search_text)
self._filter_vkbasalt_settings(search_text)
self._filter_reshade_settings(search_text)
self._filter_gamescope_settings(search_text)
def _connect_autosave(self) -> None:
for widget in self.findChildren(QWidget):
if widget.property("ppqt_autosave") or widget is self.search_edit:
continue
if isinstance(widget, QLineEdit):
if isinstance(widget.parent(), QComboBox):
continue
widget.textChanged.connect(self._schedule_save)
elif isinstance(widget, QComboBox):
widget.currentTextChanged.connect(self._schedule_save)
elif isinstance(widget, QCheckBox):
widget.toggled.connect(self._schedule_save)
elif isinstance(widget, QSlider):
widget.valueChanged.connect(self._schedule_save)
elif isinstance(widget, QPushButton):
widget.clicked.connect(self._schedule_save)
else:
continue
widget.setProperty("ppqt_autosave", True)
def _schedule_save(self) -> None:
if not self.settings_loading:
self.save_timer.start(0)
def _on_save_error(self, error: QProcess.ProcessError) -> None:
if error != QProcess.ProcessError.FailedToStart:
return
self.saving = False
self.close_after_save = False
self.clear_ppdb_button.setEnabled(True)
logger.error("Failed to start settings save process: %s", cast(QProcess, self.sender()).errorString())
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
def _finish_save(self) -> None:
for key, value in self.saved_values.items():
if self.current_settings.get(key) == self.saved_current_settings.get(key):
self.current_settings[key] = value
self.original_values.update(self.saved_values)
self.original_display_values.update(self.saved_display_values)
self.mangohud_original_values.update(self.saved_values)
self.vkbasalt_original_values.update(self.saved_values)
self.gamescope_original_values.update(self.saved_values)
self.clear_ppdb_button.setEnabled(True)
self.saving = False
self.apply_changes()
if self.close_after_save and not self.saving:
self.close()
def apply_changes(self):
"""Apply changes by collecting diffs from both main and advanced tabs."""
if self.settings_loading or self.saving:
return
self.save_timer.stop()
changes = []
mangohud_enabled = False
@@ -1440,6 +1506,8 @@ class ExeSettingsDialog(
for key, widget in self.advanced_widgets.items():
orig_val = self.original_display_values.get(key, '')
if isinstance(widget, QComboBox):
if widget.currentData() == 'download_wine':
continue
new_val = widget.currentText()
if self.user_conf and new_val == _('Default'):
new_val = ''
@@ -1479,7 +1547,6 @@ class ExeSettingsDialog(
if self.gamescope_available:
gamescope_changes = self._collect_gamescope_changes()
vkbasalt_changes = self._collect_vkbasalt_changes()
reshade_changes = self._collect_reshade_changes()
if self.user_conf:
if self.current_settings.get('PW_MANGOHUD') != '1':
mangohud_changes = [
@@ -1496,14 +1563,7 @@ class ExeSettingsDialog(
change for change in gamescope_changes
if change.startswith('PW_GAMESCOPE=')
]
if self.current_settings.get('PW_USE_RESHADE') != '1':
reshade_changes = [
change for change in reshade_changes
if change.startswith('PW_USE_RESHADE=')
]
specialized_changes = (
mangohud_changes + vkbasalt_changes + reshade_changes + gamescope_changes
)
specialized_changes = mangohud_changes + vkbasalt_changes + gamescope_changes
if self.user_conf:
defaults = ('', '0', '0.00', 'Home')
specialized_changes = [
@@ -1532,26 +1592,42 @@ class ExeSettingsDialog(
if not changes:
return
self.saving = True
self.clear_ppdb_button.setEnabled(False)
self.saved_current_settings = self.current_settings.copy()
self.saved_values = dict(change.split('=', 1) for change in changes)
self.saved_display_values = {
key: widget.currentText() if isinstance(widget, QComboBox) else widget.text().strip()
for key, widget in self.advanced_widgets.items()
if isinstance(widget, (QComboBox, QLineEdit))
}
for key in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
if key in self.saved_display_values:
self.saved_display_values[key] = re.sub(
r"[ \t]", "_", self.saved_display_values[key].strip()
).upper()
if 'PW_RUN_AFTER_EXE' in self.saved_values:
self.saved_display_values['PW_RUN_AFTER_EXE'] = self.saved_values['PW_RUN_AFTER_EXE']
if self.user_conf:
self.user_conf_changes = changes
self.close_after_user_conf_changes = True
self.apply_button.setEnabled(False)
self._apply_next_user_conf_change()
return
process = QProcess(self)
self.save_process = QProcess(self)
process = self.save_process
process.finished.connect(self.on_edit_db_finished)
process.errorOccurred.connect(self._on_save_error)
process_args = ["cli", "--edit-db", self.exe_path] + changes
args = self._get_process_args(process_args)
process.start(args[0], args[1:])
self.apply_button.setEnabled(False)
def _apply_next_user_conf_change(self) -> None:
if not self.user_conf_changes:
self.apply_button.setEnabled(True)
if self.close_after_user_conf_changes:
self.close()
self._finish_save()
else:
self.saving = False
self.load_current_settings()
return
@@ -1562,33 +1638,45 @@ class ExeSettingsDialog(
command.append(value)
self.user_conf_process = QProcess(self)
self.user_conf_process.finished.connect(self._on_user_conf_change_finished)
self.user_conf_process.errorOccurred.connect(self._on_save_error)
args = self._get_process_args(command)
self.user_conf_process.start(args[0], args[1:])
def _on_user_conf_change_finished(self, exit_code, exit_status) -> None:
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
self.saving = False
self.close_after_save = False
self.clear_ppdb_button.setEnabled(True)
error_output = bytes(self.user_conf_process.readAllStandardError().data()).decode(
'utf-8', 'ignore'
)
self.apply_button.setEnabled(True)
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
logger.error("Failed to update user.conf: %s", error_output)
return
self.user_conf_process.deleteLater()
self._apply_next_user_conf_change()
def on_edit_db_finished(self, exit_code, exit_status):
"""Handle --edit-db output."""
process = cast(QProcess, self.sender())
self.apply_button.setEnabled(True)
process.deleteLater()
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
self.saving = False
self.close_after_save = False
self.clear_ppdb_button.setEnabled(True)
error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
logger.error(f"Failed to apply changes: {error_output}")
else:
self.load_current_settings()
self.close()
self._finish_save()
def closeEvent(self, event):
if hasattr(self, 'save_timer'):
self.apply_changes()
if self.saving:
self.close_after_save = True
event.ignore()
return
app = QApplication.instance()
if isinstance(app, QApplication):
app.removeEventFilter(self)
@@ -1668,7 +1756,8 @@ class ExeSettingsDialog(
palette.setBrush(QPalette.ColorRole.Text, item.foreground())
label.setAutoFillBackground(selected)
label.setPalette(palette)
label.setForegroundRole(QPalette.ColorRole.Text)
if label.foregroundRole() != QPalette.ColorRole.Text:
label.setForegroundRole(QPalette.ColorRole.Text)
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
name, separator, description = text.partition("\n")
formatted_text = escape(name)
@@ -1685,7 +1774,11 @@ class ExeSettingsDialog(
if label.text() != formatted_text:
label.setText(formatted_text)
width = max(1, table.columnWidth(0))
table.setRowHeight(row, label.heightForWidth(width) + table.fontMetrics().height())
height_key = [width, formatted_text, label.font().toString()]
if label.property("ppqt_height_key") != height_key:
height = label.heightForWidth(width) + table.fontMetrics().height()
label.setProperty("ppqt_height_key", height_key)
table.setRowHeight(row, height)
for row in range(table.rowCount()):
widget = table.cellWidget(row, 1)
if widget is None:
@@ -1699,13 +1792,11 @@ class ExeSettingsDialog(
)
return False
if isinstance(obj, QWidget) and self._handle_reshade_key_button_event(obj, event):
return True
if isinstance(obj, QWidget) and self._handle_vkbasalt_key_button_event(obj, event):
self._schedule_save()
return True
action_buttons = (
getattr(self, "apply_button", None),
getattr(self, "cancel_button", None),
getattr(self, "open_ppdb_button", None),
getattr(self, "clear_ppdb_button", None),
@@ -1783,8 +1874,9 @@ class ExeSettingsDialog(
y = available_rect.top()
self.gamepad_tooltip.setFixedSize(required_width, required_height)
self.gamepad_tooltip.move(x, y)
self.gamepad_tooltip.move(self.mapFromGlobal(QPoint(x, y)))
self.gamepad_tooltip.setVisible(True)
self.gamepad_tooltip.raise_()
self.gamepad_tooltip_timer.start(tooltip_timeout_ms)
if anchor_global_pos is not None:
@@ -1814,6 +1906,11 @@ class ExeSettingsDialog(
super().hideEvent(event)
def reject(self):
if hasattr(self, 'save_timer'):
self.apply_changes()
if self.saving:
self.close_after_save = True
return
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
self.gamepad_tooltip.setVisible(False)
-446
View File
@@ -1,446 +0,0 @@
"""ReShade settings support for executable settings dialog."""
import os
from typing import Any, cast
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QKeyEvent, QKeySequence
from PySide6.QtWidgets import (
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
from portprotonqt.localization import _
from portprotonqt.theme_manager import ThemeManager
RESHADE_ENV_KEYS = [
'PW_RESHADE_EXE',
'PW_RESHADE_API',
'PW_RESHADE_FONT',
'PW_RESHADE_SCREENSHOT_PATH',
'PW_RESHADE_SCREENSHOT_KEY',
'PW_RESHADE_OVERLAY_KEY',
]
RESHADE_API_OPTIONS = {
"Auto": "auto",
"DXGI": "dxgi.dll",
"DirectX 8/9": "d3d9.dll",
"OpenGL": "opengl32.dll",
"Vulkan": "vulkan",
}
RESHADE_SPECIAL_KEYS = {
Qt.Key.Key_Cancel: 3,
Qt.Key.Key_Backspace: 8,
Qt.Key.Key_Tab: 9,
Qt.Key.Key_Clear: 12,
Qt.Key.Key_Return: 13,
Qt.Key.Key_Enter: 13,
Qt.Key.Key_Pause: 19,
Qt.Key.Key_CapsLock: 20,
Qt.Key.Key_Escape: 27,
Qt.Key.Key_Space: 32,
Qt.Key.Key_PageUp: 33,
Qt.Key.Key_PageDown: 34,
Qt.Key.Key_End: 35,
Qt.Key.Key_Home: 36,
Qt.Key.Key_Left: 37,
Qt.Key.Key_Up: 38,
Qt.Key.Key_Right: 39,
Qt.Key.Key_Down: 40,
Qt.Key.Key_Print: 44,
Qt.Key.Key_Insert: 45,
Qt.Key.Key_Delete: 46,
Qt.Key.Key_Help: 47,
Qt.Key.Key_Meta: 91,
Qt.Key.Key_Menu: 93,
Qt.Key.Key_Sleep: 95,
Qt.Key.Key_NumLock: 144,
Qt.Key.Key_ScrollLock: 145,
Qt.Key.Key_Semicolon: 186,
Qt.Key.Key_Equal: 187,
Qt.Key.Key_Comma: 188,
Qt.Key.Key_Minus: 189,
Qt.Key.Key_Period: 190,
Qt.Key.Key_Slash: 191,
Qt.Key.Key_QuoteLeft: 192,
Qt.Key.Key_BracketLeft: 219,
Qt.Key.Key_Backslash: 220,
Qt.Key.Key_BracketRight: 221,
Qt.Key.Key_Apostrophe: 222,
Qt.Key.Key_Back: 166,
Qt.Key.Key_Forward: 167,
Qt.Key.Key_Refresh: 168,
Qt.Key.Key_Stop: 169,
Qt.Key.Key_Search: 170,
Qt.Key.Key_Favorites: 171,
Qt.Key.Key_HomePage: 172,
Qt.Key.Key_VolumeMute: 173,
Qt.Key.Key_VolumeDown: 174,
Qt.Key.Key_VolumeUp: 175,
Qt.Key.Key_MediaNext: 176,
Qt.Key.Key_MediaPrevious: 177,
Qt.Key.Key_MediaStop: 178,
Qt.Key.Key_MediaPlay: 179,
Qt.Key.Key_LaunchMail: 180,
Qt.Key.Key_LaunchMedia: 181,
Qt.Key.Key_Launch0: 182,
Qt.Key.Key_Launch1: 183,
}
RESHADE_KEYPAD_KEYS = {
Qt.Key.Key_Asterisk: 106,
Qt.Key.Key_Plus: 107,
Qt.Key.Key_Comma: 108,
Qt.Key.Key_Minus: 109,
Qt.Key.Key_Period: 110,
Qt.Key.Key_Slash: 111,
}
RESHADE_FIRST_FUNCTION_KEY = 112
RESHADE_LAST_FUNCTION_KEY = 135
RESHADE_FIRST_NUMPAD_KEY = 96
class ReShadeSettingsMixin:
"""Mixin with ReShade settings UI and serialization logic."""
theme: Any
exe_path: str | None
current_settings: dict[str, str]
original_values: dict[str, str]
reshade_tab: QWidget
reshade_tab_layout: QVBoxLayout
def init_reshade_state(self) -> None:
self.reshade_original_values = {}
self.reshade_key_target = None
def setup_reshade_tab(self) -> None:
"""Create ReShade tab widgets."""
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
scroll.setStyleSheet(self.theme.SCROLL_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
container = QWidget()
layout = QVBoxLayout(container)
layout.setSpacing(self.theme.exeSettingsGroupBoxBlockSpacing)
actions = QGroupBox(_("Actions"))
actions.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
actions_layout = QGridLayout(actions)
self.reshade_enable_button = self._create_reshade_button("")
self.reshade_enable_button.clicked.connect(self.toggle_reshade_enable)
actions_layout.addWidget(self.reshade_enable_button, 0, 0)
layout.addWidget(actions)
self._add_reshade_settings_group(layout)
layout.addStretch()
scroll.setWidget(container)
self.reshade_tab_layout.addWidget(scroll)
def _add_reshade_settings_group(self, parent_layout: QVBoxLayout) -> None:
self.reshade_settings_group = QGroupBox(_("Settings"))
self.reshade_settings_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
settings_layout = QGridLayout(self.reshade_settings_group)
self.reshade_exe_edit = self._add_reshade_file_row(
settings_layout, 0, "EXE", ".exe"
)
self.reshade_api_combo = CustomComboBox(theme=self.theme)
self.reshade_api_combo.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
)
self.reshade_api_combo.view().window().setAttribute(
Qt.WidgetAttribute.WA_TranslucentBackground
)
self.reshade_api_combo.setStyleSheet(
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
)
self.reshade_api_combo.addItems(list(RESHADE_API_OPTIONS))
settings_layout.addWidget(QLabel("API"), 1, 0)
settings_layout.addWidget(self.reshade_api_combo, 1, 1)
self.reshade_font_combo = CustomComboBox(theme=self.theme)
self.reshade_font_combo.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
)
self.reshade_font_combo.view().window().setAttribute(
Qt.WidgetAttribute.WA_TranslucentBackground
)
self.reshade_font_combo.setStyleSheet(
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
)
self.reshade_font_combo.addItem(_("Default"), "")
settings_layout.addWidget(QLabel(_("Font")), 2, 0)
settings_layout.addWidget(self.reshade_font_combo, 2, 1)
self.reshade_screenshot_edit = self._add_reshade_directory_row(
settings_layout, 3, _("Screenshots")
)
self.reshade_screenshot_key_button = self._create_reshade_key_button(
"screenshot"
)
settings_layout.addWidget(QLabel(_("Screenshot key")), 4, 0)
settings_layout.addWidget(self.reshade_screenshot_key_button, 4, 1)
self.reshade_overlay_key_button = self._create_reshade_key_button("overlay")
settings_layout.addWidget(QLabel(_("Overlay key")), 5, 0)
settings_layout.addWidget(self.reshade_overlay_key_button, 5, 1)
parent_layout.addWidget(self.reshade_settings_group)
self._load_reshade_fonts()
def _create_reshade_button(self, label: str) -> QPushButton:
button = QPushButton(label)
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
return button
def _add_reshade_file_row(
self, layout: QGridLayout, row: int, label: str, file_filter: str
) -> QLineEdit:
line_edit = QLineEdit()
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
button = AutoSizeButton(
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
)
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.clicked.connect(
lambda _checked=False: self._select_reshade_file(line_edit, file_filter)
)
field_layout = QHBoxLayout()
field_layout.addWidget(line_edit)
field_layout.addWidget(button)
layout.addWidget(QLabel(label), row, 0)
layout.addLayout(field_layout, row, 1)
return line_edit
def _select_reshade_file(self, line_edit: QLineEdit, file_filter: str) -> None:
from portprotonqt.dialogs.file_explorer import FileExplorer
current_path = line_edit.text().strip()
initial_path = os.path.dirname(current_path) if os.path.isfile(current_path) else None
explorer = FileExplorer(
self, theme=self.theme, file_filter=file_filter, initial_path=initial_path
)
explorer.file_signal.file_selected.connect(line_edit.setText)
explorer.exec()
def _add_reshade_directory_row(
self, layout: QGridLayout, row: int, label: str
) -> QLineEdit:
line_edit = QLineEdit()
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
button = AutoSizeButton(
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
)
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.clicked.connect(
lambda _checked=False: self._select_reshade_directory(line_edit)
)
field_layout = QHBoxLayout()
field_layout.addWidget(line_edit)
field_layout.addWidget(button)
layout.addWidget(QLabel(label), row, 0)
layout.addLayout(field_layout, row, 1)
return line_edit
def _select_reshade_directory(self, line_edit: QLineEdit) -> None:
from portprotonqt.dialogs.file_explorer import FileExplorer
initial_path = line_edit.text().strip() or os.path.dirname(self.exe_path or "")
explorer = FileExplorer(
self, theme=self.theme, initial_path=initial_path, directory_only=True
)
explorer.file_signal.file_selected.connect(line_edit.setText)
explorer.exec()
def _create_reshade_key_button(self, target: str) -> QPushButton:
button = self._create_reshade_button("")
button.clicked.connect(lambda _checked=False: self._capture_reshade_key(target))
button.installEventFilter(cast(QWidget, self))
return button
def _capture_reshade_key(self, target: str) -> None:
self.reshade_key_target = target
button = self._get_reshade_key_button(target)
button.setText(_("Press a button to choose"))
button.grabKeyboard()
def _get_reshade_key_button(self, target: str) -> QPushButton:
if target == "screenshot":
return self.reshade_screenshot_key_button
return self.reshade_overlay_key_button
def _handle_reshade_key_button_event(self, obj: QWidget, event: QEvent) -> bool:
target = self.reshade_key_target
if target is None or obj is not self._get_reshade_key_button(target):
return False
if event.type() != QEvent.Type.KeyPress:
return False
key_event = cast(QKeyEvent, event)
key_value = self._reshade_virtual_key(
key_event.key(), key_event.modifiers()
)
if key_value is None:
return True
modifiers = key_event.modifiers()
value = ",".join(str(item) for item in (
key_value,
int(bool(modifiers & Qt.KeyboardModifier.ControlModifier)),
int(bool(modifiers & Qt.KeyboardModifier.ShiftModifier)),
int(bool(modifiers & Qt.KeyboardModifier.AltModifier)),
))
self.current_settings[f'PW_RESHADE_{target.upper()}_KEY'] = value
button = self._get_reshade_key_button(target)
button.setText(QKeySequence(key_event.keyCombination()).toString())
button.releaseKeyboard()
self.reshade_key_target = None
return True
def _reshade_virtual_key(
self, key: int, modifiers: Qt.KeyboardModifier
) -> int | None:
is_keypad = bool(modifiers & Qt.KeyboardModifier.KeypadModifier)
if is_keypad and Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
return RESHADE_FIRST_NUMPAD_KEY + key - Qt.Key.Key_0.value
if is_keypad:
try:
keypad_key = RESHADE_KEYPAD_KEYS.get(Qt.Key(key))
except ValueError:
keypad_key = None
if keypad_key is not None:
return keypad_key
if Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
return key
if Qt.Key.Key_A.value <= key <= Qt.Key.Key_Z.value:
return key
if Qt.Key.Key_F1.value <= key <= Qt.Key.Key_F24.value:
return RESHADE_FIRST_FUNCTION_KEY + key - Qt.Key.Key_F1.value
try:
return RESHADE_SPECIAL_KEYS.get(Qt.Key(key))
except ValueError:
return None
def _format_reshade_key(self, value: str) -> str:
try:
key, control, shift, alt = (int(item) for item in value.split(","))
except ValueError:
return value
qt_key = next(
(item for item, code in RESHADE_SPECIAL_KEYS.items() if code == key), None
)
if qt_key is not None:
name = QKeySequence(qt_key).toString()
elif RESHADE_FIRST_FUNCTION_KEY <= key <= RESHADE_LAST_FUNCTION_KEY:
name = f"F{key - RESHADE_FIRST_FUNCTION_KEY + 1}"
else:
name = chr(key)
modifiers = [
label for active, label in (
(control, "Ctrl"), (shift, "Shift"), (alt, "Alt")
) if active
]
return "+".join([*modifiers, name])
def _load_reshade_fonts(self) -> None:
selected_family = self.current_settings.get('PW_RESHADE_FONT', '')
font_options = cast(Any, self)._get_mangohud_font_options()
families = {name for name, _path in font_options}
self.reshade_font_combo.clear()
self.reshade_font_combo.addItem(_("Default"), "")
for family in sorted(families, key=str.casefold):
self.reshade_font_combo.addItem(family, family)
self._select_reshade_font(selected_family)
def _select_reshade_font(self, family: str) -> None:
for index in range(self.reshade_font_combo.count()):
if self.reshade_font_combo.itemData(index) == family:
self.reshade_font_combo.setCurrentIndex(index)
return
if family:
self.reshade_font_combo.addItem(family, family)
self.reshade_font_combo.setCurrentIndex(self.reshade_font_combo.count() - 1)
def populate_reshade(self) -> None:
"""Populate ReShade tab from current settings."""
reshade_exe = self.current_settings.get('PW_RESHADE_EXE', '') or self.exe_path or ''
self.reshade_exe_edit.setText(reshade_exe)
api_value = self.current_settings.get('PW_RESHADE_API', 'auto')
api_label = next(
(label for label, value in RESHADE_API_OPTIONS.items() if value == api_value),
"Auto",
)
self.reshade_api_combo.setCurrentText(api_label)
font_family = self.current_settings.get('PW_RESHADE_FONT', '')
self._select_reshade_font(font_family)
screenshot_path = self.current_settings.get('PW_RESHADE_SCREENSHOT_PATH', '')
screenshot_key = self.current_settings.get(
'PW_RESHADE_SCREENSHOT_KEY', '44,0,0,0'
)
overlay_key = self.current_settings.get(
'PW_RESHADE_OVERLAY_KEY', '36,0,0,0'
)
self.reshade_screenshot_edit.setText(screenshot_path)
self.reshade_screenshot_key_button.setText(
self._format_reshade_key(screenshot_key)
)
self.reshade_overlay_key_button.setText(self._format_reshade_key(overlay_key))
self.current_settings['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
self.current_settings['PW_RESHADE_OVERLAY_KEY'] = overlay_key
self.reshade_original_values = {
key: self.current_settings.get(key, '') for key in RESHADE_ENV_KEYS
}
self.reshade_original_values['PW_RESHADE_EXE'] = reshade_exe
self.reshade_original_values['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
self.reshade_original_values['PW_RESHADE_OVERLAY_KEY'] = overlay_key
self._update_reshade_widgets()
def toggle_reshade_enable(self) -> None:
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
self.current_settings['PW_USE_RESHADE'] = '0' if enabled else '1'
self._update_reshade_widgets()
def _update_reshade_widgets(self) -> None:
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
self.reshade_enable_button.setText(
_("Disable {0}").format("ReShade")
if enabled else _("Enable {0}").format("ReShade")
)
self.reshade_enable_button.setStyleSheet(
self.theme.ACTION_BUTTON_ACTIVE_STYLE if enabled else self.theme.ACTION_BUTTON_STYLE
)
self.reshade_settings_group.setVisible(enabled)
def _collect_reshade_changes(self) -> list[str]:
changes = []
enabled = '1' if self.current_settings.get('PW_USE_RESHADE') == '1' else '0'
original_enabled = '1' if self.original_values.get('PW_USE_RESHADE') == '1' else '0'
if enabled != original_enabled:
changes.append(f"PW_USE_RESHADE={enabled}")
values = {
'PW_RESHADE_EXE': self.reshade_exe_edit.text().strip(),
'PW_RESHADE_API': RESHADE_API_OPTIONS[self.reshade_api_combo.currentText()],
'PW_RESHADE_FONT': str(self.reshade_font_combo.currentData() or ''),
'PW_RESHADE_SCREENSHOT_PATH': self.reshade_screenshot_edit.text().strip(),
'PW_RESHADE_SCREENSHOT_KEY': self.current_settings['PW_RESHADE_SCREENSHOT_KEY'],
'PW_RESHADE_OVERLAY_KEY': self.current_settings['PW_RESHADE_OVERLAY_KEY'],
}
for key, value in values.items():
if value != self.reshade_original_values.get(key, ''):
changes.append(f"{key}={value}")
return changes
def _filter_reshade_settings(self, search_text: str) -> None:
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
self.reshade_settings_group.setVisible(
enabled and (
not search_text
or search_text in "exe api font screenshot overlay"
or search_text in _("Settings").lower()
)
)
+16
View File
@@ -286,6 +286,10 @@ class AnimatedCard(QFrame):
colors = (self.theme.color_bg, self.theme.color_surface_elevated)
painter.fillRect(self.rect(), QColor(colors[row % len(colors)]))
self.animations.paint_border(painter)
if getattr(self, "batch_selected", False):
painter.setPen(QPen(QColor(self.theme.color_accent),
self.theme.GAME_CARD_ANIMATION["focus_border_width"]))
painter.drawRect(self.rect())
def enterEvent(self, event: QEnterEvent) -> None:
self.raise_()
@@ -375,6 +379,7 @@ class GameCard(AnimatedCard):
self.select_callback = select_callback
self.context_menu_manager = context_menu_manager
self.batch_selected = False
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu)
self.theme_manager = ThemeManager()
@@ -1256,6 +1261,12 @@ class GameCard(AnimatedCard):
super().showEvent(event)
def click(self) -> None:
if self.context_menu_manager:
if self.context_menu_manager.card_selection_mode is True:
self.batch_selected = not self.batch_selected
self.update()
return
self.context_menu_manager.clear_card_selection()
if self._get_missing_executable_path() and self.context_menu_manager:
self.context_menu_manager.handle_missing_executable(self)
return
@@ -1282,6 +1293,11 @@ class GameCard(AnimatedCard):
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
self.batch_selected = not self.batch_selected
self.update()
event.accept()
return
self.click()
super().mousePressEvent(event)
+7 -5
View File
@@ -8,6 +8,7 @@ from PySide6.QtWidgets import (
QLineEdit,
QListView,
QMenu,
QPushButton,
QSlider,
QStackedWidget,
QTableWidget,
@@ -160,11 +161,7 @@ class ButtonInputMixin(InputMixin):
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)
)
keyboard = getattr(active, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
if keyboard:
keyboard.show_for_widget(focused)
@@ -246,6 +243,11 @@ class ButtonInputMixin(InputMixin):
return False
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
if button_code in BUTTONS['add_game'] and isinstance(active, QDialog):
action = active.findChild(QPushButton, "gamepadAddButton")
if action is not None and action.isVisible() and action.isEnabled():
action.click()
return True
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
active.reject()
return True
+1 -4
View File
@@ -317,10 +317,7 @@ class DpadInputMixin(InputMixin):
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)
keyboard = getattr(active_window, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
if value == 0:
self._reset_dpad_repeat()
@@ -141,6 +141,10 @@ class FileExplorerInputMixin(InputMixin):
self.file_explorer.show_folder_context_menu(item_rect.center())
elif button_code in BUTTONS['add_game']: # X button
if self._handle_active_window_button(self.file_explorer, button_code):
return
if not self.file_explorer.directory_only:
return
if self.file_explorer.file_list.count() == 0:
return
+1 -5
View File
@@ -93,10 +93,6 @@ class KeyboardInputMixin(InputMixin):
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")
@@ -162,7 +158,7 @@ class KeyboardInputMixin(InputMixin):
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
return False
return QObject.eventFilter(cast(QObject, self), obj, event)
return False
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
native_key = self._native_scan_to_input_key(event)
+6 -6
View File
@@ -184,10 +184,7 @@ class SettingsInputMixin(InputMixin):
return
# 4. Global Shortcuts
if button_code in BUTTONS['add_game']: # X: Apply
self.settings_dialog.apply_changes()
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
if button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
focused = QApplication.focusWidget()
focused_combo = None
if isinstance(focused, QTableWidget) and self.settings_dialog:
@@ -342,12 +339,14 @@ class SettingsInputMixin(InputMixin):
combo_tables = (
self.settings_dialog.advanced_table,
self.settings_dialog.settings_table,
getattr(self.settings_dialog, "favorites_table", None),
)
if self.settings_dialog and table in combo_tables and table.currentRow() >= 0:
cell_widget = table.cellWidget(table.currentRow(), 1)
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
if code == PAD_DPAD_Y and value != 0:
idx = cell_widget.currentIndex()
view = cell_widget.view()
idx = view.currentIndex().row()
step = -1 if value < 0 else 1
model = cell_widget.model()
end = -1 if step < 0 else cell_widget.count()
@@ -355,7 +354,8 @@ class SettingsInputMixin(InputMixin):
row for row in range(idx + step, end, step)
if model.flags(model.index(row, 0)) & Qt.ItemFlag.ItemIsEnabled
), idx)
cell_widget.setCurrentIndex(new_idx)
view.setCurrentIndex(model.index(new_idx, 0))
view.scrollTo(model.index(new_idx, 0), QAbstractItemView.ScrollHint.EnsureVisible)
return # Consume event
# 3. Standard Table Navigation
@@ -22,6 +22,12 @@ msgstr ""
"X-Generator: Weblate 2026.5\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Stop Game"
msgstr ""
@@ -142,6 +148,9 @@ msgstr ""
msgid "Failed to rename file: {error}"
msgstr ""
msgid "Select multiple"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
@@ -294,10 +303,6 @@ msgid ""
"take effect."
msgstr ""
#, python-brace-format
msgid "'{game_name}' was added to Steam."
msgstr ""
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr ""
@@ -471,6 +476,16 @@ msgstr ""
msgid "Wine prefix not found"
msgstr ""
msgid ""
"Cyrillic characters were found in the following paths. Wine may work "
"unreliably. Move the game or PortProton to a path without Cyrillic "
"characters:\n"
"\n"
msgstr ""
msgid "Don't remind again"
msgstr ""
msgid "Game not found."
msgstr ""
@@ -766,9 +781,6 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Exit"
msgstr ""
@@ -781,9 +793,6 @@ msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Resume Game"
msgstr ""
msgid "Failed to pause game"
msgstr ""
@@ -886,6 +895,22 @@ msgstr ""
msgid "Launching {0}"
msgstr ""
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr ""
msgid "Name:"
msgstr ""
@@ -910,6 +935,30 @@ msgstr ""
msgid "Apply"
msgstr ""
msgid "Select folder"
msgstr ""
msgid "Processing games…"
msgstr ""
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr ""
msgid "No games found in the selected folder."
msgstr ""
msgid "Select games"
msgstr ""
msgid "Close"
msgstr ""
msgid "Invalid image"
msgstr ""
@@ -928,7 +977,13 @@ msgstr ""
msgid "The program stopped. Compatibility details were collected automatically."
msgstr ""
msgid "Close"
msgid "Downloading…"
msgstr ""
msgid "Download process failed"
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "Open"
@@ -952,9 +1007,6 @@ msgstr ""
msgid "Next Tab"
msgstr ""
msgid "Save"
msgstr ""
msgid "Search"
msgstr ""
@@ -1096,9 +1148,6 @@ msgstr ""
msgid "Successfully removed {} WINE/Proton."
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "All selected WINE/Proton have been downloaded!"
msgstr ""
@@ -1186,9 +1235,6 @@ msgstr ""
msgid "Failed to open file:\n"
msgstr ""
msgid "Information"
msgstr ""
msgid "Confirm PPDB Clear"
msgstr ""
@@ -2117,9 +2163,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
msgid "Download process failed"
msgstr ""
msgid "Quick Launch"
msgstr ""
@@ -2192,6 +2235,9 @@ msgid ""
"will remove the .desktop files and custom data."
msgstr ""
msgid "Failed to install theme"
msgstr ""
msgid "PortProton Settings"
msgstr ""
@@ -2255,6 +2301,9 @@ msgstr ""
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
@@ -2475,16 +2524,10 @@ msgstr ""
msgid "Failed to download theme"
msgstr ""
msgid "Downloading…"
msgstr ""
#, python-brace-format
msgid "Downloading… {0}%"
msgstr ""
msgid "Failed to install theme"
msgstr ""
msgid "Select Theme:"
msgstr ""
@@ -21,6 +21,12 @@ msgstr ""
"X-Generator: Weblate 2026.10.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr "Spiel fortsetzen"
msgid "Pause Game"
msgstr "Spiel anhalten"
msgid "Stop Game"
msgstr "Spiel beenden"
@@ -147,6 +153,10 @@ msgstr "Die Datei existiert bereits: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Datei konnte nicht umbenannt werden: {error}"
#, fuzzy
msgid "Select multiple"
msgstr "Alle auswählen"
msgid "Delete from PortProton"
msgstr "Von PortProton löschen"
@@ -306,10 +316,6 @@ msgstr ""
"'{game_name}' wurde zu Steam hinzugefügt. Bitte starte Steam neu, damit die "
"Änderungen wirksam werden."
#, python-brace-format
msgid "'{game_name}' was added to Steam."
msgstr "'{game_name}' wurde zu Steam hinzugefügt."
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "Fehler beim Hinzufügen von '{game_name}' zu Steam: {error}"
@@ -483,6 +489,16 @@ msgstr "Eine weitere Epic-Operation läuft"
msgid "Wine prefix not found"
msgstr "Wine-Prefix nicht gefunden"
msgid ""
"Cyrillic characters were found in the following paths. Wine may work "
"unreliably. Move the game or PortProton to a path without Cyrillic "
"characters:\n"
"\n"
msgstr ""
msgid "Don't remind again"
msgstr ""
msgid "Game not found."
msgstr "Spiel nicht gefunden."
@@ -837,9 +853,6 @@ msgstr "Min."
msgid "sec."
msgstr "Sek."
msgid "Pause Game"
msgstr "Spiel anhalten"
msgid "Exit"
msgstr "Exit"
@@ -852,9 +865,6 @@ msgstr "Favoriten"
msgid "Recent Games"
msgstr "Aktuelle Spiele"
msgid "Resume Game"
msgstr "Spiel fortsetzen"
msgid "Failed to pause game"
msgstr "Spiel konnte nicht pausiert werden"
@@ -958,6 +968,22 @@ msgstr "AppImage aktualisieren..."
msgid "Launching {0}"
msgstr "Start {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Ordner konnte nicht erstellt werden: {error}"
msgid "Name:"
msgstr "Titel:"
@@ -982,6 +1008,33 @@ msgstr "Titelbild-Vorschau:"
msgid "Apply"
msgstr "Anwenden"
#, fuzzy
msgid "Select folder"
msgstr "Ordner löschen"
#, fuzzy
msgid "Processing games…"
msgstr "Aktuelle Spiele"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr "Information"
msgid "No games found in the selected folder."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "Aktuelle Spiele"
msgid "Close"
msgstr "Schließen"
msgid "Invalid image"
msgstr "Ungültiges Bild"
@@ -1002,8 +1055,14 @@ msgstr ""
"Das Programm wurde beendet. Kompatibilitätsdetails wurden automatisch "
"erfasst."
msgid "Close"
msgstr "Schließen"
msgid "Downloading…"
msgstr "Herunterladen…"
msgid "Download process failed"
msgstr "Download-Vorgang ist fehlgeschlagen"
msgid "Downloading Complete"
msgstr "Download abgeschlossen"
msgid "Open"
msgstr "Öffnen"
@@ -1026,9 +1085,6 @@ msgstr "Vorheriger Tab"
msgid "Next Tab"
msgstr "Nächster Tab"
msgid "Save"
msgstr "Speichern"
msgid "Search"
msgstr "Suchen"
@@ -1176,9 +1232,6 @@ msgstr "WINE/Proton konnte nicht entfernt werden in {path}: {error}"
msgid "Successfully removed {} WINE/Proton."
msgstr "{} WINE/Proton erfolgreich entfernt."
msgid "Downloading Complete"
msgstr "Download abgeschlossen"
msgid "All selected WINE/Proton have been downloaded!"
msgstr "Alle ausgewählten WINE/Proton-Versionen wurden heruntergeladen!"
@@ -1266,9 +1319,6 @@ msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
msgid "Failed to open file:\n"
msgstr "Datei konnte nicht geöffnet werden:\n"
msgid "Information"
msgstr "Information"
msgid "Confirm PPDB Clear"
msgstr "PPDB-Zurücksetzung bestätigen"
@@ -2215,9 +2265,6 @@ msgstr "Fehlgeschlagen"
msgid "Repaired"
msgstr "Repariert"
msgid "Download process failed"
msgstr "Download-Vorgang ist fehlgeschlagen"
msgid "Quick Launch"
msgstr "Schnellstart"
@@ -2292,6 +2339,9 @@ msgstr ""
"Sollen '{count}' Titelbidler ohne ausführbaren Dateien wirklich gelöscht "
"werden? Dabei werden Benutzerdaten und .desktop-Dateien gelöscht."
msgid "Failed to install theme"
msgstr "Installation des Themes fehlgeschlagen"
msgid "PortProton Settings"
msgstr "PortProton-Einstellungen"
@@ -2355,6 +2405,9 @@ msgstr "Benutzeroberflächen-Sounds aktivieren"
msgid "Show compatibility report after game crash"
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
@@ -2579,15 +2632,9 @@ msgstr "Laden der Themes fehlgeschlagen"
msgid "Failed to download theme"
msgstr "Herunterladen des Themes fehlgeschlagen"
msgid "Downloading…"
msgstr "Herunterladen…"
msgid "Downloading… {0}%"
msgstr "Herunterladen… {0}%"
msgid "Failed to install theme"
msgstr "Installation des Themes fehlgeschlagen"
msgid "Select Theme:"
msgstr "Theme auswählen:"
@@ -21,6 +21,12 @@ msgstr ""
"X-Generator: Weblate 2026.8.1\n"
"Generated-By: Babel 2.17.0\n"
msgid "Resume Game"
msgstr "Reanudar el juego"
msgid "Pause Game"
msgstr "Pausar el juego"
msgid "Stop Game"
msgstr "Detener Juego"
@@ -149,6 +155,10 @@ msgstr "El archivo ya existe: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "No se pudo cambiar el nombre del archivo: {error}"
#, fuzzy
msgid "Select multiple"
msgstr "Seleccionar todo"
msgid "Delete from PortProton"
msgstr "Eliminar de PortProton"
@@ -308,10 +318,6 @@ msgstr ""
"Se ha añadido «{game_name}» a Steam. Reinicia Steam para que los cambios "
"surtan efecto."
#, python-brace-format
msgid "'{game_name}' was added to Steam."
msgstr "«{game_name}» se ha añadido a Steam."
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "No se ha podido añadir «{game_name}» a Steam: {error}"
@@ -488,6 +494,16 @@ msgstr ""
msgid "Wine prefix not found"
msgstr "Prefijo no encontrado"
msgid ""
"Cyrillic characters were found in the following paths. Wine may work "
"unreliably. Move the game or PortProton to a path without Cyrillic "
"characters:\n"
"\n"
msgstr ""
msgid "Don't remind again"
msgstr ""
msgid "Game not found."
msgstr "Juego no encontrado."
@@ -857,9 +873,6 @@ msgstr "min"
msgid "sec."
msgstr "s"
msgid "Pause Game"
msgstr "Pausar el juego"
msgid "Exit"
msgstr "Salir"
@@ -872,9 +885,6 @@ msgstr "Favoritos"
msgid "Recent Games"
msgstr "Juegos recientes"
msgid "Resume Game"
msgstr "Reanudar el juego"
msgid "Failed to pause game"
msgstr "Error al pausar el juego"
@@ -977,6 +987,22 @@ msgstr "Actualizando AppImage…"
msgid "Launching {0}"
msgstr "Comenzando {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "No se ha podido crear la carpeta: {error}"
msgid "Name:"
msgstr "Nombre:"
@@ -1001,6 +1027,33 @@ msgstr "Vista previa de la portada:"
msgid "Apply"
msgstr "Aplicar"
#, fuzzy
msgid "Select folder"
msgstr "Borrar la carpeta"
#, fuzzy
msgid "Processing games…"
msgstr "Juegos recientes"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr ""
msgid "No games found in the selected folder."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "Juegos recientes"
msgid "Close"
msgstr ""
msgid "Invalid image"
msgstr "Imagen no válida"
@@ -1019,7 +1072,14 @@ msgstr ""
msgid "The program stopped. Compatibility details were collected automatically."
msgstr ""
msgid "Close"
msgid "Downloading…"
msgstr "Descargando…"
#, fuzzy
msgid "Download process failed"
msgstr "Descargar seleccionados"
msgid "Downloading Complete"
msgstr ""
msgid "Open"
@@ -1043,9 +1103,6 @@ msgstr "Pestaña anterior"
msgid "Next Tab"
msgstr "Pestaña siguiente"
msgid "Save"
msgstr "Guardar"
msgid "Search"
msgstr "Buscar"
@@ -1191,9 +1248,6 @@ msgstr "No se pudo eliminar WINE/Proton en {path}: {error}"
msgid "Successfully removed {} WINE/Proton."
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "All selected WINE/Proton have been downloaded!"
msgstr ""
@@ -1284,9 +1338,6 @@ msgstr ""
msgid "Failed to open file:\n"
msgstr "No se ha podido abrir la carpeta: {error}"
msgid "Information"
msgstr ""
msgid "Confirm PPDB Clear"
msgstr "Confirmar que PPDB está vacío"
@@ -2226,10 +2277,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
#, fuzzy
msgid "Download process failed"
msgstr "Descargar seleccionados"
msgid "Quick Launch"
msgstr ""
@@ -2307,6 +2354,9 @@ msgstr ""
"¿Estás seguro de que quieres eliminar «{game_name}»? Esto eliminará el "
"archivo .desktop y los datos personalizados."
msgid "Failed to install theme"
msgstr "Error al instalar el tema"
msgid "PortProton Settings"
msgstr ""
@@ -2370,6 +2420,9 @@ msgstr ""
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
@@ -2592,15 +2645,9 @@ msgstr "Error al cargar los temas"
msgid "Failed to download theme"
msgstr "Error al descargar los temas"
msgid "Downloading…"
msgstr "Descargando…"
msgid "Downloading… {0}%"
msgstr "Descargando… {0}%"
msgid "Failed to install theme"
msgstr "Error al instalar el tema"
msgid "Select Theme:"
msgstr ""
+73 -30
View File
@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-10-11 01:04+0500\n"
"POT-Creation-Date: 2026-10-21 00:34+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,6 +18,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Stop Game"
msgstr ""
@@ -138,6 +144,9 @@ msgstr ""
msgid "Failed to rename file: {error}"
msgstr ""
msgid "Select multiple"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
@@ -290,10 +299,6 @@ msgid ""
"take effect."
msgstr ""
#, python-brace-format
msgid "'{game_name}' was added to Steam."
msgstr ""
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr ""
@@ -467,6 +472,16 @@ msgstr ""
msgid "Wine prefix not found"
msgstr ""
msgid ""
"Cyrillic characters were found in the following paths. Wine may work "
"unreliably. Move the game or PortProton to a path without Cyrillic "
"characters:\n"
"\n"
msgstr ""
msgid "Don't remind again"
msgstr ""
msgid "Game not found."
msgstr ""
@@ -762,9 +777,6 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Exit"
msgstr ""
@@ -777,9 +789,6 @@ msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Resume Game"
msgstr ""
msgid "Failed to pause game"
msgstr ""
@@ -882,6 +891,22 @@ msgstr ""
msgid "Launching {0}"
msgstr ""
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr ""
msgid "Name:"
msgstr ""
@@ -906,6 +931,30 @@ msgstr ""
msgid "Apply"
msgstr ""
msgid "Select folder"
msgstr ""
msgid "Processing games…"
msgstr ""
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr ""
msgid "No games found in the selected folder."
msgstr ""
msgid "Select games"
msgstr ""
msgid "Close"
msgstr ""
msgid "Invalid image"
msgstr ""
@@ -924,7 +973,13 @@ msgstr ""
msgid "The program stopped. Compatibility details were collected automatically."
msgstr ""
msgid "Close"
msgid "Downloading…"
msgstr ""
msgid "Download process failed"
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "Open"
@@ -948,9 +1003,6 @@ msgstr ""
msgid "Next Tab"
msgstr ""
msgid "Save"
msgstr ""
msgid "Search"
msgstr ""
@@ -1092,9 +1144,6 @@ msgstr ""
msgid "Successfully removed {} WINE/Proton."
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "All selected WINE/Proton have been downloaded!"
msgstr ""
@@ -1182,9 +1231,6 @@ msgstr ""
msgid "Failed to open file:\n"
msgstr ""
msgid "Information"
msgstr ""
msgid "Confirm PPDB Clear"
msgstr ""
@@ -2113,9 +2159,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
msgid "Download process failed"
msgstr ""
msgid "Quick Launch"
msgstr ""
@@ -2188,6 +2231,9 @@ msgid ""
"will remove the .desktop files and custom data."
msgstr ""
msgid "Failed to install theme"
msgstr ""
msgid "PortProton Settings"
msgstr ""
@@ -2251,6 +2297,9 @@ msgstr ""
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
@@ -2472,16 +2521,10 @@ msgstr ""
msgid "Failed to download theme"
msgstr ""
msgid "Downloading…"
msgstr ""
#, python-brace-format
msgid "Downloading… {0}%"
msgstr ""
msgid "Failed to install theme"
msgstr ""
msgid "Select Theme:"
msgstr ""
@@ -9,7 +9,7 @@ msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
"PO-Revision-Date: 2026-09-24 06:40+0000\n"
"PO-Revision-Date: 2026-10-17 23:27+0000\n"
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
"portprotonqt/portprotonqt/pt_BR/>\n"
@@ -18,9 +18,15 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 2026.9.1\n"
"X-Generator: Weblate 2026.10.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr "Retomar jogo"
msgid "Pause Game"
msgstr "Pausar jogo"
msgid "Stop Game"
msgstr "Parar o Jogo"
@@ -147,6 +153,10 @@ msgstr "A pasta já existe: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Falha ao renomear o arquivo: {error}"
#, fuzzy
msgid "Select multiple"
msgstr "Selecionar Tudo"
msgid "Delete from PortProton"
msgstr "Excluir do PortProton"
@@ -306,10 +316,6 @@ msgstr ""
"'{game_name}' foi adicionado à Steam. Reinicie a Steam para aplicar as "
"alterações."
#, python-brace-format
msgid "'{game_name}' was added to Steam."
msgstr "'{game_name}' foi adicionado à Steam."
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "Falha ao adicionar '{game_name}' à Steam: {error}"
@@ -482,6 +488,16 @@ msgstr "Outra operação da Epic está em andamento"
msgid "Wine prefix not found"
msgstr "Prefixo do Wine não encontrado"
msgid ""
"Cyrillic characters were found in the following paths. Wine may work "
"unreliably. Move the game or PortProton to a path without Cyrillic "
"characters:\n"
"\n"
msgstr ""
msgid "Don't remind again"
msgstr ""
msgid "Game not found."
msgstr "Jogo não encontrado."
@@ -839,9 +855,6 @@ msgstr "min."
msgid "sec."
msgstr "s."
msgid "Pause Game"
msgstr "Pausar jogo"
msgid "Exit"
msgstr "Sair"
@@ -854,9 +867,6 @@ msgstr "Favoritos"
msgid "Recent Games"
msgstr "Jogos Recentes"
msgid "Resume Game"
msgstr "Retomar jogo"
msgid "Failed to pause game"
msgstr "Falha ao pausar o jogo"
@@ -959,6 +969,22 @@ msgstr "Atualizando AppImage..."
msgid "Launching {0}"
msgstr "Iniciando {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Falha ao criar a pasta: {error}"
msgid "Name:"
msgstr "Nome:"
@@ -983,6 +1009,33 @@ msgstr "Pré-visualização da Capa:"
msgid "Apply"
msgstr "Aplicar"
#, fuzzy
msgid "Select folder"
msgstr "Excluir Pasta"
#, fuzzy
msgid "Processing games…"
msgstr "Jogos Recentes"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr "Informação"
msgid "No games found in the selected folder."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "Jogos Recentes"
msgid "Close"
msgstr "Fechar"
msgid "Invalid image"
msgstr "Imagem inválida"
@@ -1003,8 +1056,14 @@ msgstr ""
"O programa foi encerrado. As informações de compatibilidade foram coletadas "
"automaticamente."
msgid "Close"
msgstr "Fechar"
msgid "Downloading…"
msgstr "Baixando…"
msgid "Download process failed"
msgstr "O processo de download falhou"
msgid "Downloading Complete"
msgstr "Download Concluído"
msgid "Open"
msgstr "Abrir"
@@ -1027,9 +1086,6 @@ msgstr "Aba Anterior"
msgid "Next Tab"
msgstr "Próxima Aba"
msgid "Save"
msgstr "Salvar"
msgid "Search"
msgstr "Pesquisar"
@@ -1177,9 +1233,6 @@ msgstr "Falha ao remover WINE/Proton em {path}: {error}"
msgid "Successfully removed {} WINE/Proton."
msgstr "{} WINE/Proton removido(s) com sucesso."
msgid "Downloading Complete"
msgstr "Download Concluído"
msgid "All selected WINE/Proton have been downloaded!"
msgstr "Todos os WINE/Proton selecionados foram baixados!"
@@ -1218,15 +1271,14 @@ msgstr "O download ou a extração foi cancelado."
msgid "System WINE"
msgstr "WINE do Sistema"
#, fuzzy
msgid "Global Game Settings"
msgstr "Salvar Configurações"
msgstr "Configurações globais dos jogos"
msgid "Exe Settings"
msgstr "Configurações do Exe"
msgid "Edit user.conf"
msgstr ""
msgstr "Editar o arquivo user.conf"
msgid "Search settings…"
msgstr "Pesquisar configurações…"
@@ -1252,9 +1304,8 @@ msgstr "Editar PPDB"
msgid "Clear PPDB"
msgstr "Limpar PPDB"
#, fuzzy
msgid "Failed to load global game settings."
msgstr "Falha ao carregar temas"
msgstr "Falha ao carregar as configurações globais dos jogos."
msgid "Executable path is not available."
msgstr "O caminho do executável não está disponível."
@@ -1265,12 +1316,8 @@ msgstr "O arquivo PPDB não existe em: "
msgid "Failed to open PPDB file:\n"
msgstr "Falha ao abrir o arquivo PPDB:\n"
#, fuzzy
msgid "Failed to open file:\n"
msgstr "Falha ao abrir o arquivo PPDB:\n"
msgid "Information"
msgstr "Informação"
msgstr "Falha ao abrir o arquivo:\n"
msgid "Confirm PPDB Clear"
msgstr "Confirmar Limpeza do PPDB"
@@ -1292,9 +1339,8 @@ msgstr "Padrão"
msgid "disabled"
msgstr "desativado"
#, fuzzy
msgid "Download Wine/Proton..."
msgstr "Baixar WINE/Proton para a Steam"
msgstr "Baixar Wine/Proton..."
msgid "Enter prefix name"
msgstr "Digite o nome do prefixo"
@@ -2135,7 +2181,7 @@ msgid "GOG account connected"
msgstr "Conta GOG conectada"
msgid "Log out"
msgstr "Encerrar sessão"
msgstr "Sair da sessão"
msgid "Open login page"
msgstr "Abrir página de login"
@@ -2217,9 +2263,6 @@ msgstr "Falhou"
msgid "Repaired"
msgstr "Reparado"
msgid "Download process failed"
msgstr "O processo de download falhou"
msgid "Quick Launch"
msgstr "Início Rápido"
@@ -2259,16 +2302,14 @@ msgstr "Oculto"
msgid "Badge View Type:"
msgstr "Modo de Exibição do Selo:"
#, fuzzy
msgid "Theme"
msgstr "Temas"
msgstr "Tema"
msgid "Grid"
msgstr ""
msgstr "Grade"
#, fuzzy
msgid "List"
msgstr "Claro"
msgstr "Lista"
msgid "Vertical"
msgstr "Vertical"
@@ -2296,6 +2337,9 @@ msgstr ""
"Tem certeza de que deseja excluir {count} cartões sem executável? Isso "
"removerá os arquivos .desktop e os dados personalizados."
msgid "Failed to install theme"
msgstr "Falha ao instalar o tema"
msgid "PortProton Settings"
msgstr "Configurações do PortProton"
@@ -2326,15 +2370,14 @@ msgstr "Tipo de Controle:"
msgid "Launch Application in Fullscreen"
msgstr "Iniciar Aplicativo em Tela Cheia"
#, fuzzy
msgid "Hide Cursor in Fullscreen"
msgstr "Forçar janelas em tela cheia"
msgstr "Ocultar o cursor em tela cheia"
msgid "Auto Fullscreen on Gamepad connected"
msgstr "Tela Cheia Automática ao Conectar o Controle"
msgid "Disable Boot Animation"
msgstr ""
msgstr "Desativar animação de inicialização"
msgid "Minimize to tray on close"
msgstr "Minimizar para a bandeja ao fechar"
@@ -2360,9 +2403,12 @@ msgstr "Ativar sons da interface"
msgid "Show compatibility report after game crash"
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
msgid "Disable ALT Linux i586 dependency check"
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr "Desativar verificação de dependências do ALT Linux i586"
msgid "Disable runtime download"
msgstr "Desativar download de runtime"
@@ -2370,7 +2416,7 @@ msgid "Add to Steam compatibility tools"
msgstr "Adicionar às ferramentas de compatibilidade da Steam"
msgid "Disable Steam Proton scanning"
msgstr ""
msgstr "Desativar a busca por instalações do Steam Proton"
msgid "Download WINE/Proton to Steam"
msgstr "Baixar WINE/Proton para a Steam"
@@ -2582,15 +2628,9 @@ msgstr "Falha ao carregar temas"
msgid "Failed to download theme"
msgstr "Falha ao baixar o tema"
msgid "Downloading…"
msgstr "Baixando…"
msgid "Downloading… {0}%"
msgstr "Baixando… {0}%"
msgid "Failed to install theme"
msgstr "Falha ao instalar o tema"
msgid "Select Theme:"
msgstr "Selecionar Tema:"
@@ -9,8 +9,8 @@ msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
"PO-Revision-Date: 2026-10-09 11:06+0000\n"
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
"PO-Revision-Date: 2026-10-15 19:27+0000\n"
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/ru/>\n"
"Language: ru\n"
@@ -19,9 +19,15 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 2026.9.1\n"
"X-Generator: Weblate 2026.10.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr "Возобновить игру"
msgid "Pause Game"
msgstr "Приостановить игру"
msgid "Stop Game"
msgstr "Остановить игру"
@@ -147,6 +153,10 @@ msgstr "Файл уже существует: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Не удалось переименовать файл: {error}"
#, fuzzy
msgid "Select multiple"
msgstr "Выбрать всё"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
@@ -304,10 +314,6 @@ msgstr ""
"'{game_name}' добавлен(а) в Steam. Пожалуйста, перезапустите Steam, чтобы"
" изменения вступили в силу."
#, python-brace-format
msgid "'{game_name}' was added to Steam."
msgstr "'{game_name}' добавлен(а) в Steam."
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
@@ -480,6 +486,16 @@ msgstr "Другая операция Epic уже выполняется"
msgid "Wine prefix not found"
msgstr "Префикс Wine не найден"
msgid ""
"Cyrillic characters were found in the following paths. Wine may work "
"unreliably. Move the game or PortProton to a path without Cyrillic "
"characters:\n"
"\n"
msgstr ""
msgid "Don't remind again"
msgstr ""
msgid "Game not found."
msgstr "Игра не найдена."
@@ -747,14 +763,14 @@ msgstr ""
"run --user --scope --slice=app.slice"
msgid "CPU Cores Limit"
msgstr "Ограничение ядер CPU"
msgstr "Ограничение ядер ЦП"
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
"Ограничение количества ядер CPU полезно для игр на Unity (рекомендуется "
"установить значение равным 8)"
"Ограничение количества ядер процессора полезно для игр на Unity "
"(рекомендуется установить значение равным 8)"
msgid "OpenGL Version"
msgstr "Версия OpenGL"
@@ -838,9 +854,6 @@ msgstr "мин."
msgid "sec."
msgstr "сек."
msgid "Pause Game"
msgstr "Приостановить игру"
msgid "Exit"
msgstr "Выход"
@@ -853,9 +866,6 @@ msgstr "Избранное"
msgid "Recent Games"
msgstr "Недавние"
msgid "Resume Game"
msgstr "Возобновить игру"
msgid "Failed to pause game"
msgstr "Не удалось приостановить игру"
@@ -958,6 +968,22 @@ msgstr "Обновление AppImage..."
msgid "Launching {0}"
msgstr "Запуск {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Не удалось создать папку: {error}"
msgid "Name:"
msgstr "Название:"
@@ -982,6 +1008,33 @@ msgstr "Предпросмотр обложки:"
msgid "Apply"
msgstr "Применить"
#, fuzzy
msgid "Select folder"
msgstr "Удалить Папку"
#, fuzzy
msgid "Processing games…"
msgstr "Недавние"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr "Информация"
msgid "No games found in the selected folder."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "Недавние"
msgid "Close"
msgstr "Закрыть"
msgid "Invalid image"
msgstr "Недопустимое изображение"
@@ -1002,8 +1055,14 @@ msgstr ""
"Программа остановилась. Информация о совместимости была собрана "
"автоматически."
msgid "Close"
msgstr "Закрыть"
msgid "Downloading…"
msgstr "Скачивание…"
msgid "Download process failed"
msgstr "Ошибка процесса загрузки"
msgid "Downloading Complete"
msgstr "Скачивание завершено"
msgid "Open"
msgstr "Открыть"
@@ -1026,9 +1085,6 @@ msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
msgid "Save"
msgstr "Сохранить"
msgid "Search"
msgstr "Поиск"
@@ -1176,9 +1232,6 @@ msgstr "Не удалось удалить WINE/Proton в {path}: {error}"
msgid "Successfully removed {} WINE/Proton."
msgstr "Успешно удалено {} WINE/Proton."
msgid "Downloading Complete"
msgstr "Скачивание завершено"
msgid "All selected WINE/Proton have been downloaded!"
msgstr "Все выбранные WINE/Proton успешно загружены!"
@@ -1265,9 +1318,6 @@ msgstr "Не удалось открыть файл PPDB:\n"
msgid "Failed to open file:\n"
msgstr "Не удалось открыть файл:\n"
msgid "Information"
msgstr "Информация"
msgid "Confirm PPDB Clear"
msgstr "Подтвердите очистку PPDB"
@@ -1547,7 +1597,7 @@ msgid "Failed to save custom preset."
msgstr "Ошибка сохранения пользовательских настроек."
msgid "Overlay position"
msgstr "Позиция MangoHud"
msgstr "Положение MangoHud"
msgid "Device battery"
msgstr "Батарея устройства"
@@ -1562,7 +1612,7 @@ msgid "FCAT screen edge"
msgstr "Край области FCAT"
msgid "Table columns"
msgstr "Кол-во колонок"
msgstr "Столбцов таблицы"
msgid "Network (tx/rx kb/s)"
msgstr "Сеть (tx/rx kb/s)"
@@ -1931,9 +1981,8 @@ msgid ""
"Select one or more FPS presets. The values are saved into FPS_LIMIT and "
"MangoHud will also show the active FPS limit."
msgstr ""
"Выберите один или несколько предустановленных профилей FPS. Значения "
"будут сохранены в FPS_LIMIT и MangoHud также отобразит активное "
"ограничение FPS."
"Выберите один или несколько предустановленных профилей FPS. Значения будут "
"сохранены в FPS_LIMIT, а также MangoHud отобразит активное ограничение FPS."
msgid "Additional comma-separated MangoHud options not covered by the GUI."
msgstr "Дополнительные параметры MangoHud (через запятую), отсутствующие в GUI."
@@ -2207,9 +2256,6 @@ msgstr "Ошибка"
msgid "Repaired"
msgstr "Исправлено"
msgid "Download process failed"
msgstr "Ошибка процесса загрузки"
msgid "Quick Launch"
msgstr "Быстрый запуск"
@@ -2284,6 +2330,9 @@ msgstr ""
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
"приведёт к удалению файлов .desktop и пользовательских данных."
msgid "Failed to install theme"
msgstr "Не удалось установить тему"
msgid "PortProton Settings"
msgstr "Настройки PortProtonQt"
@@ -2347,6 +2396,9 @@ msgstr "Включить звуки интерфейса"
msgid "Show compatibility report after game crash"
msgstr "Показывать отчёт о совместимости после сбоя игры"
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr "Отключить проверку зависимостей ALT Linux i586"
@@ -2567,15 +2619,9 @@ msgstr "Не удалось загрузить темы"
msgid "Failed to download theme"
msgstr "Не удалось скачать тему"
msgid "Downloading…"
msgstr "Скачивание…"
msgid "Downloading… {0}%"
msgstr "Скачивание… {0}%"
msgid "Failed to install theme"
msgstr "Не удалось установить тему"
msgid "Select Theme:"
msgstr "Выбрать тему:"
@@ -21,6 +21,14 @@ msgstr ""
"X-Generator: Weblate 2026.6.1\n"
"Generated-By: Babel 2.18.0\n"
#, fuzzy
msgid "Resume Game"
msgstr "最近游戏"
#, fuzzy
msgid "Pause Game"
msgstr "启动游戏"
msgid "Stop Game"
msgstr "停止游戏"
@@ -141,6 +149,10 @@ msgstr "文件已存在:{file_name}"
msgid "Failed to rename file: {error}"
msgstr "重命名文件失败:{error}"
#, fuzzy
msgid "Select multiple"
msgstr "全选"
msgid "Delete from PortProton"
msgstr "从 PortProton 中删除"
@@ -298,10 +310,6 @@ msgid ""
"take effect."
msgstr "\"{game_name}\"已添加到 Steam。请重启 Steam 以使更改生效。"
#, python-brace-format
msgid "'{game_name}' was added to Steam."
msgstr "\"{game_name}\"已添加到 Steam。"
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "无法将\"{game_name}\"添加到 Steam:{error}"
@@ -476,6 +484,16 @@ msgstr ""
msgid "Wine prefix not found"
msgstr "未找到前缀"
msgid ""
"Cyrillic characters were found in the following paths. Wine may work "
"unreliably. Move the game or PortProton to a path without Cyrillic "
"characters:\n"
"\n"
msgstr ""
msgid "Don't remind again"
msgstr ""
msgid "Game not found."
msgstr "未找到游戏。"
@@ -802,10 +820,6 @@ msgstr "分钟。"
msgid "sec."
msgstr "秒。"
#, fuzzy
msgid "Pause Game"
msgstr "启动游戏"
msgid "Exit"
msgstr "退出"
@@ -818,10 +832,6 @@ msgstr "收藏夹"
msgid "Recent Games"
msgstr "最近游戏"
#, fuzzy
msgid "Resume Game"
msgstr "最近游戏"
#, fuzzy
msgid "Failed to pause game"
msgstr "停止游戏失败"
@@ -925,6 +935,22 @@ msgstr "正在更新 AppImage..."
msgid "Launching {0}"
msgstr "正在启动 {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "创建文件夹失败:{error}"
msgid "Name:"
msgstr "名称:"
@@ -949,6 +975,34 @@ msgstr "封面预览:"
msgid "Apply"
msgstr "应用"
#, fuzzy
msgid "Select folder"
msgstr "删除文件夹"
#, fuzzy
msgid "Processing games…"
msgstr "最近游戏"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr "信息"
msgid "No games found in the selected folder."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "最近游戏"
#, fuzzy
msgid "Close"
msgstr "鼠标"
msgid "Invalid image"
msgstr "图像无效"
@@ -967,9 +1021,15 @@ msgstr ""
msgid "The program stopped. Compatibility details were collected automatically."
msgstr ""
msgid "Downloading…"
msgstr "正在下载…"
#, fuzzy
msgid "Close"
msgstr "鼠标"
msgid "Download process failed"
msgstr "下载进行中"
msgid "Downloading Complete"
msgstr "下载完成"
msgid "Open"
msgstr "打开"
@@ -992,9 +1052,6 @@ msgstr "上一标签页"
msgid "Next Tab"
msgstr "下一标签页"
msgid "Save"
msgstr "保存"
msgid "Search"
msgstr "搜索"
@@ -1142,9 +1199,6 @@ msgstr "移除位于 {path} 的 WINE/Proton 失败:{error}"
msgid "Successfully removed {} WINE/Proton."
msgstr "已成功移除 {} 个 WINE/Proton。"
msgid "Downloading Complete"
msgstr "下载完成"
msgid "All selected WINE/Proton have been downloaded!"
msgstr "所有选定的 WINE/Proton 已下载完成!"
@@ -1234,9 +1288,6 @@ msgstr "打开 PPDB 文件失败:\n"
msgid "Failed to open file:\n"
msgstr "打开 PPDB 文件失败:\n"
msgid "Information"
msgstr "信息"
msgid "Confirm PPDB Clear"
msgstr "确认清除 PPDB"
@@ -2187,10 +2238,6 @@ msgstr "详细"
msgid "Repaired"
msgstr "已配对"
#, fuzzy
msgid "Download process failed"
msgstr "下载进行中"
msgid "Quick Launch"
msgstr "快速启动"
@@ -2273,6 +2320,9 @@ msgstr ""
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
"据。"
msgid "Failed to install theme"
msgstr "安装主题失败"
msgid "PortProton Settings"
msgstr "PortProton 设置"
@@ -2338,6 +2388,9 @@ msgstr "启用界面音效"
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
@@ -2560,15 +2613,9 @@ msgstr "加载主题失败"
msgid "Failed to download theme"
msgstr "下载主题失败"
msgid "Downloading…"
msgstr "正在下载…"
msgid "Downloading… {0}%"
msgstr "正在下载… {0}%"
msgid "Failed to install theme"
msgstr "安装主题失败"
msgid "Select Theme:"
msgstr "选择主题:"
+6 -3
View File
@@ -3,7 +3,7 @@ import configparser
from pathlib import Path
import locale
import os
from babel import Locale
from babel import Locale, UnknownLocaleError
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -85,8 +85,11 @@ def get_system_locale():
if ui_config.get_force_english():
return 'en'
loc = locale.getlocale()[0]
return loc if loc else 'en'
try:
loc = locale.getlocale()[0]
return str(Locale.parse(loc)) if loc else 'en'
except (UnknownLocaleError, ValueError):
return 'en'
def get_steam_language():
try:
+58 -18
View File
@@ -7,6 +7,7 @@ import shlex
import subprocess
import re
import time
import unicodedata
from datetime import datetime
from pathlib import Path
from queue import Empty, Queue
@@ -115,7 +116,7 @@ class MainWindow(
QMainWindow,
):
games_loaded = Signal(list)
compatibility_report_ready = Signal(str)
compatibility_report_ready = Signal(str, str)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
@@ -2493,11 +2494,12 @@ class MainWindow(
except (OSError, ValueError, TypeError) as error:
logger.error("Compatibility analysis failed for %s: %s", launch.executable, error)
return
self.compatibility_report_ready.emit(report)
self.compatibility_report_ready.emit(report, launch.executable)
def _show_compatibility_report(self, report: str) -> None:
def _show_compatibility_report(self, report: str, executable: str) -> None:
logger.info("Compatibility report dialog opened")
dialog = CompatibilityReportDialog(self, self.theme, report)
dialog.check_ppdb(executable)
dialog.exec()
def _terminate_game_processes(self) -> None:
@@ -2764,22 +2766,23 @@ class MainWindow(
def _delete_egs_game(self, app_id: str) -> None:
game = self.egs_api.load_installed().get(app_id, {})
message_box = QMessageBox(self)
message_box.setIcon(QMessageBox.Icon.Question)
message_box.setWindowTitle(_("Confirm Deletion"))
message_box.setText(
_("Delete '{0}' and all files in its installation folder?").format(
game.get("title") or app_id
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
message_box = QMessageBox(self)
message_box.setIcon(QMessageBox.Icon.Question)
message_box.setWindowTitle(_("Confirm Deletion"))
message_box.setText(
_("Delete '{0}' and all files in its installation folder?").format(
game.get("title") or app_id
)
)
)
message_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
message_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
self._start_egs_operation(
app_id, ["uninstall", app_id, "-y"], _("Delete")
)
@@ -2870,6 +2873,37 @@ class MainWindow(
prefix_path = Path(self.portproton_location) / "data/prefixes" / prefix_name
return self.egs_api.is_eos_overlay_enabled(prefix_path)
def _warn_cyrillic_launch_paths(self, game_path: str) -> bool:
if ui_config.get_disable_cyrillic_path_check():
return False
paths = [game_path, *(self.start_sh or [])[:1]]
affected_paths = []
for path in paths:
for candidate in (os.path.abspath(path), os.path.realpath(path)):
if candidate in affected_paths:
continue
if any("CYRILLIC" in unicodedata.name(char, "") for char in candidate):
affected_paths.append(candidate)
if not affected_paths:
return False
message_box = QMessageBox(self)
message_box.setIcon(QMessageBox.Icon.Warning)
message_box.setWindowTitle(_("Warning"))
message_box.setText(
_("Cyrillic characters were found in the following paths. "
"Wine may work unreliably. Move the game or PortProton "
"to a path without Cyrillic characters:\n\n")
+ "\n".join(affected_paths)
)
message_box.setStandardButtons(QMessageBox.StandardButton.Close)
disable_button = message_box.addButton(
_("Don't remind again"), QMessageBox.ButtonRole.ActionRole
)
message_box.exec()
if message_box.clickedButton() is disable_button:
ui_config.set_disable_cyrillic_path_check(True)
return True
def _launch_egs_game(self, app_id: str, button=None) -> None:
target = self.egs_api.get_launch_target(app_id)
if not target:
@@ -2897,6 +2931,7 @@ class MainWindow(
"launch", app_id, "--json", "--wrapper", self.start_sh[0],
"--no-wine", "--skip-version-check",
])
self._warn_cyrillic_launch_paths(target)
process = subprocess.Popen(
command, env=self.egs_api.get_environment(), shell=False,
preexec_fn=os.setsid, stdout=subprocess.PIPE,
@@ -2958,6 +2993,9 @@ class MainWindow(
if not self._check_alt_i586_dependencies_before_launch():
self._finish_silent_launch()
return
self._warn_cyrillic_launch_paths(
self.gog_api.get_launch_target(app_id) or install_path
)
needs_setup = getattr(self.gog_api, "needs_support_setup", lambda _app_id: False)
if needs_setup(app_id):
self._start_gog_support_setup(app_id, button)
@@ -3063,6 +3101,8 @@ class MainWindow(
else:
if not self._check_alt_i586_dependencies_before_launch():
return
if file_to_check.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS):
self._warn_cyrillic_launch_paths(file_to_check)
if update_button:
try:
+1 -1
View File
@@ -118,7 +118,6 @@ install_data(
'dialogs/settings_dialog.py',
'dialogs/settings_mangohud.py',
'dialogs/settings_gamescope.py',
'dialogs/settings_reshade.py',
'dialogs/settings_vkbasalt.py',
'dialogs/proton_manager.py',
'dialogs/prefix_backup.py',
@@ -187,6 +186,7 @@ install_data(
['scripts_utils/__init__.py',
'scripts_utils/dbus_tools.py',
'scripts_utils/easyterm.py',
'scripts_utils/exe_metadata.py',
'scripts_utils/graphics_detector.py',
'scripts_utils/idle_inhibit.py',
'scripts_utils/json_tools.py',
+34 -5
View File
@@ -2,6 +2,7 @@
import argparse
import asyncio
import contextlib
import os
import sys
import time
from collections.abc import Awaitable
@@ -168,17 +169,20 @@ async def request_deepin_wm_switch() -> bool:
async def request_idle_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
"""Request idle inhibit through screensaver or portal D-Bus API."""
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
"""Request idle inhibit through screensaver, portal or logind D-Bus API."""
bus = None
try:
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
try:
kind, payload = await _request_screensaver_inhibit(bus, application, reason)
except Exception as error:
logger.warning("Screensaver inhibit failed: %s", error)
kind, payload = await _request_portal_inhibit(bus, reason)
except Exception:
bus.disconnect()
raise
except Exception as error:
logger.warning("Session idle inhibit failed: %s", error)
if bus is not None:
bus.disconnect()
return await _request_logind_inhibit(application, reason)
return bus, kind, payload
@@ -211,8 +215,33 @@ async def _request_portal_inhibit(bus: MessageBus, reason: str) -> tuple[str, An
return "portal", body[0]
async def _request_logind_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
bus = await dbus_call(MessageBus(bus_type=BusType.SYSTEM, negotiate_unix_fd=True).connect())
try:
reply = await dbus_call(bus.call(Message(
destination="org.freedesktop.login1",
path="/org/freedesktop/login1",
interface="org.freedesktop.login1.Manager",
member="Inhibit",
signature="ssss",
body=["sleep:idle", application, reason, "block"],
)))
if reply.message_type.name == "ERROR":
raise DBusError(reply.error_name, reply.body[0])
return bus, "logind", reply.unix_fds[reply.body[0]]
except Exception:
bus.disconnect()
raise
async def release_idle_inhibit(bus: MessageBus, kind: str, payload: Any) -> None:
"""Release idle inhibit requested through request_idle_inhibit."""
if kind == "logind":
try:
os.close(payload)
finally:
bus.disconnect()
return
with contextlib.suppress(DBusError, asyncio.TimeoutError):
if kind == "screensaver":
iface, cookie = payload
+1 -5
View File
@@ -1026,11 +1026,7 @@ class TerminalWidget(QPlainTextEdit):
max_fd = os.sysconf("SC_OPEN_MAX")
except ValueError:
max_fd = 256
for fd in range(3, max_fd):
try:
os.close(fd)
except OSError:
pass
os.closerange(3, max_fd)
if self.cwd:
os.chdir(self.cwd)
@@ -0,0 +1,46 @@
"""Read PE version metadata for PortProton shell scripts."""
import re
import sys
from pathlib import Path
import pefile
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def read_metadata(path: str) -> dict[str, str] | None:
"""Read version strings without starting an external metadata tool."""
try:
with pefile.PE(path, fast_load=True) as pe:
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
return {
key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
for block in getattr(pe, "FileInfo", []) for info in block
for table in getattr(info, "StringTable", []) for key, value in table.entries.items()
}
except pefile.PEFormatError as error:
logger.debug("Unsupported PE file %s: %s", path, error)
except OSError as error:
logger.warning("Failed to read executable metadata %s: %s", path, error)
return None
def main() -> int:
if len(sys.argv) != 2:
sys.stderr.write("Usage: python -m portprotonqt.scripts_utils.exe_metadata FILE\n")
return 2
metadata = read_metadata(sys.argv[1])
if metadata is None:
return 1
sys.stdout.write(f"File Name: {Path(sys.argv[1]).name}\n")
for key, value in metadata.items():
label = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key)
sys.stdout.write(f"{label}: {' '.join(value.splitlines())}\n")
return 0
if __name__ == "__main__":
sys.exit(main())
-1
View File
@@ -149,7 +149,6 @@ def get_toggle_settings():
'PW_USE_SPECIALK': _("Enable SpecialK (injection library for fixing graphics, latency and more)"),
# Upscalers and Frame Generation
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
'PW_USE_RESHADE': _("Enable ReShade post-processing"),
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
# Ray Tracing
+2
View File
@@ -4,6 +4,7 @@ from portprotonqt.steam_api.utils import (
safe_vdf_load,
decode_text,
get_steam_home,
get_steam_runtime_library_paths,
get_steam_launch_commands,
get_steam_compatibilitytools_dir,
get_steam_compat_tool,
@@ -62,6 +63,7 @@ __all__ = [
"safe_vdf_load",
"decode_text",
"get_steam_home",
"get_steam_runtime_library_paths",
"get_steam_launch_commands",
"get_steam_compatibilitytools_dir",
"get_steam_compat_tool",
+6 -3
View File
@@ -752,15 +752,18 @@ def get_steam_game_info_async(
def on_app_info(app_info: dict | None) -> None:
if not app_info:
cover = get_local_steam_cover(appid, prefer_exact=False)
if not cover and not has_custom_data:
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
def on_anticheat_info_no_info(anticheat_info: dict) -> None:
result = _build_game_info_result(
appid="",
appid=appid,
name=decode_text(game_name),
description="",
cover="",
cover=cover,
controller_support="",
protondb_tier="",
steam_game="false",
steam_game="true",
anticheat_status=anticheat_info.get("status", ""),
anticheat_slug=anticheat_info.get("slug", ""),
)
+1 -1
View File
@@ -179,7 +179,7 @@ def build_index(steam_apps: list) -> dict:
for app in steam_apps:
normalized = app.get("normalized_name", "")
if normalized:
steam_apps_index[normalized] = app
steam_apps_index.setdefault(normalized, app)
return steam_apps_index
+11
View File
@@ -89,6 +89,17 @@ def get_steam_home() -> Path | None:
return None
def get_steam_runtime_library_paths() -> list[str]:
"""Return runtime library directories from all Steam installations."""
runtime_paths = []
for steam_path in _iter_existing_steam_data_dirs():
for runtime_dir in ("ubuntu12_32", "ubuntu12_64"):
library_path = steam_path / runtime_dir
if library_path.is_dir():
runtime_paths.append(str(library_path))
return runtime_paths
def get_steam_launch_commands(appid: str) -> list[list[str]]:
"""Return Steam launch commands based on detected Steam data dir."""
steam_home = get_steam_home()
+73 -30
View File
@@ -59,6 +59,7 @@ def _format_download_size(size_mib: float) -> str:
if TYPE_CHECKING:
from PySide6.QtWidgets import QMainWindow
from portprotonqt.context_menu_manager import ContextMenuManager
_MainWindowTypingBase = QMainWindow
else:
@@ -204,6 +205,35 @@ class GOGRepairWorker(QThread):
self.error = str(error)
class GOGDeleteWorker(QThread):
"""Delete a GOG installation outside the UI thread."""
def __init__(self, api: GOGAPI, game: dict, menu_manager: "ContextMenuManager") -> None:
super().__init__()
self.api = api
self.game = game
self.menu_manager = menu_manager
self.error = ""
def run(self) -> None:
app_id = str(self.game["app_id"])
try:
install_path = self.api.get_installed_path(app_id)
if install_path is None:
raise FileNotFoundError(f"GOG installation not found: {app_id}")
shutil.rmtree(install_path)
manifest_path = self.api.config_dir / "heroic_gogdl" / "manifests" / app_id
try:
manifest_path.unlink(missing_ok=True)
except OSError as error:
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
self.api.remove_installed_game(app_id)
self.menu_manager.remove_gog_shortcuts(str(self.game["title"]))
except Exception as error:
logger.exception("Failed to delete GOG game %s", app_id)
self.error = str(error)
class StoreDLCWorker(QThread):
"""Read owned, installable store DLC outside the UI thread."""
@@ -324,6 +354,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
self.gog_process = None
self.egs_process = None
self.gog_repair_worker = None
self.gog_delete_worker = None
self.egs_install_importing = False
self.store_download_queue = []
self.gog_download_output = ""
@@ -1051,38 +1082,41 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
if install_path is None:
self.gogAccountStatus.setText(_("GOG installation not found"))
return
message_box = QMessageBox(self)
message_box.setIcon(QMessageBox.Icon.Question)
message_box.setWindowTitle(_("Confirm Deletion"))
message_box.setText(
_("Delete '{0}' and all files in its installation folder?").format(
game["title"]
if getattr(self, "gog_delete_worker", None) is not None:
self.gogAccountStatus.setText(_("Another GOG operation is already running"))
return
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
message_box = QMessageBox(self)
message_box.setIcon(QMessageBox.Icon.Question)
message_box.setWindowTitle(_("Confirm Deletion"))
message_box.setText(
_("Delete '{0}' and all files in its installation folder?").format(
game["title"]
)
)
)
message_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
message_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
worker = GOGDeleteWorker(self.gog_api, game, self.context_menu_manager)
worker.finished.connect(self._on_gog_delete_finished)
self.gog_delete_worker = worker
worker.start()
def _on_gog_delete_finished(self) -> None:
worker = self.gog_delete_worker
if worker is None:
return
try:
shutil.rmtree(install_path)
except OSError as error:
logger.error("Failed to delete GOG game %s: %s", app_id, error)
self.gogAccountStatus.setText(str(error))
return
manifest_path = (
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
)
try:
manifest_path.unlink(missing_ok=True)
except OSError as error:
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
self.gog_api.remove_installed_game(app_id)
self.context_menu_manager.remove_gog_shortcuts(str(game["title"]))
self.loadGames(force_load=True)
self.gog_delete_worker = None
if worker.error:
self.gogAccountStatus.setText(worker.error)
else:
self.loadGames(force_load=True)
worker.deleteLater()
def _start_gog_download(
self, game: dict, install_path: Path, command: list[str], action: str
@@ -1104,6 +1138,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
process.setArguments(command[1:])
process.setProcessEnvironment(self._gog_process_environment())
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
process.readyReadStandardOutput.connect(self._read_gog_download_output)
process.finished.connect(self._on_gog_download_finished)
self.gog_process = process
@@ -1137,6 +1172,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
process.setArguments(command[1:])
process.setProcessEnvironment(self._egs_process_environment())
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
process.readyReadStandardOutput.connect(self._read_egs_download_output)
process.finished.connect(self._on_egs_download_finished)
self.egs_process = process
@@ -1170,6 +1206,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
process.setArguments(command[1:])
process.setProcessEnvironment(self._egs_process_environment())
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
process.readyReadStandardOutput.connect(self._read_egs_download_output)
process.finished.connect(
lambda code, _status: self._on_egs_operation_finished(
@@ -1185,6 +1222,12 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
self.switchTab(6)
process.start()
def _handle_store_process_error(self, process: QProcess, error: QProcess.ProcessError) -> None:
if error != QProcess.ProcessError.FailedToStart:
return
logger.error("Failed to start store operation: %s", process.errorString())
process.finished.emit(os.EX_UNAVAILABLE, QProcess.ExitStatus.CrashExit)
def _cancel_gog_download(self) -> None:
process = self.gog_process or self.egs_process
if process is None:
+55 -8
View File
@@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
QHBoxLayout,
QLineEdit,
QMessageBox,
QProgressDialog,
QSizePolicy,
QToolButton,
QVBoxLayout,
@@ -42,6 +43,7 @@ from portprotonqt.logger import get_logger
from portprotonqt.scripts_utils.shortcut_tools import find_ext_ppdb
from portprotonqt.scripts_utils.prefix_backup import BACKUP_EXTENSION
from portprotonqt.steam_api import get_cached_steam_game_info, is_game_in_steam
from portprotonqt.tabs.theme_store_workers import THEME_ARCHIVE_EXTENSIONS, ThemeArchiveInstallWorker
from portprotonqt.time_utils import format_playtime
logger = get_logger(__name__)
@@ -604,18 +606,21 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
for url in event.mimeData().urls():
path = url.toLocalFile().lower()
if path.endswith(
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + WINE_ARCHIVE_EXTENSIONS
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + THEME_ARCHIVE_EXTENSIONS
):
event.acceptProposedAction()
return
event.ignore()
def dropEvent(self, event):
if any(worker.isRunning() for worker in getattr(self, "_themeArchiveWorkers", [])):
event.ignore()
return
wine_archives = []
for url in event.mimeData().urls():
path = url.toLocalFile()
path_lower = path.lower()
if path_lower.endswith(WINE_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
if path_lower.endswith(THEME_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
wine_archives.append(path)
continue
if path_lower.endswith(BACKUP_EXTENSION):
@@ -631,14 +636,56 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
event.acceptProposedAction()
break
if wine_archives:
show_proton_manager(
self,
self.portproton_location,
input_manager=self.input_manager,
local_archives=wine_archives,
)
self._install_dropped_theme_archives(wine_archives)
event.acceptProposedAction()
def _install_dropped_theme_archives(self, paths: list[str]) -> None:
progress = QProgressDialog(_("Installing…"), "", 0, 0, self)
progress.setWindowTitle(_("Theme"))
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setCancelButton(None)
progress.setMinimumDuration(0)
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
worker = ThemeArchiveInstallWorker(paths)
workers = getattr(self, "_themeArchiveWorkers", [])
self._themeArchiveWorkers = workers
workers.append(worker)
worker.installed.connect(self._on_dropped_theme_installed)
worker.failed.connect(progress.close)
worker.failed.connect(self._on_dropped_theme_failed)
worker.wine_archives.connect(progress.close)
worker.wine_archives.connect(self._on_dropped_wine_archives)
worker.finished.connect(progress.close)
worker.finished.connect(progress.deleteLater)
worker.finished.connect(lambda: workers.remove(worker))
worker.finished.connect(worker.deleteLater)
progress.show()
worker.start()
def _on_dropped_theme_installed(self, theme_names: list[str]) -> None:
for theme_name in theme_names:
self.theme_manager.invalidate_theme(theme_name)
variant = ui_config.get_theme_variant()
theme_name = ui_config.resolve_theme(theme_names[0], variant)
if theme_name not in theme_names:
theme_name = theme_names[0]
self._refresh_theme_combo(theme_name)
self._apply_theme_and_restart(theme_name, variant)
def _on_dropped_theme_failed(self, message: str) -> None:
logger.warning("Failed to install dropped theme: %s", message)
QMessageBox.warning(self, _("Error"), _("Failed to install theme"))
def _on_dropped_wine_archives(self, paths: list[str]) -> None:
wine_paths = [path for path in paths if path.lower().endswith(WINE_ARCHIVE_EXTENSIONS)]
if len(wine_paths) != len(paths):
self._on_dropped_theme_failed("No themes found in archive")
if wine_paths:
show_proton_manager(
self, self.portproton_location,
input_manager=self.input_manager, local_archives=wine_paths,
)
def openAddGameDialog(self, exe_path=None):
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
self.current_add_game_dialog.activateWindow()
+30
View File
@@ -508,6 +508,33 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
crash_reports_enabled_layout.addStretch()
uiForm.addRow(crash_reports_enabled_layout)
self.disableCyrillicPathCheckCheckBox = QCheckBox()
self.disableCyrillicPathCheckCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.disableCyrillicPathCheckCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
disable_cyrillic_path_check_title = QLabel(
_("Disable Cyrillic path warning")
)
disable_cyrillic_path_check_title.setSizePolicy(
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
)
disable_cyrillic_path_check_title.setStyleSheet(
self.theme.SETTINGS_TITLE_CHECKBOX_STYLE
)
disable_cyrillic_path_check_title.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.disableCyrillicPathCheckCheckBox.setChecked(
ui_config.get_disable_cyrillic_path_check()
)
disable_cyrillic_path_check_layout = QHBoxLayout()
disable_cyrillic_path_check_layout.setContentsMargins(0, 0, 0, 0)
disable_cyrillic_path_check_layout.addWidget(
self.disableCyrillicPathCheckCheckBox
)
disable_cyrillic_path_check_layout.addWidget(
disable_cyrillic_path_check_title
)
disable_cyrillic_path_check_layout.addStretch()
uiForm.addRow(disable_cyrillic_path_check_layout)
self.disableAltDependencyCheckCheckBox: QCheckBox | None = None
if self._is_alt_x86_64():
self.disableAltDependencyCheckCheckBox = QCheckBox()
@@ -1195,6 +1222,9 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
ui_config.set_crash_reports_enabled(
self.crashReportsEnabledCheckBox.isChecked()
)
ui_config.set_disable_cyrillic_path_check(
self.disableCyrillicPathCheckCheckBox.isChecked()
)
if self.disableAltDependencyCheckCheckBox is not None:
ui_config.set_disable_alt_i586_dependency_check(
self.disableAltDependencyCheckCheckBox.isChecked()
+35
View File
@@ -21,6 +21,7 @@ THEME_STORE_IMAGE_WORKERS = 8
THEME_STORE_CARD_BATCH_SIZE = 20
THEME_STORE_VOTES_ICON = "★"
THEME_STORE_DOWNLOADS_ICON = "⇩"
THEME_ARCHIVE_EXTENSIONS = (".zip", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".tar.bz2")
def _theme_store_download_url(theme_id: int) -> str:
@@ -126,6 +127,40 @@ def _install_theme_archive(archive_path: str) -> list[str]:
return installed_names
class ThemeArchiveInstallWorker(QThread):
installed = Signal(list)
failed = Signal(str)
wine_archives = Signal(list)
def __init__(self, paths: list[str]) -> None:
super().__init__()
self.paths = paths
def run(self) -> None:
wine_paths = []
for path in self.paths:
if self.isInterruptionRequested():
return
try:
if zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
else:
with tarfile.open(path) as archive:
names = archive.getnames()
if not any(name.rstrip("/").split("/")[-1] == "styles.py" for name in names):
wine_paths.append(path)
continue
installed = _install_theme_archive(path)
if not installed:
raise ValueError("No themes found in archive")
self.installed.emit(installed)
except (OSError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error:
self.failed.emit(str(error))
if wine_paths and not self.isInterruptionRequested():
self.wine_archives.emit(wine_paths)
class ThemeStoreListWorker(QThread):
loaded = Signal(list)
failed = Signal(str)
+14
View File
@@ -5,6 +5,17 @@ logger = get_logger(__name__)
class MainWindowWorkersMixin:
def _stopBackgroundWorkers(self) -> None:
context_menu_manager = getattr(self, "context_menu_manager", None)
if context_menu_manager is not None:
context_menu_manager.stop_card_actions()
self.store_download_queue = []
for process_name in ("gog_process", "egs_process"):
process = getattr(self, process_name, None)
if process is not None:
process.terminate()
if not process.waitForFinished():
process.kill()
process.waitForFinished()
worker_timeouts = {
"networkWorker": 3000,
"bluetoothWorker": 12000,
@@ -22,6 +33,7 @@ class MainWindowWorkersMixin:
"gog_library_worker": None,
"gog_metadata_worker": None,
"gog_repair_worker": None,
"gog_delete_worker": None,
"store_dlc_worker": None,
"gog_auth_worker": None,
"gog_account_worker": None,
@@ -38,6 +50,8 @@ class MainWindowWorkersMixin:
"_listWorkerPool",
"_imageWorkerPool",
"_detailImageWorkerPool",
"_themeArchiveWorkers",
"_gameFolderWorkers",
):
self._stopWorkerThreads(workers_name)
downloader = getattr(self, "downloader", None)
@@ -366,3 +366,5 @@ GAME_CARD_ANIMATION = {
WINETRICKS_CHECK_COLUMN_WIDTH = 80
WINETRICKS_ROW_HEIGHT = 36
GAME_FOLDER_IMPORT_DIALOG_SIZE = (800, 600)
+13
View File
@@ -141,6 +141,14 @@ def test_crash_reports_config_defaults_to_enabled(tmp_path: Path):
assert config.get_crash_reports_enabled() is False
def test_cyrillic_path_check_config_defaults_to_enabled(tmp_path: Path):
config = UIConfig(config_file=tmp_path / "test.conf")
assert config.get_disable_cyrillic_path_check() is False
config.set_disable_cyrillic_path_check(True)
assert config.get_disable_cyrillic_path_check() is True
def test_alt_dependency_check_config_defaults_to_enabled(tmp_path: Path):
config = UIConfig(config_file=tmp_path / "test.conf")
@@ -188,6 +196,11 @@ def test_horizontal_card_orientation_uses_theme_until_overridden(tmp_path: Path)
@pytest.mark.parametrize("system_locale,force_english,expected", [
("ru_RU", False, "ru_RU"),
(None, False, "en"),
("C", False, "en"),
("c", False, "en"),
("C.UTF-8", False, "en"),
("POSIX", False, "en"),
("unknown", False, "en"),
("ru_RU", True, "en"),
])
def test_system_locale(
+2 -5
View File
@@ -11,10 +11,6 @@ class TestCacheManagerInit:
CacheManager(cache_dir=cache_dir)
assert cache_dir.exists()
def test_default_cache_dir(self):
mgr = CacheManager()
assert mgr.cache_dir.exists()
class TestGetCacheFilePath:
def test_sanitizes_name(self, tmp_path: Path):
@@ -56,7 +52,8 @@ class TestSaveLoadJson:
def test_pretty_print(self, tmp_path: Path):
mgr = CacheManager(cache_dir=tmp_path)
mgr.save_json("test", {"a": 1}, pretty=True)
assert mgr.save_json("test", {"a": 1}, pretty=True)
assert mgr._get_cache_file_path("test").read_text() == '{\n "a": 1\n}'
loaded = mgr.load_json("test")
assert loaded == {"a": 1}
+32 -8
View File
@@ -16,8 +16,6 @@ from portprotonqt.cli import (
parse_portprotonqt_theme_url,
parse_resolution,
LAUNCH_FILE_EXTENSIONS,
PP_EXTENSIONS,
PREFIX_BACKUP_EXTENSION,
parse_args,
)
@@ -152,6 +150,38 @@ def test_silent_exe_uses_existing_instance(
startup.qt_app.exec.assert_not_called()
@pytest.mark.parametrize("silent", [False, True])
def test_silent_launch_skips_startup_workers(
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, silent: bool,
) -> None:
from PySide6.QtCore import QThread
from portprotonqt import appimage_updater
startup = silent_startup
window = MagicMock()
startup.window.side_effect = None
startup.window.return_value = window
monkeypatch.setattr(startup.app, "QTimer", MagicMock())
monkeypatch.setattr(QThread, "start", MagicMock())
update_worker = MagicMock()
monkeypatch.setattr(appimage_updater, "AppImageUpdateWorker", update_worker)
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
monkeypatch.setattr(startup.app.display_config, "set_fullscreen", MagicMock())
flags = ["--silent"] if silent else []
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(startup.exe)])
with pytest.raises(SystemExit) as result:
startup.app.main()
assert result.value.code == 0
assert window.silent_launch_mode is silent
assert ("initialCommandWorker" in vars(window)) is (not silent)
assert update_worker.called is (not silent)
assert ("appimageUpdateWorker" in vars(window)) is (not silent)
startup.window.assert_called_once()
@pytest.mark.parametrize("flags,target", [
([], "exe"),
(["--silent", "--log"], "exe"),
@@ -260,9 +290,6 @@ class TestIsPrefixBackupFile:
def test_nonexistent_file(self):
assert is_prefix_backup_file("/nonexistent/backup.ppack") is False
def test_ppack_extension_constant(self):
assert PREFIX_BACKUP_EXTENSION == ".ppack"
class TestIsAutoinstallFile:
def test_ppai_file(self, tmp_path: Path):
@@ -275,9 +302,6 @@ class TestIsAutoinstallFile:
txt.touch()
assert is_autoinstall_file(str(txt)) is False
def test_ppai_extension_constant(self):
assert PP_EXTENSIONS == (".ppack", ".ppai")
class TestParsePortprotonUrl:
def test_https_url(self):
+141
View File
@@ -590,3 +590,144 @@ def test_non_actionable_findings_do_not_create_fixes() -> None:
)
assert suggestions == []
@mark.parametrize("case", [
(True, False, False), (True, True, False), (False, True, True), (False, False, True),
])
def test_report_ppdb_lookup_conditions(
tmp_path: Path, monkeypatch: MonkeyPatch, case: tuple[bool, bool, bool],
) -> None:
from unittest.mock import MagicMock
from portprotonqt.dialogs import compatibility_report as dialog_module
automatic, existing, lookup = case
executable = tmp_path / "game.exe"
executable.touch()
if existing:
executable.with_suffix(".exe.ppdb").touch()
monkeypatch.setattr(dialog_module.ui_config, "get_auto_download_ppdb", lambda: automatic)
network = MagicMock()
monkeypatch.setattr(dialog_module, "QNetworkAccessManager", network)
monkeypatch.setattr(dialog_module, "QNetworkDiskCache", MagicMock())
monkeypatch.setattr(dialog_module.proxy_config, "get_proxy", lambda: {})
dialog = MagicMock()
dialog_module.CompatibilityReportDialog.check_ppdb(dialog, str(executable))
assert network.called is lookup
if lookup:
assert network.return_value.get.call_args.args[0].url().toString().endswith("/game.exe")
@mark.parametrize("metadata,visible", [
(b'{"ppdb_url":"https://linux-gaming.ru/ppdb/download"}', True),
(b'{"ppdb_url":""}', False), (b'{}', False), (b'[]', False),
(b'invalid', False), (b'{"ppdb_url":"file:///tmp/settings"}', False),
])
def test_report_ppdb_button_requires_download_url(metadata: bytes, visible: bool) -> None:
from unittest.mock import MagicMock
from PySide6.QtCore import QByteArray
from PySide6.QtNetwork import QNetworkReply
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
dialog = MagicMock()
reply = MagicMock()
reply.error.return_value = QNetworkReply.NetworkError.NoError
reply.readAll.return_value = QByteArray(metadata)
CompatibilityReportDialog._ppdb_lookup_finished(dialog, reply)
assert dialog.ppdb_button.show.called is visible
reply.deleteLater.assert_called_once()
@mark.parametrize("existing,failed", [
(False, False), (True, False), (False, True), (True, True),
])
def test_report_ppdb_download_replaces_existing_file_only_on_success(
tmp_path: Path, existing: bool, failed: bool,
) -> None:
from unittest.mock import MagicMock
from PySide6.QtCore import QByteArray
from PySide6.QtNetwork import QNetworkReply
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
path = tmp_path / "game.exe.ppdb"
if existing:
path.write_bytes(b"user settings")
dialog = MagicMock(ppdb_path=str(path))
reply = MagicMock()
reply.error.return_value = (QNetworkReply.NetworkError.ConnectionRefusedError
if failed else QNetworkReply.NetworkError.NoError)
reply.readAll.return_value = QByteArray(b"downloaded settings")
CompatibilityReportDialog._ppdb_download_finished(dialog, reply)
if failed:
if existing:
assert path.read_bytes() == b"user settings"
else:
assert not path.exists()
dialog.ppdb_button.setEnabled.assert_called_with(True)
else:
assert path.read_bytes() == b"downloaded settings"
dialog.ppdb_button.setEnabled.assert_called_with(False)
def test_report_close_releases_pending_ppdb_network(
tmp_path: Path, monkeypatch: MonkeyPatch,
) -> None:
from unittest.mock import MagicMock
from PySide6.QtCore import QCoreApplication, QEvent
from PySide6.QtWidgets import QApplication
from shiboken6 import isValid
from portprotonqt.dialogs import compatibility_report as dialog_module
app = QApplication.instance() or QApplication([])
theme = SimpleNamespace(
COMPATIBILITY_REPORT_DIALOG={
"minimum_width": 400, "minimum_height": 300, "margin": 10, "spacing": 10,
},
MAIN_WINDOW_STYLE="", COMPATIBILITY_REPORT_DIALOG_STYLE="", SCROLL_STYLE="",
ACTION_BUTTON_STYLE="", ACTION_BUTTON_ACTIVE_STYLE="",
)
executable = tmp_path / "game.exe"
executable.touch()
monkeypatch.setattr(dialog_module, "CACHE_DIR", tmp_path / "cache")
monkeypatch.setattr(dialog_module.ui_config, "get_auto_download_ppdb", lambda: False)
monkeypatch.setattr(dialog_module.proxy_config, "get_proxy", lambda: {})
get = MagicMock()
monkeypatch.setattr(dialog_module.QNetworkAccessManager, "get", get)
dialog = dialog_module.CompatibilityReportDialog(None, theme, "report")
dialog.check_ppdb(str(executable))
manager = dialog.ppdb_network
assert manager.parent() is dialog
assert dialog.ppdb_button.isHidden()
dialog.reject()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert not isValid(manager)
assert app is not None
def test_report_ppdb_redownload_bypasses_cache(tmp_path: Path) -> None:
from unittest.mock import MagicMock
from PySide6.QtCore import QUrl
from PySide6.QtNetwork import QNetworkRequest
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
path = tmp_path / "game.exe.ppdb"
path.write_bytes(b"old settings")
dialog = MagicMock(ppdb_path=str(path), ppdb_url=QUrl("https://linux-gaming.ru/ppdb"))
CompatibilityReportDialog._download_ppdb(dialog)
request = dialog.ppdb_network.get.call_args.args[0]
assert request.url() == dialog.ppdb_url
assert request.attribute(QNetworkRequest.Attribute.CacheLoadControlAttribute) == (
QNetworkRequest.CacheLoadControl.AlwaysNetwork
)
dialog.ppdb_button.setEnabled.assert_called_with(False)
assert path.read_bytes() == b"old settings"
+67 -74
View File
@@ -1,27 +1,13 @@
"""Tests for scripts_utils/dbus_tools.py — D-Bus helpers, notifications, power profiles."""
import asyncio
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from dbus_fast import DBusError, Message, Variant
from dbus_fast import BusType, DBusError, Message, Variant
from portprotonqt.scripts_utils.dbus_tools import (
ACTIVE_PROFILE,
DBUS_TIMEOUT,
NOTIFICATIONS_BUS_NAME,
NOTIFICATIONS_BUS_PATH,
PORTAL_BUS_NAME,
PORTAL_BUS_PATH,
PORTAL_IDLE_FLAG,
PORTAL_INTERFACE,
PORTAL_NOTIFICATION_INTERFACE,
PORTAL_REQUEST_INTERFACE,
POWER_PROFILE_ENDPOINTS,
PROFILES,
PROPERTIES_INTERFACE,
SCREENSAVER_BUS_NAME,
SCREENSAVER_BUS_PATH,
NotificationRequest,
_active_power_profile,
_bus_call,
@@ -48,21 +34,6 @@ DUMMY_MSG = Message(
)
class TestNotificationRequest:
def test_construction(self):
req = NotificationRequest("app", "icon", "title", "body", 5000)
assert req.app == "app"
assert req.icon == "icon"
assert req.title == "title"
assert req.body == "body"
assert req.timeout == 5000
def test_frozen(self):
req = NotificationRequest("a", "i", "t", "b", 1)
with pytest.raises(AttributeError):
req.app = "x" # type: ignore[misc]
class TestSerializeIcon:
def test_file_path_with_slash(self):
result = _serialize_icon("/usr/share/icon.png")
@@ -213,11 +184,12 @@ class TestDbusCall:
result = _run_async(dbus_call(coro()))
assert result == "ok"
def test_timeout(self):
async def slow():
await asyncio.sleep(100)
def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None:
async def slow() -> None:
await asyncio.sleep(0.05)
monkeypatch.setattr("portprotonqt.scripts_utils.dbus_tools.DBUS_TIMEOUT", 0.01)
with pytest.raises(asyncio.TimeoutError):
_run_async(asyncio.wait_for(slow(), timeout=0.01))
_run_async(dbus_call(slow()))
class TestBusCall:
@@ -365,6 +337,66 @@ class TestRequestIdleInhibit:
assert bus is not None
@pytest.mark.parametrize("session_available", [True, False])
def test_idle_inhibit_falls_back_to_logind(session_available: bool) -> None:
session = MagicMock()
system = MagicMock()
session.connect = AsyncMock(return_value=session)
if not session_available:
session.connect.side_effect = OSError("no session bus")
system.connect = AsyncMock(return_value=system)
system.call = AsyncMock(return_value=Message.new_method_return(
Message(destination="a.b", path="/a", member="m", serial=1),
signature="h", body=[0], unix_fds=[42],
))
with (
patch("portprotonqt.scripts_utils.dbus_tools.MessageBus", side_effect=[session, system]) as factory,
patch("portprotonqt.scripts_utils.dbus_tools._request_screensaver_inhibit", new_callable=AsyncMock) as screensaver,
patch("portprotonqt.scripts_utils.dbus_tools._request_portal_inhibit", new_callable=AsyncMock) as portal,
):
screensaver.side_effect = RuntimeError("no screensaver")
portal.side_effect = RuntimeError("no portal")
bus, kind, payload = _run_async(request_idle_inhibit("app", "reason"))
assert (bus, kind, payload) == (system, "logind", 42)
factory.assert_called_with(bus_type=BusType.SYSTEM, negotiate_unix_fd=True)
assert session.disconnect.call_count == int(session_available)
message = system.call.call_args.args[0]
assert message.destination == "org.freedesktop.login1"
assert message.path == "/org/freedesktop/login1"
assert message.interface == "org.freedesktop.login1.Manager"
assert message.member == "Inhibit"
assert message.signature == "ssss"
assert message.body == ["sleep:idle", "app", "reason", "block"]
system.disconnect.assert_not_called()
def test_idle_inhibit_disconnects_when_logind_fails() -> None:
session = MagicMock()
session.connect = AsyncMock(side_effect=OSError("no session bus"))
system = MagicMock()
system.connect = AsyncMock(return_value=system)
system.call = AsyncMock(return_value=Message.new_error(
Message(destination="a.b", path="/a", member="m", serial=1),
"org.freedesktop.DBus.Error.AccessDenied", "denied",
))
with patch("portprotonqt.scripts_utils.dbus_tools.MessageBus", side_effect=[session, system]):
with pytest.raises(DBusError, match="denied"):
_run_async(request_idle_inhibit("app", "reason"))
system.disconnect.assert_called_once()
def test_release_logind_closes_inhibitor() -> None:
bus = MagicMock()
read_fd, write_fd = os.pipe()
try:
_run_async(release_idle_inhibit(bus, "logind", write_fd))
assert os.read(read_fd, 1) == b""
bus.disconnect.assert_called_once()
finally:
os.close(read_fd)
class TestReleaseIdleInhibit:
def test_release_screensaver(self):
async def run():
@@ -583,42 +615,3 @@ class TestMain:
with patch("sys.argv", ["dbus_tools"]):
with pytest.raises(SystemExit):
main()
class TestConstants:
def test_power_profile_endpoints_count(self):
assert len(POWER_PROFILE_ENDPOINTS) == 2
def test_power_profile_endpoints_format(self):
for name, path in POWER_PROFILE_ENDPOINTS:
assert isinstance(name, str)
assert isinstance(path, str)
assert path.startswith("/")
def test_bus_timeout(self):
assert DBUS_TIMEOUT == 10
def test_portal_idle_flag(self):
assert PORTAL_IDLE_FLAG == 8
def test_screensaver_bus(self):
assert SCREENSAVER_BUS_NAME == "org.freedesktop.ScreenSaver"
assert SCREENSAVER_BUS_PATH == "/org/freedesktop/ScreenSaver"
def test_portal_bus(self):
assert PORTAL_BUS_NAME == "org.freedesktop.portal.Desktop"
assert PORTAL_BUS_PATH == "/org/freedesktop/portal/desktop"
def test_notification_bus(self):
assert NOTIFICATIONS_BUS_NAME == "org.freedesktop.Notifications"
assert NOTIFICATIONS_BUS_PATH == "/org/freedesktop/Notifications"
def test_interfaces(self):
assert PORTAL_INTERFACE == "org.freedesktop.portal.Inhibit"
assert PORTAL_NOTIFICATION_INTERFACE == "org.freedesktop.portal.Notification"
assert PORTAL_REQUEST_INTERFACE == "org.freedesktop.portal.Request"
assert PROPERTIES_INTERFACE == "org.freedesktop.DBus.Properties"
def test_profile_constants(self):
assert ACTIVE_PROFILE == "ActiveProfile"
assert PROFILES == "Profiles"
+9 -6
View File
@@ -12,6 +12,9 @@ from PySide6.QtWidgets import QApplication
from portprotonqt.downloader import Downloader
from portprotonqt.tabs.workers import MainWindowWorkersMixin
THREAD_TIMEOUT_MS = 30000
SYNC_TIMEOUT_SECONDS = 30
@pytest.fixture(autouse=True)
def application() -> QCoreApplication:
@@ -76,7 +79,7 @@ def test_missing_destination_still_downloads_with_a_worker(
assert worker is not None
assert worker in downloader._active_threads
assert worker.wait(5000)
assert worker.wait(THREAD_TIMEOUT_MS)
assert results == []
QApplication.processEvents()
assert results == [str(target)]
@@ -124,11 +127,11 @@ def test_download_started_from_a_worker_cleans_up_on_the_owner_thread(
caller = threading.Thread(target=start_download)
caller.start()
caller.join(timeout=5)
caller.join(timeout=SYNC_TIMEOUT_SECONDS)
assert not caller.is_alive()
worker = workers[0]
assert worker is not None
assert worker.wait(5000)
assert worker.wait(THREAD_TIMEOUT_MS)
QApplication.processEvents()
assert results == [(path, QThread.currentThread())]
@@ -146,13 +149,13 @@ def test_shutdown_waits_for_an_active_download(
def download(*_args: Any) -> str:
entered.set()
assert release.wait(5)
assert release.wait(SYNC_TIMEOUT_SECONDS)
return path
monkeypatch.setattr("portprotonqt.downloader.download_with_cache", download)
worker = downloader.download_async("https://example.com/data.json", path)
assert worker is not None
assert entered.wait(5)
assert entered.wait(SYNC_TIMEOUT_SECONDS)
owner = cast(Any, MainWindowWorkersMixin())
owner.downloader = downloader
owner.compatibility_workers = []
@@ -171,4 +174,4 @@ def test_shutdown_waits_for_an_active_download(
finally:
release.set()
release_timer.join()
worker.wait(5000)
worker.wait(THREAD_TIMEOUT_MS)
+5 -2
View File
@@ -8,6 +8,8 @@ from PySide6.QtWidgets import QApplication
from portprotonqt.scripts_utils.easyterm import TerminalWidget
PROCESS_TIMEOUT_SECONDS = 30
def test_terminal_keeps_failed_command_output(
monkeypatch: pytest.MonkeyPatch,
@@ -25,9 +27,10 @@ def test_terminal_keeps_failed_command_output(
False,
)
deadline = time.monotonic() + 5
while terminal.child_pid is not None and time.monotonic() < deadline:
deadline = time.monotonic() + PROCESS_TIMEOUT_SECONDS
while terminal.exit_code is None and time.monotonic() < deadline:
application.processEvents()
terminal._check_child()
time.sleep(0.01)
assert terminal.exit_code == 7
+48
View File
@@ -0,0 +1,48 @@
"""Tests for PE metadata consumed by PortProton shell scripts."""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from portprotonqt.scripts_utils import exe_metadata
def test_reads_version_strings_and_closes_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
path = tmp_path / "Game.exe"
pe = MagicMock()
table = SimpleNamespace(entries={b"ProductName": b"Game", b"CompanyName": b"Sony"})
pe.__enter__.return_value.FileInfo = [[SimpleNamespace(StringTable=[table])]]
constructor = MagicMock(return_value=pe)
monkeypatch.setattr(exe_metadata.pefile, "PE", constructor)
assert exe_metadata.read_metadata(str(path)) == {"ProductName": "Game", "CompanyName": "Sony"}
constructor.assert_called_once_with(str(path), fast_load=True)
pe.__enter__.return_value.parse_data_directories.assert_called_once_with(
directories=[exe_metadata.pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]],
)
pe.__exit__.assert_called_once()
@pytest.mark.parametrize("error", [exe_metadata.pefile.PEFormatError("invalid"), OSError("unreadable")])
def test_unreadable_pe_allows_exiftool_fallback(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None:
monkeypatch.setattr(exe_metadata.pefile, "PE", MagicMock(side_effect=error))
assert exe_metadata.read_metadata("Game.exe") is None
def test_cli_preserves_shell_field_labels(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
monkeypatch.setattr(exe_metadata.sys, "argv", ["exe_metadata", "/Games/Game.exe"])
monkeypatch.setattr(exe_metadata, "read_metadata", lambda _path: {
"ProductName": "Game", "FileDescription": "Description\r\nextra", "CompanyName": "Sony",
})
assert exe_metadata.main() == 0
assert capsys.readouterr().out == (
"File Name: Game.exe\nProduct Name: Game\nFile Description: Description extra\nCompany Name: Sony\n"
)
def test_cli_returns_failure_for_unsupported_file(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(exe_metadata.sys, "argv", ["exe_metadata", "Game.exe"])
monkeypatch.setattr(exe_metadata, "read_metadata", lambda _path: None)
assert exe_metadata.main() == 1
+146 -6
View File
@@ -6,8 +6,9 @@ from typing import Any, cast
from unittest.mock import MagicMock
from PySide6.QtCore import QEvent, QObject, QStringListModel, Qt
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QWidget
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
from pytest import MonkeyPatch, raises, mark
from shiboken6 import delete
import portprotonqt.input_manager as input_manager
import portprotonqt.input_manager.buttons as input_buttons
@@ -35,6 +36,34 @@ HIDDEN_CARD_X = 20
NEXT_CARD_X = 40
@mark.parametrize("event_type", [QEvent.Type.Destroy, QEvent.Type.ChildRemoved, QEvent.Type.DeferredDelete])
def test_keyboard_filter_ignores_deleted_layout(event_type: QEvent.Type) -> None:
app = QApplication.instance() or QApplication([])
manager = InputManager.__new__(InputManager)
QObject.__init__(manager)
layout = QVBoxLayout()
delete(layout)
assert manager.eventFilter(layout, QEvent(event_type)) is False
assert app is not None
@mark.parametrize("event_type", [QEvent.Type.Destroy, QEvent.Type.ChildRemoved, QEvent.Type.DeferredDelete])
def test_keyboard_filter_teardown_does_not_lookup_application(
monkeypatch: MonkeyPatch, event_type: QEvent.Type,
) -> None:
app = QApplication.instance() or QApplication([])
manager = InputManager.__new__(InputManager)
QObject.__init__(manager)
watched = QObject()
lookup = MagicMock(side_effect=AssertionError("Application lookup during teardown"))
monkeypatch.setattr(QApplication, "instance", lookup)
assert manager.eventFilter(watched, QEvent(event_type)) is False
lookup.assert_not_called()
assert app is not None
@mark.parametrize("initial", [True, False])
def test_gamepad_auto_fullscreen_shows_boot_on_hotplug(monkeypatch: MonkeyPatch, initial: bool) -> None:
app = QApplication.instance() or QApplication([])
@@ -646,7 +675,10 @@ def test_confirm_button_opens_combo_popup() -> None:
assert app is not None
def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: MonkeyPatch) -> None:
@mark.parametrize("table_name", ["advanced_table", "favorites_table"])
def test_settings_gamepad_skips_separator_and_activates_download(
monkeypatch: MonkeyPatch, table_name: str,
) -> None:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
from portprotonqt.input_manager.constants import BUTTONS
from portprotonqt.input_manager.settings import SettingsInputMixin
@@ -672,7 +704,7 @@ def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: Mo
cast(ExeSettingsDialog, settings), index, combo, "PW_WINE_USE",
))
manager = SimpleNamespace(
settings_dialog=SimpleNamespace(advanced_table=table, settings_table=None, favorites_table=None),
settings_dialog=SimpleNamespace(advanced_table=None, settings_table=None, favorites_table=None),
_is_current_settings_tool_tab=lambda: False,
_handle_table_action_navigation=lambda *_args: False,
_get_dialog_action_buttons=lambda _dialog: [],
@@ -680,13 +712,13 @@ def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: Mo
_get_open_settings_combo=lambda: combo,
_handle_common_ui_elements=lambda _code: False,
)
setattr(manager.settings_dialog, table_name, table)
dialog.show()
combo.showPopup()
app.processEvents()
SettingsInputMixin.handle_settings_dpad(cast(SettingsInputMixin, manager), PAD_DPAD_Y, 1, 0)
assert combo.view().currentIndex().row() == 2
assert highlighted[-1] == 2
assert combo.currentData() == "download_wine"
assert combo.currentText() == "Proton"
SettingsInputMixin.handle_settings_button(
cast(SettingsInputMixin, manager), next(iter(BUTTONS["confirm"])), 1,
)
@@ -725,7 +757,7 @@ def test_settings_combo_navigation_with_keyboard_events_intercepted() -> None:
app.processEvents()
for direction, expected in ((1, 1), (1, 2), (1, 2), (-1, 1), (-1, 0), (-1, 0)):
SettingsInputMixin.handle_settings_dpad(cast(SettingsInputMixin, manager), PAD_DPAD_Y, direction, 0)
assert combo.currentIndex() == expected
assert combo.currentIndex() == 0
assert combo.view().currentIndex().row() == expected
assert combo.view().isVisible()
finally:
@@ -1427,3 +1459,111 @@ def test_settings_tool_gamepad_reaches_action_buttons(tool: str, monkeypatch: Mo
manager.handle_settings_dpad(PAD_DPAD_Y, -1, 0)
assert QApplication.focusWidget() is control
dialog.close()
def test_confirm_in_dialog_uses_its_own_keyboard() -> None:
app = QApplication.instance() or QApplication([])
dialog = cast(Any, QDialog())
field = QLineEdit(dialog)
shown_for = []
dialog.keyboard = SimpleNamespace(show_for_widget=shown_for.append)
main_keyboard = MagicMock()
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=main_keyboard))
confirm = next(iter(input_manager.BUTTONS["confirm"]))
assert manager._route_button_to_text_input(dialog, field, confirm)
assert shown_for == [field]
main_keyboard.show_for_widget.assert_not_called()
assert app is not None
def test_dpad_stays_on_active_dialog_keyboard(monkeypatch: MonkeyPatch) -> None:
app = QApplication.instance() or QApplication([])
dialog = cast(Any, QDialog())
field = QLineEdit(dialog)
key = QPushButton("Key", dialog)
key.move(0, 40)
down = MagicMock(side_effect=key.setFocus)
dialog.keyboard = SimpleNamespace(isVisible=lambda: True, move_focus_left=MagicMock(), move_focus_right=MagicMock(),
move_focus_up=MagicMock(), move_focus_down=down)
dialog.show()
dialog.activateWindow()
app.processEvents()
field.setFocus()
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None, gamesListWidget=QWidget()))
manager.axis_moving = False
manager._gamepad_handling_enabled = True
navigation = MagicMock()
monkeypatch.setattr(manager, "_start_dpad_repeat", MagicMock())
monkeypatch.setattr(manager, "_route_dpad_navigation", navigation)
monkeypatch.setattr(QApplication, "activeWindow", lambda: dialog)
manager._handle_default_dpad(PAD_DPAD_Y, 1, 0.0)
down.assert_called_once()
navigation.assert_not_called()
assert QApplication.focusWidget() is key
dialog.close()
@mark.parametrize("mode", ["import", "exe", "directory"])
def test_file_picker_x_does_not_put_folder_in_executable_field(
tmp_path: Any, monkeypatch: MonkeyPatch, mode: str,
) -> None:
from PySide6.QtWidgets import QListWidget
from portprotonqt.dialogs.base import FileSelectedSignal
app = QApplication.instance() or QApplication([])
folder = tmp_path / "Games"
folder.mkdir()
picker = cast(Any, QDialog())
picker.current_path = str(tmp_path)
picker.directory_only = mode == "directory"
picker.file_list = QListWidget(picker)
picker.file_list.addItem("Games/")
picker.file_list.setCurrentRow(0)
picker.file_signal = FileSelectedSignal()
selected = []
picker.file_signal.file_selected.connect(selected.append)
action = QPushButton(picker)
if mode == "import":
action.setObjectName("gamepadAddButton")
clicked = MagicMock()
action.clicked.connect(clicked)
picker.show()
app.processEvents()
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None))
manager.file_explorer = picker
monkeypatch.setattr(QApplication, "activeWindow", lambda: picker)
monkeypatch.setattr(QApplication, "activePopupWidget", lambda: None)
manager.handle_file_explorer_button(next(iter(input_manager.BUTTONS["add_game"])), 1)
assert clicked.call_count == int(mode == "import")
assert selected == ([str(folder)] if mode == "directory" else [])
assert picker.result() == (QDialog.DialogCode.Accepted if mode == "directory" else QDialog.DialogCode.Rejected)
picker.close()
def test_x_edits_keyboard_without_triggering_dialog_action(monkeypatch: MonkeyPatch) -> None:
app = QApplication.instance() or QApplication([])
dialog = cast(Any, QDialog())
keyboard = QFrame(dialog)
dialog.keyboard = keyboard
action = QPushButton(dialog)
action.setObjectName("gamepadAddButton")
clicked = MagicMock()
action.clicked.connect(clicked)
dialog.show()
app.processEvents()
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None))
handled = MagicMock()
monkeypatch.setattr(manager, "_handle_common_ui_elements", lambda _button: False)
monkeypatch.setattr(manager, "handle_virtual_keyboard", handled)
monkeypatch.setattr(input_buttons, "VirtualKeyboard", QFrame)
monkeypatch.setattr(QApplication, "activeWindow", lambda: dialog)
button = next(iter(input_manager.BUTTONS["add_game"]))
manager._handle_default_button(button, 1)
handled.assert_called_once_with(button, 1)
clicked.assert_not_called()
dialog.close()
+109 -15
View File
@@ -1,8 +1,6 @@
"""Progressive library loading, drawing and input regressions."""
from collections.abc import Callable, Generator
from pathlib import Path
from time import perf_counter
import logging
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
@@ -17,6 +15,8 @@ from portprotonqt.game_card import GameCard
from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.theme_manager import load_theme
LOAD_TIMEOUT_MS = 30000
@pytest.fixture
def library_scene(
@@ -399,7 +399,7 @@ def real_cover_scene(
@pytest.mark.parametrize("animation_type", ["gradient", "glow"])
def test_real_cover_loading_and_first_paint_focus_latency(
def test_real_cover_loading_yields_and_focuses_first_paint(
real_cover_scene: tuple[GameLibraryManager, Path],
monkeypatch: pytest.MonkeyPatch, animation_type: str,
) -> None:
@@ -407,14 +407,14 @@ def test_real_cover_loading_and_first_paint_focus_latency(
monkeypatch.setattr(manager.theme, "GAME_CARD_ANIMATION",
{**manager.theme.GAME_CARD_ANIMATION, "card_animation_type": animation_type})
threads: list[bool] = []
paints: list[tuple[float, bool]] = []
ticks: list[float] = []
paints: list[bool] = []
ticks: list[tuple[int, int]] = []
original_paint = GameCard.paintEvent
original_cover = GameCard.on_cover_loaded
def record_paint(card: GameCard, event: Any) -> None:
if card.name == "Game 0":
paints.append((perf_counter(), card._focused or card._hovered))
paints.append(card._focused or card._hovered)
original_paint(card, event)
def record_cover(card: GameCard, pixmap: QPixmap) -> None:
@@ -422,7 +422,7 @@ def test_real_cover_loading_and_first_paint_focus_latency(
original_cover(card, pixmap)
def sample() -> None:
ticks.append(perf_counter())
ticks.append((len(manager.game_card_cache), len(threads)))
if len(threads) == len(games) and not manager._incremental_add_queue:
loop.quit()
@@ -434,18 +434,14 @@ def test_real_cover_loading_and_first_paint_focus_latency(
timer.setInterval(5)
timer.timeout.connect(sample)
timer.start()
QTimer.singleShot(2000, loop.quit)
QTimer.singleShot(LOAD_TIMEOUT_MS, loop.quit)
manager.set_games(games)
loop.exec()
timer.stop()
assert len(threads) == len(games) and all(threads), threads
highlighted = next((paint for paint in paints if paint[1]), None)
assert highlighted is not None
focus_delay = highlighted[0] - paints[0][0]
max_gap = max(after - before for before, after in zip(ticks, ticks[1:], strict=False))
logging.getLogger(__name__).info("%s: highlight %.1f ms, UI pause %.1f ms", animation_type, focus_delay * 1000, max_gap * 1000)
assert focus_delay < 0.05, paints
assert max_gap < 0.1, ticks
assert paints and paints[0], paints
assert any(0 < cards < len(games) for cards, _ in ticks), ticks
assert any(0 < covers < len(games) for _, covers in ticks), ticks
@pytest.mark.parametrize("scenario", [("grid", 0), ("grid", 1), ("vertical", 0), ("vertical", 1)])
@@ -545,3 +541,101 @@ def test_vertical_library_headers_align_with_values(
assert heading.alignment() == value.alignment() == Qt.AlignmentFlag.AlignCenter
assert abs(heading.mapToGlobal(heading.rect().center()).x()
- value.mapToGlobal(value.rect().center()).x()) <= 1
def test_control_click_toggles_card_without_opening_details(
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
) -> None:
manager, _ = library_scene
game = ("Game", "", "", "", "", "game.exe", "", "", "", "", 0, 0, "portproton")
card = manager._create_game_card(game)
position = card.rect().center().toPointF()
for selected in (True, False):
event = QMouseEvent(QEvent.Type.MouseButtonPress, position, position,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.ControlModifier)
card.mousePressEvent(event)
assert card.batch_selected is selected
cast(Any, manager.main_window).openGameDetailPage.assert_not_called()
@pytest.mark.parametrize("activation", ["mouse", "gamepad"])
@pytest.mark.parametrize("game_source", ["portproton", "gog", "egs"])
def test_selection_mode_toggles_without_opening_game(
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
monkeypatch: pytest.MonkeyPatch, activation: str, game_source: str,
) -> None:
from portprotonqt.context_menu_manager import ContextMenuManager
from portprotonqt.main_window import MainWindow
library, _ = library_scene
menu = object.__new__(ContextMenuManager)
menu.game_library_manager = library
game = ("Game", "", "", "", "", "missing.exe", "", "", "", "", 0, 0, game_source)
card = library._create_game_card(game)
card.context_menu_manager = menu
library.game_card_cache[(card.name, card.exec_line)] = card
monkeypatch.setattr(QApplication, "focusWidget", lambda: card)
menu.start_card_selection(card)
assert menu.card_selection_mode and card.batch_selected
for selected in (False, True):
if activation == "mouse":
position = card.rect().center().toPointF()
card.mousePressEvent(QMouseEvent(
QEvent.Type.MouseButtonPress, position, position,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
))
else:
MainWindow.activateFocusedWidget(cast(Any, library.main_window))
assert card.batch_selected is selected
cast(Any, library.main_window).openGameDetailPage.assert_not_called()
menu.clear_card_selection()
assert not menu.card_selection_mode and not card.batch_selected
@pytest.mark.parametrize("layout_mode", ["grid", "horizontal", "horizontal_top", "list", "vertical"])
def test_search_preserves_selection_across_different_queries(
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
monkeypatch: pytest.MonkeyPatch, layout_mode: str,
) -> None:
from portprotonqt.context_menu_manager import ContextMenuManager
library, _ = library_scene
monkeypatch.setattr("portprotonqt.config.ui_config.get_library_layout_mode", lambda _: layout_mode)
monkeypatch.setattr(library, "_incremental_batch_size", 1)
library.rebuild_library_layout(layout_mode)
games = [(name, "", "", "", "", name, "", "", "", "", 0, 0, "portproton")
for name in ("DOOM64", "Akalabeth of Doom", "Biomance")]
cast(Any, library.main_window)._loaded_library_cache = {"portproton": games}
library.set_games(games, focus_first_card=False)
assert library._update_timer is not None and library._incremental_add_timer is not None
library._update_timer.stop()
library._update_game_grid_immediate()
while library._incremental_add_queue:
library._process_incremental_add_batch()
library._incremental_add_timer.stop()
menu = object.__new__(ContextMenuManager)
menu.game_library_manager = library
cards = {card.name: card for card in library.game_card_cache.values()}
for card in cards.values():
card.context_menu_manager = menu
for query in ("doom", "bio", ""):
library.main_window.searchEdit.setText(query)
library.filter_games_delayed()
library._update_timer.stop()
library._update_game_grid_immediate()
while library._incremental_add_queue:
library._process_incremental_add_batch()
library._incremental_add_timer.stop()
if query == "doom":
menu.start_card_selection(cards["DOOM64"])
cards["Akalabeth of Doom"].click()
elif query == "bio":
cards["Biomance"].click()
assert not cards["DOOM64"].isVisible()
if query != "doom":
assert all(card.batch_selected for card in cards.values())
assert menu.card_selection_mode
menu.clear_card_selection()
assert not any(card.batch_selected for card in cards.values())
cast(Any, library.main_window).openGameDetailPage.assert_not_called()
+149 -8
View File
@@ -11,7 +11,7 @@ from typing import Any, cast
from unittest.mock import MagicMock
import psutil
from pytest import MonkeyPatch, fixture, mark
from pytest import MonkeyPatch, fixture, mark, raises
from PySide6.QtCore import QEvent, QEventLoop, QObject, QPoint, QPointF, Qt, QTimer
from PySide6.QtGui import QAction, QMouseEvent, QPixmap, QWheelEvent
from PySide6.QtWidgets import (
@@ -22,6 +22,7 @@ from PySide6.QtWidgets import (
QLabel,
QMenu,
QMainWindow,
QMessageBox,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
@@ -69,6 +70,125 @@ from portprotonqt.tabs.control_hints import MainWindowControlHintsMixin
import portprotonqt.localization as localization
@mark.parametrize("game_path,script_path,expected", [
("/games/Game.exe", "/opt/PortProton/start.sh", False),
("/games/Игра/Game.exe", "/opt/PortProton/start.sh", True),
("/games/Ґра.exe", "/opt/PortProton/start.sh", True),
("/games/Game.exe", "/home/Игрок/PortProton/start.sh", True),
("/games/日本語/Game.exe", "/opt/PortProton/start.sh", False),
])
def test_cyrillic_launch_warning(
monkeypatch: MonkeyPatch, game_path: str, script_path: str, expected: bool,
) -> None:
message_box = MagicMock()
message_box.addButton.return_value = object()
message_box_type = MagicMock(return_value=message_box)
message_box_type.Icon = QMessageBox.Icon
message_box_type.StandardButton = QMessageBox.StandardButton
message_box_type.ButtonRole = QMessageBox.ButtonRole
window = SimpleNamespace(start_sh=[script_path])
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
assert MainWindow._warn_cyrillic_launch_paths(cast(Any, window), game_path) is expected
assert message_box.exec.call_count == int(expected)
if expected:
assert (game_path if "games/" in game_path and game_path != "/games/Game.exe"
else script_path) in message_box.setText.call_args.args[0]
def test_cyrillic_launch_warning_resolves_symlinks(
monkeypatch: MonkeyPatch, tmp_path: Path,
) -> None:
target = tmp_path / "Игры"
target.mkdir()
link = tmp_path / "games"
link.symlink_to(target, target_is_directory=True)
message_box = MagicMock()
message_box.addButton.return_value = object()
message_box_type = MagicMock(return_value=message_box)
message_box_type.Icon = QMessageBox.Icon
message_box_type.StandardButton = QMessageBox.StandardButton
message_box_type.ButtonRole = QMessageBox.ButtonRole
window = SimpleNamespace(start_sh=[])
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
MainWindow._warn_cyrillic_launch_paths(cast(Any, window), str(link / "game.exe"))
message_box.exec.assert_called_once()
assert str(target / "game.exe") in message_box.setText.call_args.args[0]
def test_cyrillic_launch_warning_can_be_disabled(monkeypatch: MonkeyPatch) -> None:
set_disabled = MagicMock()
disable_button = object()
message_box = MagicMock()
message_box.addButton.return_value = disable_button
message_box.clickedButton.return_value = disable_button
message_box_type = MagicMock(return_value=message_box)
message_box_type.Icon = QMessageBox.Icon
message_box_type.StandardButton = QMessageBox.StandardButton
message_box_type.ButtonRole = QMessageBox.ButtonRole
window = SimpleNamespace(start_sh=[])
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
monkeypatch.setattr(main_window_module.ui_config, "set_disable_cyrillic_path_check", set_disabled)
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
MainWindow._warn_cyrillic_launch_paths(cast(Any, window), "/games/Игра.exe")
set_disabled.assert_called_once_with(True)
@mark.parametrize("source", ["local", "gog", "egs"])
def test_cyrillic_paths_continue_launch(
monkeypatch: MonkeyPatch, tmp_path: Path, source: str,
) -> None:
target = tmp_path / "Игры" / "Breach.exe"
target.parent.mkdir()
target.touch()
window: Any = MainWindow.__new__(MainWindow)
window.start_sh = ["/opt/PortProton/start.sh"]
window.game_processes = []
window.target_exe = None
window.current_play_button = None
window._check_alt_i586_dependencies_before_launch = lambda: True
window._check_missing_prefix_before_launch = MagicMock()
window._finish_silent_launch = MagicMock()
window._start_gog_support_setup = MagicMock()
api = SimpleNamespace(
ensure_launch_parameters=lambda _app_id: None,
get_installed_path=lambda _app_id: target.parent,
get_launch_target=lambda _app_id: str(target),
needs_support_setup=lambda _app_id: True,
build_command=lambda arguments: arguments,
get_environment=lambda: {},
)
window.gog_api = api
window.egs_api = api
warning = MagicMock(return_value=True)
popen = MagicMock(side_effect=RuntimeError("launch reached"))
window._warn_cyrillic_launch_paths = warning
monkeypatch.setattr(main_window_module.subprocess, "Popen", popen)
monkeypatch.setattr(main_window_module.SoundManager, "play", MagicMock())
monkeypatch.setattr(main_window_module.QMessageBox, "warning", MagicMock())
if source == "local":
window.toggleGame(shlex.quote(str(target)), game_name="Breach")
elif source == "egs":
with raises(RuntimeError, match="launch reached"):
window._launch_egs_game("123")
else:
window._launch_gog_game("123")
warning.assert_called_once()
if source == "gog":
window._start_gog_support_setup.assert_called_once_with("123", None)
else:
popen.assert_called_once()
@mark.parametrize("fullscreen, enabled", [(False, True), (True, True), (True, False)])
def test_fullscreen_cursor_hides_after_inactivity(monkeypatch: MonkeyPatch, fullscreen: bool, enabled: bool) -> None:
app = QApplication.instance() or QApplication([])
@@ -192,7 +312,7 @@ def _tile_theme() -> Any:
@mark.parametrize(
"worker_name",
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "store_dlc_worker"),
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
)
def test_shutdown_waits_for_store_library_workers(worker_name: str) -> None:
worker = MagicMock()
@@ -1158,6 +1278,7 @@ def test_game_card_click_uses_select_callback() -> None:
autoinstall_exe_name="",
select_callback=select_callback,
_get_missing_executable_path=lambda: "",
context_menu_manager=None,
)
GameCard.click(cast(Any, card))
@@ -1760,7 +1881,7 @@ def test_dxvk_incompatibility_reports_after_manual_stop(
reports = []
launch = SimpleNamespace(executable="/games/game.exe", duration=60.0)
window.portproton_location = "/portproton"
window.compatibility_report_ready = SimpleNamespace(emit=reports.append)
window.compatibility_report_ready = SimpleNamespace(emit=lambda *args: reports.append(args))
monkeypatch.setattr(
"portprotonqt.main_window.has_dxvk_vulkan_incompatibility",
lambda _path: True,
@@ -1772,7 +1893,7 @@ def test_dxvk_incompatibility_reports_after_manual_stop(
window._build_compatibility_report(launch, stopped_by_user=True)
assert reports == ["forced report"]
assert reports == [("forced report", launch.executable)]
def test_disabled_crash_reports_skip_launch_analysis(
monkeypatch: MonkeyPatch,
@@ -1841,7 +1962,7 @@ def test_nonzero_wine_exit_reports_after_long_game(monkeypatch: MonkeyPatch) ->
window: Any = MainWindow.__new__(MainWindow)
window.portproton_location = "/portproton"
reports = []
window.compatibility_report_ready = SimpleNamespace(emit=reports.append)
window.compatibility_report_ready = SimpleNamespace(emit=lambda *args: reports.append(args))
launch = main_window_module.CompatibilityLaunch("/games/Game.exe", 5, 120.0)
monkeypatch.setattr(main_window_module, "has_dxvk_vulkan_incompatibility", lambda _path: False)
monkeypatch.setattr(main_window_module.os.path, "isfile", lambda _path: True)
@@ -1851,7 +1972,7 @@ def test_nonzero_wine_exit_reports_after_long_game(monkeypatch: MonkeyPatch) ->
window._build_compatibility_report(launch, stopped_by_user=False)
window._build_compatibility_report(launch, stopped_by_user=True)
assert reports == ["crash report"]
assert reports == [("crash report", launch.executable)]
def test_update_prefix_log_does_not_mark_wine_launch_start() -> None:
window: Any = MainWindow.__new__(MainWindow)
@@ -1869,14 +1990,17 @@ def test_show_compatibility_report_uses_report_dialog(monkeypatch: MonkeyPatch)
def __init__(self, parent: object, theme: object, report: str) -> None:
calls.append((parent, theme, report))
def check_ppdb(self, executable: str) -> None:
calls.append(executable)
def exec(self) -> None:
calls.append("exec")
monkeypatch.setattr("portprotonqt.main_window.CompatibilityReportDialog", ReportDialog)
window._show_compatibility_report("report text")
window._show_compatibility_report("report text", "/games/Game.exe")
assert calls == [(window, window.theme, "report text"), "exec"]
assert calls == [(window, window.theme, "report text"), "/games/Game.exe", "exec"]
def test_refresh_theme_store_visibility_adds_store(monkeypatch: Any) -> None:
window: Any = MainWindow.__new__(MainWindow)
@@ -2987,3 +3111,20 @@ def test_autoinstall_table_columns_match_header(
- value.mapTo(window, value.rect().center()).x()) <= 1
window.stackedWidget.deleteLater()
window.deleteLater()
def test_shutdown_stops_batch_before_store_processes() -> None:
calls: list[str] = []
process = MagicMock()
process.terminate.side_effect = lambda: calls.append("terminate")
process.waitForFinished.side_effect = [False, True]
menu = SimpleNamespace(stop_card_actions=lambda: calls.append("stop_batch"))
window = SimpleNamespace(
context_menu_manager=menu, egs_process=process, store_download_queue=[("egs", {})],
_stopWorkerThread=MagicMock(), _stopWorkerThreads=MagicMock(),
)
MainWindowWorkersMixin._stopBackgroundWorkers(cast(Any, window))
assert calls == ["stop_batch", "terminate"]
process.kill.assert_called_once_with()
assert process.waitForFinished.call_count == 2
assert window.store_download_queue == []
-27
View File
@@ -94,33 +94,6 @@ class TestGetCurrentLauncherCommand:
assert result is None
# ── Launcher tail extraction ─────────────────────────────────────────────────
class TestExtractLauncherTail:
def test_appimage_with_silent(self):
parts = ["/path/PortProtonQt.AppImage", "--silent", "/game.exe"]
assert _extract_launcher_tail(parts) == ["/game.exe"]
def test_appimage_without_silent(self):
parts = ["/path/PortProtonQt.AppImage", "/game.exe"]
assert _extract_launcher_tail(parts) == ["/game.exe"]
def test_portprotonqt_with_silent(self):
parts = ["portprotonqt", "--silent", "/game.exe"]
assert _extract_launcher_tail(parts) == ["/game.exe"]
def test_start_sh_with_silent(self):
parts = ["start.sh", "--silent", "/game.exe"]
assert _extract_launcher_tail(parts) == ["/game.exe"]
def test_unknown_command(self):
parts = ["/usr/bin/wine64", "/game.exe"]
assert _extract_launcher_tail(parts) is None
def test_empty(self):
assert _extract_launcher_tail([]) is None
# ── Launcher line migration ──────────────────────────────────────────────────
class TestMigrateLauncherLine:
+34 -81
View File
@@ -29,11 +29,6 @@ def test_scripts_path_uses_xdg_data_dirs(
monkeypatch.setenv("XDG_DATA_DIRS", str(data_dir))
monkeypatch.delenv("APPDIR", raising=False)
monkeypatch.delenv("SHARUN_DIR", raising=False)
monkeypatch.delenv("APPIMAGE", raising=False)
is_file = Path.is_file
monkeypatch.setattr(
Path, "is_file", lambda path: path.is_relative_to(tmp_path) and is_file(path)
)
assert get_portproton_scripts_path() == str(scripts_dir)
@@ -215,7 +210,7 @@ def test_game_launch_marker_is_emitted_for_wine_and_proton() -> None:
)
marker = "printf '%s\\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'"
assert helper.count(marker) == 2
assert helper.count(marker) == 1
def test_user_conf_get_without_name_lists_active_values(tmp_path: Path) -> None:
@@ -279,94 +274,52 @@ def test_vk_gpu_info_uses_build_aux_binary() -> None:
assert "dev-scripts/vk_gpu_info" not in helper
def test_reshade_prefers_directx_for_unity() -> None:
def test_steam_uses_common_portwine_launch() -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
encoding="utf-8",
)
assert "source=$(echo \"$detect\" | grep '^SOURCE=' | cut -d= -f2-)" in helper
assert (
'[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] '
"&& vulkan=false"
) in helper
def test_reshade_and_optiscaler_are_mutually_exclusive() -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
start = Path("build-aux/share/portproton/scripts/start.sh").read_text(
encoding="utf-8",
)
assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"' in helper
assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"' in helper
assert "steamplay_launch ()" not in helper
assert 'export PW_STEAMPLAY="1"' in start
assert '"${PROTON_RUNNER}" waitforexitandrun "${PW_EXE_FILE}" "$@"' in helper
def test_reshade_repeated_link_does_not_create_nested_shaders(tmp_path: Path) -> None:
def test_steam_skips_database_initialization() -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
encoding="utf-8",
)
link_function = helper.split("try_force_link_dir () {", 1)[1].split("\n}", 1)[0]
shaders = tmp_path / "shaders"
shaders.mkdir()
game = tmp_path / "game"
game.mkdir()
script = f'try_force_link_dir () {{{link_function}\n}}\n'
link_call = helper.split(' [[ "$PW_USE_RESHADE" != 1 ]] && return 0', 1)[1]
link_call = link_call.split(' pw_specialk_set_ini_value', 1)[0]
script += f'root="$1"\nPATH_TO_GAME="$2"\n{link_call * 2}'
subprocess.run(
["bash", "-c", script, "bash", str(tmp_path), str(game)],
check=True,
)
assert (game / "ReShade_shaders").resolve() == shaders
assert not (shaders / "shaders").exists()
def test_reshade_disable_cleans_unreal_shipping_directory(tmp_path: Path) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
start = Path("build-aux/share/portproton/scripts/start.sh").read_text(
encoding="utf-8",
)
sync_function = helper.split("pw_reshade_sync_files () {", 1)[1].split("\n}", 1)[0]
game_root = tmp_path / "game"
game_root.mkdir()
game_exe = game_root / "Praest.exe"
game_exe.touch()
game = game_root / "Praest" / "Binaries" / "Win64"
game.mkdir(parents=True)
runtime = tmp_path / "plugins" / "reshade_windows" / "reshade" / "6.8.0_Addon"
runtime.mkdir(parents=True)
reshade_dll = runtime / "ReShade32.dll"
reshade_dll.touch()
(game / "opengl32.dll").symlink_to(reshade_dll)
(game / "opengl32.dll.b").symlink_to(reshade_dll)
(game / "ReShadePreset.ini").write_text("[GENERAL]\n")
(game / "reshade_version").write_text("6.8.0_Addon\nopengl32.dll\n")
script = f'pw_reshade_sync_files () {{{sync_function}\n}}\n'
script += 'try_remove_file () { rm -f "$1"; }\npw_reshade_sync_files\n'
subprocess.run(
["bash", "-c", script],
check=True,
env={
**os.environ,
"PW_EXE_FILE": str(game_exe),
"PW_PLUGINS_PATH": str(tmp_path / "plugins"),
"PW_USE_RESHADE": "0",
"PW_RESHADE_API": "",
"PW_RESHADE_EXE": "",
"PATH_TO_GAME": str(game),
},
db_init = helper.index("pw_init_db ()")
steam_db_return = helper.index("return 0", db_init)
normal_db = helper.index('[[ -n "${PW_DEFAULT_WINE_USE:-}" ]]', steam_db_return)
portproton_start = helper.index("start_portproton ()")
steam_db = helper.index(
'export PORTWINE_DB_FILE="${PW_EXE_FILE}.ppdb"',
portproton_start,
)
assert not (game / "opengl32.dll").exists()
assert not (game / "opengl32.dll.b").exists()
assert not (game / "ReShadePreset.ini").exists()
def test_ini_writer_matches_spaces_around_equals() -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
encoding="utf-8",
steam_block_end = helper.index("\n fi\n\n pw_check_and_download_plugins", steam_db)
steam_block = helper[steam_db:steam_block_end]
prefix_guard = helper.index('if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then')
normal_prefix = helper.index(
'data/prefixes/${PW_PREFIX_NAME}"/drive_c/windows',
prefix_guard,
)
api_guard = helper.index('if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then', normal_prefix)
api_symlink = helper.index('print_info "Try create symlink DXVK files..."', api_guard)
winetricks_guard = helper.index('if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then', api_symlink)
winetricks_update = helper.index("update_winetricks", winetricks_guard)
assert '^[[:space:]]*$key[[:space:]]*=' in helper
assert steam_db_return < normal_db
assert portproton_start < steam_db
assert "unset PW_PREFIX_NAME" in steam_block
assert 'export WINEPREFIX="${STEAM_COMPAT_DATA_PATH}/pfx"' in steam_block
assert "PW_PREFIX_NAME=" not in steam_block
assert prefix_guard < normal_prefix
assert api_guard < api_symlink
assert winetricks_guard < winetricks_update
assert "pw_init_db" in start
+40
View File
@@ -192,3 +192,43 @@ def test_vk_gpu_info_uses_build_aux_binary() -> None:
assert '/../../../bin/vk_gpu_info"' in helper
assert "dev-scripts/vk_gpu_info" not in helper
@mark.parametrize("filename,pe_status,expected_calls", [
("Game [one].EXE", "0", ["pe"]),
("Game [one].exe", "1", ["pe", "exiftool"]),
("Game [one].lnk", "0", ["exiftool"]),
])
def test_executable_metadata_uses_pefile_and_preserves_lnk_fallback(
tmp_path: Path, filename: str, pe_status: str, expected_calls: list[str],
) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
definition = helper.split("use_exiftool () {", 1)[1].split("\npw_create_gui_png () {", 1)[0]
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
exiftool = bin_dir / "exiftool"
exiftool.write_text('#!/bin/sh\nprintf "exiftool\\n" >> "$CALLS"\nprintf "Product Name: Fallback\\n"\n')
exiftool.chmod(0o755)
calls = tmp_path / "calls"
script = '''python_module () {
printf 'pe\\n' >> "$CALLS"
if [[ "$PE_STATUS" == 0 ]]; then
printf 'Product Name: Game\\nFile Description: Description\\nCompany Name: Sony\\n'
fi
return "$PE_STATUS"
}
'''
script += 'use_exiftool () {' + definition + '\nuse_exiftool "$TARGET"\nuse_exiftool "$TARGET"\n'
result = subprocess.run(
["bash", "-c", script], capture_output=True, text=True, check=False,
env={**os.environ, "PATH": f"{bin_dir}:{os.defpath}", "PW_TMPFS_PATH": str(tmp_path),
"CALLS": str(calls), "PE_STATUS": pe_status, "TARGET": str(tmp_path / filename)},
)
assert result.returncode == 0, result.stderr
assert calls.read_text().splitlines() == expected_calls
metadata = (tmp_path / "exiftool.tmp").read_text()
assert metadata.splitlines()[0] == str(tmp_path / filename)
assert "Product Name:" in metadata
if expected_calls == ["pe"]:
assert "File Description: Description" in metadata
assert "Company Name: Sony" in metadata
+46 -1
View File
@@ -2,17 +2,52 @@
import io
import tarfile
from types import SimpleNamespace
from pathlib import Path
from typing import Any, cast
import pytest
from PySide6.QtCore import QMimeData, QUrl
from PySide6.QtWidgets import QApplication, QWidget
from portprotonqt.dialogs.proton_manager import ProtonManager
from portprotonqt.dialogs.wine_extractor import ExtractionThread
from portprotonqt.tabs import library_tab
def test_nested_manager_registers_input_before_connecting_hints(monkeypatch: pytest.MonkeyPatch) -> None:
from unittest.mock import MagicMock
from PySide6.QtWidgets import QDialog, QPushButton
from portprotonqt.theme_manager import ThemeWrapper, load_theme
import portprotonqt.dialogs.proton_manager as module
app = QApplication.instance() or QApplication([])
main = cast(Any, QWidget())
main.get_button_icon = MagicMock(return_value="placeholder")
main.get_nav_icon = MagicMock(return_value="placeholder")
settings = cast(Any, QDialog(main))
events = []
inputs = SimpleNamespace(
gamepad=object(), gamepad_type="xbox",
enable_proton_manager_mode=lambda dialog: events.append(("enable", dialog)),
disable_proton_manager_mode=lambda: None,
connect_surface_updates=lambda name, callback: events.append((name, callback)),
)
settings.input_manager = inputs
monkeypatch.setattr(module.ProtonManager, "start_loading_wine_data", lambda self: None)
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **_kwargs: QPushButton(text))
manager = module.ProtonManager(settings, theme=ThemeWrapper(load_theme("standart")), input_manager=inputs)
assert manager.main_window is main
assert events[0] == ("enable", manager)
assert events[1][0] == "proton_manager_dialog"
assert main.get_button_icon.called and main.get_nav_icon.called
manager.close()
settings.deleteLater()
main.deleteLater()
app.processEvents()
def test_cachyos_v3_builds_require_supported_cpu_level(monkeypatch) -> None:
manager = ProtonManager.__new__(ProtonManager)
manager.cpu_level = 2
@@ -111,6 +146,7 @@ def test_extraction_does_not_change_process_directory(tmp_path, monkeypatch) ->
def test_library_drop_opens_manager_for_wine_archive(tmp_path, monkeypatch) -> None:
_app = QApplication.instance() or QApplication([])
archive_path = tmp_path / "PROTON_LG_10-30.tar.xz"
archive_path.touch()
mime_data = QMimeData()
@@ -126,9 +162,18 @@ def test_library_drop_opens_manager_for_wine_archive(tmp_path, monkeypatch) -> N
def acceptProposedAction(self):
self.accepted = True
class Window:
class Window(QWidget):
portproton_location = "/tmp/PortProtonQt"
input_manager = None
_on_dropped_theme_installed = library_tab.MainWindowLibraryTabMixin._on_dropped_theme_installed
_on_dropped_theme_failed = library_tab.MainWindowLibraryTabMixin._on_dropped_theme_failed
_on_dropped_wine_archives = library_tab.MainWindowLibraryTabMixin._on_dropped_wine_archives
_install_dropped_theme_archives = library_tab.MainWindowLibraryTabMixin._install_dropped_theme_archives
theme = SimpleNamespace(MESSAGE_BOX_STYLE="", PROGRESS_BAR_STYLE="")
from portprotonqt.tabs.theme_store_workers import ThemeArchiveInstallWorker
monkeypatch.setattr(ThemeArchiveInstallWorker, "start", lambda self: self.wine_archives.emit(self.paths))
monkeypatch.setattr(
library_tab, "show_proton_manager", lambda *args, **kwargs: calls.append(kwargs)
+300 -46
View File
@@ -26,7 +26,6 @@ from PySide6.QtWidgets import (
from portprotonqt.custom_widgets import CustomComboBox
from portprotonqt.tabs.settings_tab import SettingsButtonsLayout
from portprotonqt.dialogs.settings_gamescope import (
GAMESCOPE_TOGGLE_CATEGORIES,
GAMESCOPE_TOGGLE_DESCRIPTIONS,
@@ -37,10 +36,10 @@ from portprotonqt.dialogs.settings_mangohud import (
MANGOHUD_TOGGLE_DESCRIPTIONS,
MangoHudSettingsMixin,
)
from portprotonqt.dialogs.settings_reshade import ReShadeSettingsMixin
from portprotonqt.dialogs.settings_vkbasalt import VkBasaltSettingsMixin
PAINT_SETTLE_MS = 250
_application = QApplication.instance() or QApplication([])
@@ -91,45 +90,6 @@ def test_settings_dialog_search_accepts_typing_without_search_focus() -> None:
QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
def test_reshade_collects_enable_exe_api_and_font() -> None:
settings = ReShadeSettingsMixin()
settings.current_settings = {
"PW_USE_RESHADE": "1",
"PW_RESHADE_SCREENSHOT_KEY": "44,0,0,0",
"PW_RESHADE_OVERLAY_KEY": "36,0,0,0",
}
settings.original_values = {"PW_USE_RESHADE": "0"}
settings.reshade_original_values = {
"PW_RESHADE_EXE": "",
"PW_RESHADE_API": "auto",
"PW_RESHADE_FONT": "",
"PW_RESHADE_SCREENSHOT_PATH": "",
"PW_RESHADE_SCREENSHOT_KEY": "44,0,0,0",
"PW_RESHADE_OVERLAY_KEY": "36,0,0,0",
}
settings.reshade_exe_edit = QLineEdit("/games/game.exe")
settings.reshade_api_combo = CustomComboBox()
settings.reshade_api_combo.addItems(["Auto", "Vulkan"])
settings.reshade_api_combo.setCurrentText("Vulkan")
settings.reshade_font_combo = CustomComboBox()
settings.reshade_font_combo.addItem("DejaVu Sans", "DejaVu Sans")
settings.reshade_screenshot_edit = QLineEdit("/screenshots")
assert settings._collect_reshade_changes() == [
"PW_USE_RESHADE=1",
"PW_RESHADE_EXE=/games/game.exe",
"PW_RESHADE_API=vulkan",
"PW_RESHADE_FONT=DejaVu Sans",
"PW_RESHADE_SCREENSHOT_PATH=/screenshots",
]
assert settings._reshade_virtual_key(
Qt.Key.Key_Semicolon.value, Qt.KeyboardModifier.NoModifier
) == 186
assert settings._reshade_virtual_key(
Qt.Key.Key_1.value, Qt.KeyboardModifier.KeypadModifier
) == 97
def test_mangohud_search_ignores_unavailable_categories() -> None:
settings = MangoHudSettingsMixin()
settings.mangohud_category_groups = {}
@@ -504,7 +464,7 @@ def test_settings_table_descriptions_and_centered_controls(theme_name: str) -> N
@pytest.mark.parametrize("close_dialog", [False, True])
@pytest.mark.parametrize("themed", [False, True])
def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed: bool) -> None:
from PySide6.QtCore import Qt, QTimer
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QComboBox, QDialog
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
@@ -512,7 +472,7 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
QDialog.__init__(dialog)
dialog.vkbasalt_toggle_key_button = None
dialog.input_manager = None
dialog.gamepad_tooltip = QLabel(dialog, Qt.WindowType.ToolTip)
dialog.gamepad_tooltip = QLabel(dialog)
dialog.gamepad_tooltip_timer = QTimer(dialog)
dialog.gamepad_tooltip_timer.setSingleShot(True)
combo = QComboBox(dialog)
@@ -521,9 +481,16 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
from portprotonqt.themes.standart import styles
combo.setStyleSheet(ThemeWrapper(styles).COMBOBOX_STYLE)
dialog.gamepad_tooltip.setStyleSheet(ThemeWrapper(styles).TOOLTIP_STYLE)
combo.addItems(["Proton", "WINE-11.18-AMD64-WOW64" if themed else "Proton " * 40])
dialog.resize(600, 400)
dialog.move(20, 20)
combo.setGeometry(450, 20, 140, 40)
combo.view().setFixedWidth(180)
combo.highlighted.connect(lambda row: dialog._on_combo_highlighted(row, combo))
combo.view().installEventFilter(dialog)
combo.view().selectionModel().currentChanged.connect(
lambda index, _previous: dialog._on_combo_highlighted(index.row(), combo),
)
combo.setCurrentIndex(1)
dialog.show()
combo.showPopup()
@@ -531,10 +498,11 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
assert dialog.gamepad_tooltip.isVisible()
assert dialog.gamepad_tooltip.text() == combo.itemText(1)
dialog._on_combo_highlighted(0, combo)
assert dialog.rect().contains(dialog.gamepad_tooltip.geometry())
combo.view().setCurrentIndex(combo.model().index(0, 0))
assert not dialog.gamepad_tooltip.isVisible()
assert not dialog.gamepad_tooltip_timer.isActive()
dialog._on_combo_highlighted(1, combo)
combo.view().setCurrentIndex(combo.model().index(1, 0))
assert dialog.gamepad_tooltip.isVisible()
if close_dialog:
@@ -549,6 +517,112 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
dialog.close()
def test_favorite_combo_preserves_separator_actions_and_source_updates() -> None:
from PySide6.QtWidgets import QComboBox
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
from portprotonqt.theme_manager import ThemeWrapper, load_theme
table = QTableWidget(1, 2)
source = QComboBox(table)
source.addItems(["Wine", "Proton"])
source.insertSeparator(source.count())
source.addItem("Download", "download_wine")
activated = []
source.activated.connect(activated.append)
source.activated.connect(lambda _index: source.setCurrentIndex(0))
highlighted = []
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
QDialog.__init__(dialog)
dialog.vkbasalt_toggle_key_button = None
dialog.input_manager = None
dialog.theme = ThemeWrapper(load_theme("standart"))
dialog.favorites_table = table
dialog._on_combo_highlighted = lambda row, combo: highlighted.append((row, combo))
dialog._add_favorite_combo(0, source)
combo = cast(QComboBox, table.cellWidget(0, 1))
assert combo.model() is source.model()
assert not combo.model().flags(combo.model().index(2, 0)) & Qt.ItemFlag.ItemIsEnabled
assert combo.itemData(3) == "download_wine"
combo.setCurrentIndex(3)
combo.activated.emit(3)
assert activated == [3]
assert combo.currentText() == source.currentText() == "Wine"
source.addItem("New Wine")
assert combo.itemText(combo.count() - 1) == "New Wine"
highlighted.clear()
combo.view().setCurrentIndex(combo.model().index(1, 0))
assert highlighted == [(1, combo)]
table.deleteLater()
@pytest.fixture
def linked_settings_combos() -> tuple:
from PySide6.QtWidgets import QComboBox
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
from portprotonqt.theme_manager import ThemeWrapper, load_theme
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
QDialog.__init__(dialog)
dialog.vkbasalt_toggle_key_button = None
dialog.input_manager = None
dialog.theme = ThemeWrapper(load_theme("standart"))
dialog.gamepad_tooltip = QLabel(dialog)
dialog.gamepad_tooltip.setStyleSheet(dialog.theme.TOOLTIP_STYLE)
dialog.gamepad_tooltip_timer = QTimer(dialog)
stack = QStackedWidget(dialog)
stack.setGeometry(0, 0, 600, 400)
dialog.favorites_table = QTableWidget(1, 2)
advanced = QTableWidget(1, 2)
stack.addWidget(dialog.favorites_table)
stack.addWidget(advanced)
source = QComboBox()
source.addItems(["Wine", "WINE-11.18-AMD64-WOW64 " * 3])
advanced.setCellWidget(0, 1, source)
source.view().installEventFilter(dialog)
source.view().setMouseTracking(True)
source.view().entered.connect(lambda index: dialog._on_combo_highlighted(index.row(), source))
source.view().selectionModel().currentChanged.connect(
lambda index, _old: dialog._on_combo_highlighted(index.row(), source),
)
dialog._add_favorite_combo(0, source)
favorite = cast(QComboBox, dialog.favorites_table.cellWidget(0, 1))
dialog.resize(600, 400)
dialog.show()
return dialog, stack, favorite, source
@pytest.mark.parametrize("navigation", ["gamepad", "mouse"])
def test_linked_settings_combos_keep_visible_tooltip_during_navigation(
linked_settings_combos: tuple, navigation: str,
) -> None:
from PySide6.QtGui import QMouseEvent
dialog, stack, favorite, source = linked_settings_combos
for page, combo in enumerate((favorite, source)):
stack.setCurrentIndex(page)
combo.view().setFixedWidth(180)
combo.showPopup()
QApplication.processEvents()
for row in (1, 0):
if navigation == "mouse":
rect = combo.view().visualRect(combo.model().index(row, 0))
point = rect.intersected(combo.view().viewport().rect()).center().toPointF()
QApplication.sendEvent(combo.view().viewport(), QMouseEvent(
QEvent.Type.MouseMove, point, point, Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier,
))
else:
combo.setCurrentIndex(row)
assert dialog.gamepad_tooltip.isVisible() == bool(row), page
if row:
assert dialog.gamepad_tooltip.text() == combo.itemText(row)
assert dialog.rect().contains(dialog.gamepad_tooltip.geometry())
assert not dialog.gamepad_tooltip.isWindow()
combo.hidePopup()
dialog.close()
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic", "classic-light"])
def test_settings_selected_row_keeps_text_readable(theme_name: str) -> None:
from PySide6.QtGui import QColor, QPalette
@@ -671,3 +745,183 @@ def test_winetricks_centered_checkboxes_and_install_selection(
dialog.install_selected(force=True)
dialog._start_install_process.assert_called_once_with(["art2kmin"], True)
dialog.deleteLater()
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic", "classic-light"])
def test_settings_table_stops_repainting_when_idle(theme_name: str) -> None:
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QTableWidgetItem
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
from portprotonqt.theme_manager import ThemeWrapper, load_theme
theme = ThemeWrapper(load_theme(theme_name))
dialog = cast(ExeSettingsDialog, SimpleNamespace(theme=theme))
class PaintFilter(QObject):
paints = 0
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
if event.type() == QEvent.Type.Paint:
self.paints += 1
return ExeSettingsDialog.eventFilter(dialog, obj, event)
return False
table = QTableWidget(3, 3)
table.resize(800, 400)
table.setStyleSheet(theme.WINETRICKS_TABBLE_STYLE)
table.setColumnWidth(0, 500)
table.setColumnHidden(2, True)
table.viewport().setProperty("ppqt_settings_values", True)
paint_filter = PaintFilter(table)
table.viewport().installEventFilter(paint_filter)
for row in range(3):
table.setItem(row, 0, QTableWidgetItem("Setting\nDescription " * 5))
combo = CustomComboBox(theme=theme)
combo.addItem("Value")
table.setCellWidget(row, 1, combo)
table.show()
for selected_row, width in ((0, 500), (1, 300)):
table.selectRow(selected_row)
table.setColumnWidth(0, width)
for _ in range(10):
QApplication.processEvents()
QTest.qWait(PAINT_SETTLE_MS)
paints = paint_filter.paints
QTest.qWait(PAINT_SETTLE_MS)
assert paint_filter.paints == paints
table.close()
@pytest.fixture
def autosave_dialog(request, monkeypatch, tmp_path) -> tuple[QDialog, list[list[str]], bool]:
from PySide6.QtCore import QProcess
from portprotonqt.dialogs import settings_dialog as module
dialog = module.ExeSettingsDialog.__new__(module.ExeSettingsDialog)
QDialog.__init__(dialog)
dialog.saving = False
dialog.settings_loading = False
dialog.close_after_save = False
user_conf = bool(request.param)
dialog.user_conf = user_conf
dialog.exe_path = str(tmp_path / 'game.exe')
dialog.save_timer = QTimer(dialog)
dialog.clear_ppdb_button = cast(module.AutoSizeButton, QPushButton(dialog))
dialog.original_values = {'TEST': '0'}
dialog.original_display_values = {}
dialog.current_settings = {}
dialog.blocked_keys = set()
prefix = CustomComboBox(dialog)
prefix.setEditable(True)
prefix.setCurrentText('lower prefix')
dialog.advanced_widgets = {} if user_conf else {'PW_PREFIX_NAME': prefix}
dialog.value_mapping = {}
dialog.game_source = ''
dialog.gamescope_available = False
dialog.settings_table = QTableWidget(1, 1, dialog)
from PySide6.QtWidgets import QTableWidgetItem
item = QTableWidgetItem('TEST')
item.setData(Qt.ItemDataRole.UserRole, 'TEST')
dialog.settings_table.setItem(0, 0, item)
checkbox = QCheckBox(dialog)
dialog.value_widgets = {(0, 1): checkbox}
dialog.mangohud_original_values = {}
dialog.vkbasalt_original_values = {}
dialog.gamescope_original_values = {}
calls: list[list[str]] = []
monkeypatch.setattr(dialog, '_collect_mangohud_changes', lambda: [])
monkeypatch.setattr(dialog, '_collect_vkbasalt_changes', lambda: [])
monkeypatch.setattr(dialog, '_get_process_args', lambda args: ['test-cli', *args])
monkeypatch.setattr(QProcess, 'start', lambda _self, program, args: calls.append(args))
return dialog, calls, user_conf
@pytest.mark.parametrize('autosave_dialog', [False, True], indirect=True)
def test_settings_autosave_ignores_download_action(autosave_dialog: tuple) -> None:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
widget, calls, user_conf = autosave_dialog
dialog = cast(ExeSettingsDialog, widget)
combo = CustomComboBox(dialog)
combo.addItem("Wine")
combo.addItem("Download", "download_wine")
combo.setCurrentIndex(1)
key = 'PW_DEFAULT_WINE_USE' if user_conf else 'PW_WINE_USE'
dialog.advanced_widgets = {key: combo}
dialog.original_display_values = {key: "Wine"}
dialog.apply_changes()
assert calls == []
assert not dialog.saving
dialog.deleteLater()
@pytest.mark.parametrize('autosave_dialog', [False, True], indirect=True)
def test_settings_autosave_serializes_changes(autosave_dialog) -> None:
from PySide6.QtCore import QProcess
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
widget, calls, user_conf = autosave_dialog
dialog = cast(ExeSettingsDialog, widget)
checkbox = dialog.value_widgets[(0, 1)]
assert isinstance(checkbox, QCheckBox)
checkbox.setChecked(True)
dialog.apply_changes()
assert dialog.saving
assert len(calls) == 1
checkbox.setChecked(False)
dialog.apply_changes()
assert len(calls) == 1
process = dialog.user_conf_process if user_conf else dialog.save_process
process.finished.emit(0, QProcess.ExitStatus.NormalExit)
assert dialog.saving
assert len(calls) == 2
expected = ['TEST', '0'] if user_conf else ['TEST=0']
assert calls[-1][-len(expected):] == expected
process = dialog.user_conf_process if user_conf else dialog.save_process
process.finished.emit(0, QProcess.ExitStatus.NormalExit)
assert not dialog.saving
assert dialog.original_values['TEST'] == '0'
assert len(calls) == 2
dialog.deleteLater()
def test_settings_autosave_ignores_search_and_initial_loading() -> None:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
QDialog.__init__(dialog)
dialog.settings_loading = True
dialog.save_timer = QTimer(dialog)
dialog.search_edit = QLineEdit(dialog)
checkbox = QCheckBox(dialog)
dialog._connect_autosave()
checkbox.setChecked(True)
assert not dialog.save_timer.isActive()
dialog.settings_loading = False
dialog.search_edit.setText('search')
assert not dialog.save_timer.isActive()
checkbox.setChecked(False)
assert dialog.save_timer.isActive()
dialog.save_timer.stop()
dialog.deleteLater()
def test_settings_close_waits_for_pending_save(monkeypatch) -> None:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
from PySide6.QtGui import QCloseEvent
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
QDialog.__init__(dialog)
dialog.save_timer = QTimer(dialog)
dialog.saving = True
dialog.close_after_save = False
monkeypatch.setattr(dialog, 'apply_changes', lambda: None)
event = QCloseEvent()
dialog.closeEvent(event)
assert not event.isAccepted()
assert dialog.close_after_save
dialog.close_after_save = False
dialog.reject()
assert dialog.close_after_save
dialog.deleteLater()
+961 -35
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -116,3 +116,25 @@ class TestDeleteCachedAppFiles:
(tmp_path / "other.txt").write_text("hello")
delete_cached_app_files(str(tmp_path), "steam_app_*.json")
assert (tmp_path / "other.txt").exists()
def test_matched_steam_game_keeps_cover_when_app_details_fail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import portprotonqt.steam_api.api as api
executable = tmp_path / "Praest.exe"
executable.touch()
app = {"appid": 1371690, "normalized_name": "graven"}
monkeypatch.setattr(api.ui_config, "get_economy_mode", lambda: False)
monkeypatch.setattr("portprotonqt.steam_api.cache.get_exiftool_data", lambda _exe: {})
monkeypatch.setattr(api, "_has_custom_data_for_exe", lambda _name: False)
monkeypatch.setattr(api, "get_steam_apps_and_index_async", lambda cb: cb(([app], {"graven": app})))
monkeypatch.setattr(api, "fetch_app_info_async", lambda _appid, cb: cb(None))
monkeypatch.setattr(api, "get_local_steam_cover", lambda *_args, **_kwargs: "")
monkeypatch.setattr(api, "get_weanticheatyet_info_async", lambda _name, cb: cb({}))
monkeypatch.setattr(api, "_add_ppdb_info", lambda result, _name, cb: cb(result))
results = []
api.get_steam_game_info_async("GRAVEN", str(executable), results.append)
assert len(results) == 1
assert results[0]["appid"] == 1371690
assert results[0]["steam_game"] == "true"
assert results[0]["cover"] == "https://steamcdn-a.akamaihd.net/steam/apps/1371690/library_600x900_2x.jpg"
+75
View File
@@ -1350,3 +1350,78 @@ def test_store_storage_migrates_existing_data(
assert not (api.data_dir / "bin").exists()
assert not legacy.exists()
assert api_class().library_path.read_text() == "[]"
def test_batch_egs_deletion_skips_confirmation(monkeypatch: MonkeyPatch) -> None:
message_box = MagicMock()
monkeypatch.setattr(main_window_module, "QMessageBox", message_box)
window = SimpleNamespace(
context_menu_manager=SimpleNamespace(_batch_action=True),
egs_api=SimpleNamespace(load_installed=lambda: {}), _start_egs_operation=MagicMock(),
)
MainWindow._delete_egs_game(cast(Any, window), "Game")
message_box.assert_not_called()
assert window._start_egs_operation.call_args.args[:2] == ("Game", ["uninstall", "Game", "-y"])
def test_batch_gog_deletion_retains_worker_without_confirmation(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
worker = MagicMock()
monkeypatch.setattr(download_tab_module, "GOGDeleteWorker", MagicMock(return_value=worker))
message_box = MagicMock()
monkeypatch.setattr(download_tab_module, "QMessageBox", message_box)
window = SimpleNamespace(
context_menu_manager=SimpleNamespace(_batch_action=True),
gog_api=SimpleNamespace(get_installed_path=lambda app_id: tmp_path),
_on_gog_delete_finished=MagicMock(), gogAccountStatus=MagicMock(),
)
game = {"app_id": "1", "title": "Game"}
GOGMixin._delete_gog_game(cast(Any, window), game)
message_box.assert_not_called()
assert window.gog_delete_worker is worker
worker.finished.connect.assert_called_once_with(window._on_gog_delete_finished)
worker.start.assert_called_once_with()
@mark.parametrize("failure", [False, True])
def test_gog_delete_worker_preserves_records_on_failure(
tmp_path: Path, monkeypatch: MonkeyPatch, failure: bool,
) -> None:
from threading import get_ident
folder = tmp_path / "game"
folder.mkdir()
(folder / "game.exe").write_text("game")
manifest = tmp_path / "heroic_gogdl" / "manifests" / "1"
manifest.parent.mkdir(parents=True)
manifest.write_text("manifest")
api = SimpleNamespace(get_installed_path=lambda app_id: folder, config_dir=tmp_path,
remove_installed_game=MagicMock())
menu = SimpleNamespace(remove_gog_shortcuts=MagicMock())
thread_ids: list[int] = []
original_rmtree = download_tab_module.shutil.rmtree
def delete_installation(path: Path) -> None:
thread_ids.append(get_ident())
if failure:
raise PermissionError("denied")
original_rmtree(path)
monkeypatch.setattr(download_tab_module.shutil, "rmtree", delete_installation)
worker = download_tab_module.GOGDeleteWorker(cast(Any, api), {"app_id": "1", "title": "Game"}, cast(Any, menu))
worker.start()
assert worker.wait(3000)
assert thread_ids and thread_ids[0] != get_ident()
assert bool(worker.error) is failure
assert folder.exists() is failure
assert manifest.exists() is failure
assert api.remove_installed_game.call_count == int(not failure)
assert menu.remove_gog_shortcuts.call_count == int(not failure)
def test_failed_store_process_completes_callback_path() -> None:
process = MagicMock()
GOGMixin._handle_store_process_error(cast(Any, SimpleNamespace()), process,
download_tab_module.QProcess.ProcessError.FailedToStart)
process.finished.emit.assert_called_once_with(
download_tab_module.os.EX_UNAVAILABLE, download_tab_module.QProcess.ExitStatus.CrashExit,
)
-56
View File
@@ -773,62 +773,6 @@ class TestThemeStylesIntegrity:
assert '"glow_base_alpha"' in content
assert '"glow_pulse_alpha"' in content
def test_classic_styles_use_fstrings_not_hardcoded(self):
"""Classic QSS style constants should be f-strings, not plain strings
(except HINT_BAR_STYLE and %-formatted styles for icon paths)."""
content = self._read_theme("classic")
tree = ast.parse(content)
plain_style_count = 0
fstring_style_count = 0
percent_style_count = 0
for node in ast.iter_child_nodes(tree):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if not isinstance(target, ast.Name):
continue
if not target.id.endswith("_STYLE"):
continue
if isinstance(node.value, ast.JoinedStr):
fstring_style_count += 1
elif isinstance(node.value, ast.BinOp) and isinstance(node.value.op, ast.Mod):
percent_style_count += 1
elif isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
plain_style_count += 1
assert fstring_style_count > 10, "Classic should use f-strings for most styles"
assert plain_style_count <= 1, f"Classic has {plain_style_count} plain string styles"
assert percent_style_count <= 3, f"Classic has {percent_style_count} %-formatted styles"
def test_classic_light_styles_use_fstrings_not_hardcoded(self):
"""Classic-light QSS style constants should be f-strings, not plain strings
(except %-formatted styles for icon paths)."""
content = self._read_theme("classic-light")
tree = ast.parse(content)
plain_style_count = 0
fstring_style_count = 0
percent_style_count = 0
for node in ast.iter_child_nodes(tree):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if not isinstance(target, ast.Name):
continue
if not target.id.endswith("_STYLE"):
continue
if isinstance(node.value, ast.JoinedStr):
fstring_style_count += 1
elif isinstance(node.value, ast.BinOp) and isinstance(node.value.op, ast.Mod):
percent_style_count += 1
elif isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
plain_style_count += 1
assert fstring_style_count > 10, "Classic-light should use f-strings for most styles"
assert plain_style_count == 0, f"Classic-light has {plain_style_count} plain string styles"
assert percent_style_count <= 3, f"Classic-light has {percent_style_count} %-formatted styles"
def test_classic_theme_inherits_standart(self):
content = self._read_theme("classic")
assert 'THEME_INHERITS = "standart"' in content
+128 -2
View File
@@ -2,13 +2,139 @@
from types import SimpleNamespace
from typing import Any, cast
import io
import tarfile
import zipfile
from PySide6.QtCore import QEvent, QPointF, Qt
import pytest
from PySide6.QtCore import QEvent, QMimeData, QPointF, Qt, QUrl
from PySide6.QtGui import QEnterEvent, QMouseEvent
from PySide6.QtWidgets import QApplication, QLabel
from PySide6.QtWidgets import QApplication, QLabel, QProgressDialog, QWidget
from types import MethodType
from portprotonqt.tabs import theme_store
from portprotonqt.tabs.theme_store import ThemeStoreCard, ThemeStoreMixin
from portprotonqt.tabs import theme_store_workers
from portprotonqt.tabs.library_tab import MainWindowLibraryTabMixin
from portprotonqt.tabs.workers import MainWindowWorkersMixin
@pytest.mark.parametrize("outcome", ["installed", "failed", "wine_archives"])
def test_theme_zip_drop_starts_retained_worker(tmp_path, monkeypatch, outcome) -> None:
_app = QApplication.instance() or QApplication([])
archive_path = tmp_path / "theme.zip"
archive_path.touch()
mime = QMimeData()
mime.setUrls([QUrl.fromLocalFile(str(archive_path))])
accepted = []
event = SimpleNamespace(
mimeData=lambda: mime,
acceptProposedAction=lambda: accepted.append(True),
ignore=lambda: accepted.append(False),
)
started = []
monkeypatch.setattr(theme_store_workers.ThemeArchiveInstallWorker, "start", lambda self: started.append(self))
window = cast(Any, QWidget())
window.theme = SimpleNamespace(MESSAGE_BOX_STYLE="", PROGRESS_BAR_STYLE="")
window._on_dropped_theme_installed = started.append
window._on_dropped_theme_failed = started.append
window._on_dropped_wine_archives = started.append
window._install_dropped_theme_archives = MethodType(
MainWindowLibraryTabMixin._install_dropped_theme_archives, window
)
MainWindowLibraryTabMixin.dragEnterEvent(cast(Any, window), event)
MainWindowLibraryTabMixin.dropEvent(cast(Any, window), event)
assert accepted == [True, True]
assert started == window._themeArchiveWorkers
assert started[0].paths == [str(archive_path)]
progress = window.findChild(QProgressDialog)
assert progress is not None
assert progress.isVisible()
assert progress.isModal()
assert progress.minimum() == progress.maximum() == 0
worker = started[0]
getattr(worker, outcome).emit("error" if outcome == "failed" else ["custom"])
assert progress.isVisible() == (outcome == "installed")
worker.finished.emit()
assert not progress.isVisible()
assert window._themeArchiveWorkers == []
@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"])
def test_dropped_theme_archive_installs_and_applies(tmp_path, monkeypatch, archive_format) -> None:
archive_path = tmp_path / f"theme.{archive_format}"
if archive_format == "zip":
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("custom/styles.py", "color = 'blue'\n")
else:
with tarfile.open(archive_path, "w:gz") as archive:
content = b"color = 'blue'\n"
entry = tarfile.TarInfo("custom/styles.py")
entry.size = len(content)
archive.addfile(entry, io.BytesIO(content))
themes_dir = tmp_path / "themes"
monkeypatch.setattr(theme_store_workers, "THEMES_DIRS", [themes_dir])
applied = []
invalidated = []
refreshed = []
window = SimpleNamespace(
theme_manager=SimpleNamespace(invalidate_theme=invalidated.append),
_refresh_theme_combo=refreshed.append,
_apply_theme_and_restart=lambda name, variant: applied.append((name, variant)),
)
monkeypatch.setattr("portprotonqt.tabs.library_tab.ui_config.get_theme_variant", lambda: "dark")
worker = theme_store_workers.ThemeArchiveInstallWorker([str(archive_path)])
worker.installed.connect(
lambda names: MainWindowLibraryTabMixin._on_dropped_theme_installed(cast(Any, window), names)
)
errors = []
worker.failed.connect(errors.append)
worker.run()
assert not errors
assert (themes_dir / "custom/styles.py").read_text() == "color = 'blue'\n"
assert invalidated == refreshed == ["custom"]
assert applied == [("custom", "dark")]
def test_dropped_archives_keep_wine_and_reject_unsafe_theme(tmp_path, monkeypatch) -> None:
wine = tmp_path / "wine.tar.gz"
with tarfile.open(wine, "w:gz") as archive:
archive.addfile(tarfile.TarInfo("wine/bin/wine"))
unsafe = tmp_path / "unsafe.zip"
with zipfile.ZipFile(unsafe, "w") as archive:
archive.writestr("../styles.py", "")
monkeypatch.setattr(theme_store_workers, "THEMES_DIRS", [tmp_path / "themes"])
worker = theme_store_workers.ThemeArchiveInstallWorker([str(wine), str(unsafe)])
wines = []
errors = []
installed = []
worker.wine_archives.connect(wines.append)
worker.failed.connect(errors.append)
worker.installed.connect(installed.append)
worker.run()
assert wines == [[str(wine)]]
assert len(errors) == 1
assert installed == []
assert not (tmp_path / "styles.py").exists()
def test_shutdown_waits_for_dropped_theme_workers() -> None:
worker = SimpleNamespace()
stopped = []
window = SimpleNamespace(
_themeArchiveWorkers=[worker],
_stopWorkerThread=lambda *args: None,
_stopWorkerThreads=lambda name: MainWindowWorkersMixin._stopWorkerThreads(cast(Any, window), name),
_stopWorker=lambda *args: stopped.append(args),
)
MainWindowWorkersMixin._stopBackgroundWorkers(cast(Any, window))
assert stopped == [(worker, "_themeArchiveWorkers", None)]
assert window._themeArchiveWorkers == []
class FakeSignal:
+16 -1
View File
@@ -6,7 +6,9 @@ Key regression areas (from git history):
- Malformed playtime data robustness (dd65021)
- #@_@# space encoding in stat paths
"""
from datetime import datetime
from datetime import datetime, timedelta
import pytest
from portprotonqt.time_utils import (
_parse_last_launch_line,
@@ -18,9 +20,22 @@ from portprotonqt.time_utils import (
parse_playtime_file,
get_playtime_for_exe,
format_playtime,
format_last_launch,
)
@pytest.mark.parametrize("detail_level", ["detailed", "brief", "steam"])
def test_time_formatting_with_c_locale(
monkeypatch: pytest.MonkeyPatch, detail_level: str,
) -> None:
monkeypatch.setattr("portprotonqt.localization.locale.getlocale", lambda: ("c", None))
monkeypatch.setattr("portprotonqt.config.ui_config.get_force_english", lambda: False)
monkeypatch.setattr("portprotonqt.time_utils.ui_config.get_time_detail_level", lambda: detail_level)
assert format_last_launch(datetime.now() - timedelta(hours=2))
assert format_playtime(7200)
# ── _parse_last_launch_line ──────────────────────────────────────────────────
class TestParseLastLaunchLine:
+22
View File
@@ -18,6 +18,7 @@ from portprotonqt.steam_api.utils import (
convert_steam_id,
decode_text,
get_steam_launch_commands,
get_steam_runtime_library_paths,
get_steam_compatibilitytools_dir,
get_steam_compat_tool,
get_last_steam_user,
@@ -580,6 +581,19 @@ class TestIterExistingSteamDataDirs:
result = _iter_existing_steam_data_dirs()
assert len(result) == 1
def test_runtime_paths_from_all_installations(self, tmp_path: Path):
native_steam = tmp_path / "native"
snap_steam = tmp_path / "snap"
(native_steam / "ubuntu12_32").mkdir(parents=True)
(snap_steam / "ubuntu12_64").mkdir(parents=True)
steam_dirs = (str(native_steam), str(snap_steam))
with patch("portprotonqt.steam_api.utils.STEAM_DATA_DIRS", steam_dirs):
assert get_steam_runtime_library_paths() == [
str(native_steam / "ubuntu12_32"),
str(snap_steam / "ubuntu12_64"),
]
def test_skips_nonexistent(self):
with patch("portprotonqt.steam_api.utils.STEAM_DATA_DIRS", ("/nonexistent/path",)):
result = _iter_existing_steam_data_dirs()
@@ -767,3 +781,11 @@ def test_disabled_steam_proton_scanning_skips_discovery(monkeypatch: pytest.Monk
with patch("portprotonqt.steam_api.utils._iter_existing_steam_data_dirs") as discover:
assert get_steam_proton_versions() == []
discover.assert_not_called()
def test_steam_index_preserves_original_game_when_name_is_reused() -> None:
apps = [
{"appid": 1371690, "normalized_name": "graven"},
{"appid": 5171110, "normalized_name": "graven"},
]
assert build_index(apps)["graven"]["appid"] == 1371690