Compare commits

...
79 Commits
Author SHA1 Message Date
Boria138 e96550d155 chore: update Brazilian Portuguese and Simplified Chinese translations
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-13 21:19:14 +05:00
Boria138 eef9824596 fix(ci): fall back to rate-mirrors when Lenuma CDN is unavailable
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-13 21:15:29 +05:00
Codeberg Translateandadem c7acdd2ef4 Translated using Weblate (Russian)
Currently translated at 100.0% (808 of 808 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-11-13 17:44:11 +05:00
Boria138 4fb74c1d15 fix: create virtual mouse only while emulation is active
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-13 10:51:50 +05:00
Boria138 78dab249c1 fix: prevent SGDB mirror cover cache collisions
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-12 18:35:11 +05:00
Boria138 9e91eeb0dc feat: add library CLI
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-12 15:10:05 +05:00
Boria138 cb8d149b2a fix: show installed Wine/Proton versions when catalog loading fails
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-09 00:14:55 +05:00
Boria138 98475c0e59 fix: use Bash for PortProton functions in EasyTerm
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-08 16:55:50 +05:00
Boria138 0391b894ee feat: support multiple Run After executables and scripts
Code check / Check code (push) Successful in 2m52s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-31 00:41:47 +05:00
Boria138 ca352d0ae9 feat: add multi-source library filtering
Code check / Check code (push) Successful in 2m30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-29 23:42:55 +05:00
Boria138 b0b22481c1 fix: enable custom icons for Steam source games
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-29 23:19:23 +05:00
Boria138 147187a02a chore: use cloud mirrors for SGDB and ProtonDB
Code check / Check code (push) Successful in 2m30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-28 10:28:48 +05:00
Boria138 a874ea6979 fix: use store icon overrides in Steam shortcuts
Code check / Check code (push) Successful in 2m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-27 16:59:17 +05:00
Boria138 ca658fa84f fix: use custom icon when adding games to Steam
Code check / Check code (push) Successful in 2m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-27 15:38:12 +05:00
Boria138 3bd78f5c73 feat: add SGDB fallback for Steam shortcut artwork
Code check / Check code (push) Successful in 2m33s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-26 23:47:04 +05:00
Boria138 b3824a3556 fix(settings): prevent favorite value clipping
Code check / Check code (push) Successful in 2m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-26 15:48:29 +05:00
Boria138 4d9e39d9e3 feat: add Space hint for multiple selection
Code check / Check code (push) Successful in 2m28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-26 15:33:13 +05:00
Gitea Actions 9c148c2710 chore: download stats 2026-10-25T14:59:05Z 2026-10-25 14:59:05 +00:00
Boria138 0dae987520 fix: avoid UI freezes when switching library layouts
Code check / Check code (push) Successful in 2m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-25 11:37:18 +05:00
Renovate Bot 00ec0baf35 chore(deps): update archlinux:base-devel docker digest to 3cbf69d
Code check / Check code (push) Successful in 2m21s
2026-10-25 04:39:11 +00:00
Renovate Bot 28726a5cbd chore(deps): update https://git.linux-gaming.ru/actions/setup-python digest to 6d4fd22
Code check / Check code (pull_request) Successful in 2m22s
Code check / Check code (push) Successful in 2m26s
2026-10-25 00:02:48 +00:00
Boria138 36a1994e4f chore(locale): update
Code check / Check code (push) Successful in 2m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-24 18:14:20 +05:00
Boria138 6bae4616ec Merge branch 'weblate' 2026-10-24 18:13:42 +05:00
Boria138 e1080d0185 feat: allow custom icons for EXE shortcuts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-24 18:12:02 +05:00
Codeberg Translateandadem e224904d4f Translated using Weblate (Russian)
Currently translated at 100.0% (807 of 807 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (809 of 809 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-24 09:27:17 +00:00
Codeberg TranslateandRubin13 2b4a0ede44 Translated using Weblate (German)
Currently translated at 100.0% (807 of 807 strings)

Translated using Weblate (German)

Currently translated at 100.0% (809 of 809 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-10-24 09:27:17 +00:00
Boria138 13079b3a50 fix: restore gamepad context menu in file explorer
Code check / Check code (push) Successful in 2m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-23 12:35:59 +05:00
Boria138 6eae0612b8 chore(locale): update
Code check / Check code (push) Successful in 2m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-22 15:49:31 +05:00
Boria138 d77d972da6 fix: confirm silent launches before replacing a session
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-22 15:47:54 +05:00
Boria138 0e9bfa9d44 fix: release DLC worker before opening dialogs
Code check / Check code (push) Successful in 2m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-22 15:13:21 +05:00
Boria138 00c1d086b1 feat: add GOG and EGS games to Steam
Code check / Check code (push) Failing after 6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-22 14:57:33 +05:00
Boria138 a3a7f61f8b fix: respect LANGUAGE when detecting Steam language
Code check / Check code (push) Successful in 2m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-22 12:25:29 +05:00
Boria138 8adc0ebda7 perf: cache Steam AppID matches across library loads
Code check / Check code (push) Successful in 4m40s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-22 12:07:53 +05:00
Boria138 6f73fa2b67 chore: switch card selection shortcut to space
Code check / Check code (push) Successful in 2m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 23:46:02 +05:00
Codeberg Translateandadem c4f172faf1 Translated using Weblate (Russian)
Currently translated at 100.0% (801 of 801 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-10-21 16:27:49 +00:00
Codeberg TranslateandRubin13 974802c409 Translated using Weblate (German)
Currently translated at 100.0% (801 of 801 strings)

Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-10-21 16:27:49 +00:00
Boria138 4d4c392d85 perf(library): skip metadata for uninstalled games
Code check / Check code (push) Successful in 2m22s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 19:21:51 +05:00
Boria138 10d034031b feat: restore library position after game details
Code check / Check code (push) Successful in 2m24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 17:15:49 +05:00
Boria138 04d1e44447 fix: show source ribbons in vertical library layout
Code check / Check code (push) Successful in 2m23s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 12:03:18 +05:00
Boria138 10cc22a88a fix: suppress success dialog for Steam CEF removal
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 12:01:19 +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
100 changed files with 27688 additions and 2067 deletions

No files matched your search

+40
View File
@@ -31,6 +31,26 @@ jobs:
image: archlinux:base-devel
options: --privileged --device /dev/fuse
steps:
- name: Configure Arch mirrors
run: |
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
@@ -69,6 +89,26 @@ jobs:
image: archlinux:base-devel
steps:
- name: Configure Arch mirrors
run: |
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
run: |
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
+15 -12
View File
@@ -22,11 +22,14 @@ jobs:
name: Check code
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
steps:
- name: Rank Arch mirrors
- name: Configure Arch mirrors
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif 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 - &&
@@ -41,18 +44,18 @@ jobs:
fi
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
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up Python
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
uses: https://git.linux-gaming.ru/actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version-file: "pyproject.toml"
+13 -7
View File
@@ -80,12 +80,15 @@ 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:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
options: --privileged --device /dev/fuse
steps:
- name: Rank Arch mirrors
- name: Configure Arch mirrors
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif 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 - &&
@@ -207,12 +210,15 @@ 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:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
steps:
- name: Rank Arch mirrors
- name: Configure Arch mirrors
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif 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 - &&
@@ -290,7 +296,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
+1
View File
@@ -45,3 +45,4 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
.codex
CODE_QUALITY_PLAN.md
.repowise
.pz
+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 |
+51
View File
@@ -7,13 +7,64 @@
### Added
- Добавлен CLI для получения локальных данных библиотеки
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
- Добавлена возможность добавлять установленные игры GOG и EGS в Steam
- Добавлен массовый импорт игр из выбранной папки
- Добавлен режим множественного выделения карточек через контекстное меню: пробел включает и отменяет режим с очисткой выделения, ЛКМ и кнопка A геймпада переключают выделение карточек
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
- Добавлено предупреждение о кириллице в путях к игре или PortProton
- Добавлен резервный запрет сна через logind/elogind при недоступности ScreenSaver и портала, в том числе для дистрибутивов без systemd
### Changed
- В Run After / Wrapper выбор файлов ограничен `.exe` и файлами с правом исполнения в Linux
- В режиме CLOUD данные ProtonDB и ссылки на обложки SteamGridDB загружаются через `deckyloader.ru`
- Повторный запуск с `--silent` теперь запрашивает подтверждение: при согласии текущий PortProtonQt и запущенные игры завершаются, затем приложение запускается заново для новой игры: при отказе новый запуск полностью отменяется, текущая сессия продолжает работать
- Найденные Steam AppID сохраняются в кеше, чтобы при повторной загрузке библиотеки пропускать чтение метаданных EXE и поиск по каталогу Steam
- При определении языка Steam_EMU теперь учитывается переменная `LANGUAGE`
- При возврате из карточки игры библиотека сохраняет прокрутку и фокус на этой карточке
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
- После добавления или удаления ярлыков в 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/`
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
### Fixed
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
- Исправлено зависание при смене сетки библиотеки
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
- При включённом фильтре установленных игр метаданные GOG и EGS загружаются только для установленных игр
- Исправлено отображение хвостиков источника игр в вертикальном режиме библиотеки
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
- Наведение на карточки больше не перехватывает фокус из строки поиска при вводе текста
- Исправлена ошибка форматирования даты последнего запуска и времени игры при системной локали `C` или `POSIX`
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
- Исправлена гонка при запуске игры с `--silent`
- Исправлено обрезание значений в избранных настройках
### Contributors
- @Dervart
- @adem4ik
- @rogerio-mattos
- @Rubin13
---
+1 -1
View File
@@ -9,7 +9,7 @@
<p>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&amp;label=Release" alt="Releases"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-14.1k-green?style=flat-square" alt="Total Downloads"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-17.9k-green?style=flat-square" alt="Total Downloads"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&amp;logo=python" alt="Python 3.11+"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt"><img src="https://img.shields.io/badge/Platform-Linux-blue?style=flat-square&amp;logo=linux" alt="Platform: Linux"></a>
+1 -1
View File
@@ -9,7 +9,7 @@
<p>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&amp;label=Релиз" alt="Релизы"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-14.1k-green?style=flat-square" alt="Всего загрузок"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-17.9k-green?style=flat-square" alt="Всего загрузок"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&amp;logo=python" alt="Python 3.11+"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"><img src="https://img.shields.io/badge/Платформа-Linux-blue?style=flat-square&amp;logo=linux" alt="Платформа: Linux"></a>
@@ -2272,9 +2272,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
@@ -4007,6 +4012,25 @@ pw_launch_wrapper () {
return
fi
local -a wrapper_scripts=()
local wrapper_script
local wrapper_scripts_valid=1
if [[ "${PW_RUN_AFTER_EXE}" == *","* ]] ; then
IFS=',' read -r -a wrapper_scripts <<< "${PW_RUN_AFTER_EXE}"
for wrapper_script in "${wrapper_scripts[@]}" ; do
if [[ ! -f "${wrapper_script}" || "${wrapper_script,,}" == *.exe ]] ; then
wrapper_scripts_valid=0
fi
done
if [[ "${wrapper_scripts_valid}" == 1 ]] ; then
for wrapper_script in "${wrapper_scripts[@]}" ; do
"${wrapper_script}"
done
"$@"
return
fi
fi
local -a wrapper_cmd
eval "wrapper_cmd=(${PW_RUN_AFTER_EXE})"
if [[ ${#wrapper_cmd[@]} -eq 0 ]] ; then
@@ -4188,9 +4212,22 @@ portwine_launch () {
fi
done
else
local -a run_after_exes=() run_after_environment=()
local run_after_entry run_after_index
local run_after_valid=1
if [[ -f "${PW_RUN_AFTER_EXE:-}" ]] ; then
run_after_exes=("${PW_RUN_AFTER_EXE}")
else
IFS=',' read -r -a run_after_exes <<< "${PW_RUN_AFTER_EXE:-}"
fi
for run_after_entry in "${run_after_exes[@]}" ; do
if [[ ! -f "${run_after_entry}" || "${run_after_entry,,}" != *.exe ]] ; then
run_after_valid=0
fi
done
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
&& [[ "${run_after_valid}" == 1 ]] \
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
then
local wait_delay wait_delay_int run_after_bat
@@ -4214,6 +4251,7 @@ portwine_launch () {
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
fi
pw_exe_file_win="${pw_exe_file_win//\//\\}"
local PW_RUN_AFTER_EXE="${run_after_exes[0]}"
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
if [[ -z "${run_after_exe_win}" ]] ; then
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
@@ -4232,10 +4270,30 @@ timeout /t ${wait_delay_int} /nobreak
start "" "%PW_RUN_AFTER_SECOND_PATH%"
EOF
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
local run_after_first_win="${run_after_exe_win}"
for ((run_after_index=1; run_after_index<${#run_after_exes[@]}; run_after_index++)) ; do
run_after_entry="${run_after_exes[run_after_index]}"
run_after_exe_win="$("${WINELOADER}" winepath -w "${run_after_entry}" 2>/dev/null | tr -d '\r')"
if [[ -z "${run_after_exe_win}" ]] ; then
if [[ "${run_after_entry}" == "${wine_drive_c}/"* ]] ; then
run_after_exe_win="c:\\${run_after_entry#"${wine_drive_c}/"}"
else
run_after_exe_win="z:${run_after_entry//\//\\}"
fi
fi
run_after_environment+=("PW_RUN_AFTER_PATH_${run_after_index}=${run_after_exe_win//\//\\}")
printf 'start "" "%%PW_RUN_AFTER_PATH_%s%%"\r\n' "${run_after_index}" >> "${run_after_bat}"
done
(
if [[ ${#run_after_environment[@]} -gt 0 ]] ; then
export "${run_after_environment[@]}"
fi
PW_RUN_AFTER_EXE="" LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
PW_RUN_AFTER_SECOND_PATH="${run_after_first_win}" \
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
)
try_remove_file "${run_after_bat}"
else
case "${PW_EXE_FILE,,}" in
+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.
+19 -3
View File
@@ -18,8 +18,8 @@ _portprotonqt_completions() {
prev="${COMP_WORDS[COMP_CWORD-1]}"
# All available options
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
# Values for options with arguments
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
@@ -73,6 +73,14 @@ _portprotonqt() {
'--add-steam-compat-tool[Add as Steam compatibility tool]'
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
'--list-games[Print local game libraries as JSON]'
'--game-info[Print one game as JSON]:ID:'
'--edit-game[Edit game settings]:ID:'
'*--set[Set a game setting]:KEY=VALUE:'
'--all[Include uninstalled games in the library list]'
'--fields[Select JSON fields]:FIELD,...:'
'--pretty[Format JSON with indentation]'
'--raw[Print compact JSON]'
'--clear-cache[Clear PortProtonQt cache and exit]'
'--reset-settings[Reset PortProtonQt settings and exit]'
'--ppqtos[Show the system tab]'
@@ -94,13 +102,21 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
# Fish completion for portprotonqt
complete -c portprotonqt -f
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
complete -c portprotonqt -l add-steam-compat-tool -d "Add as Steam compatibility tool"
complete -c portprotonqt -l reinstall-steam-compat-tool -d "Reinstall Steam compatibility tool"
complete -c portprotonqt -l remove-steam-compat-tool -d "Remove Steam compatibility tool"
complete -c portprotonqt -l list-games -d "Print local game libraries as JSON"
complete -c portprotonqt -l game-info -d "Print one game as JSON" -r -f
complete -c portprotonqt -l edit-game -d "Edit game settings" -r -f
complete -c portprotonqt -l set -d "Set a game setting (KEY=VALUE)" -r -f
complete -c portprotonqt -l all -d "Include uninstalled games in the library list"
complete -c portprotonqt -l fields -d "Select JSON fields (comma-separated)" -r -f
complete -c portprotonqt -l pretty -d "Format JSON with indentation"
complete -c portprotonqt -l raw -d "Print compact JSON"
complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
complete -c portprotonqt -l ppqtos -d "Show the system tab"
+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()
+121 -121
View File
@@ -2,7 +2,7 @@ import argparse
import sys
import os
import subprocess
import signal
import shlex
import threading
import urllib.error
import urllib.request
@@ -11,17 +11,16 @@ from logging import Logger
import psutil
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (
QApplication,
QMenu,
QMessageBox,
QSystemTrayIcon,
)
from PySide6.QtGui import QAction, QIcon
from PySide6.QtGui import QIcon
from portprotonqt.config import (
consume_download_counter_skip,
@@ -66,6 +65,8 @@ if APP_VERSION == "@APP" "_VERSION@":
__app_version__ = APP_VERSION
RESTART_STOP_TIMEOUT_SECONDS = 30
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
def get_version():
@@ -82,103 +83,6 @@ def get_version():
return __app_version__
def stop_portproton_game(start_sh: list[str], logger: Logger) -> None:
try:
subprocess.run(
start_sh + ["cli", "--stop"],
capture_output=True,
text=True,
check=False,
timeout=30,
)
except (OSError, subprocess.SubprocessError) as e:
logger.warning("Failed to execute PortProton stop command: %s", e)
def get_portproton_tray_icon() -> QIcon:
from portprotonqt.theme_manager import ThemeManager
icon = ThemeManager().get_icon("tray_portproton", ui_config.get_theme())
if isinstance(icon, QIcon):
return icon
return QIcon()
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
"""Launch a game with minimal tray game controls."""
logger = get_logger(__name__)
app.setQuitOnLastWindowClosed(False)
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
tray_icon.setToolTip(__app_name__)
from datetime import datetime
from portprotonqt.time_utils import save_last_launch, save_playtime
save_last_launch(os.path.splitext(os.path.basename(exe_path))[0], datetime.now())
start_time = datetime.now()
process = subprocess.Popen(start_sh + [exe_path], env=os.environ.copy(), shell=False)
tray_menu = QMenu()
def end_silent_run():
monitor_timer.stop()
elapsed = int((datetime.now() - start_time).total_seconds())
if elapsed > 0:
save_playtime(exe_path, elapsed)
tray_icon.hide()
app.quit()
def stop_game() -> None:
stop_portproton_game(start_sh, logger)
end_silent_run()
def close_when_game_exits(_tray_menu: QMenu = tray_menu) -> None:
if process.poll() is not None:
end_silent_run()
def update_pause_action() -> list[psutil.Process]:
try:
children = psutil.Process(process.pid).children(recursive=True)
paused = bool(children) and all(
child.status() == psutil.STATUS_STOPPED for child in children
)
except (psutil.NoSuchProcess, psutil.AccessDenied):
children = []
paused = False
pause_action.setEnabled(bool(children))
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
return children
def toggle_pause() -> None:
children = update_pause_action()
if not children:
return
try:
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
for child in children:
try:
child.send_signal(pause_signal)
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
logger.warning("Failed to inspect game pause state: %s", error)
update_pause_action()
pause_action = QAction(_("Pause Game"), tray_menu)
pause_action.triggered.connect(toggle_pause)
tray_menu.addAction(pause_action)
tray_menu.aboutToShow.connect(update_pause_action)
stop_action = QAction(_("Stop Game"), tray_menu)
stop_action.triggered.connect(stop_game)
tray_menu.addAction(stop_action)
tray_icon.setContextMenu(tray_menu)
tray_icon.show()
monitor_timer = QTimer(app)
monitor_timer.timeout.connect(close_when_game_exits)
monitor_timer.start(1000)
def restore_prefix_backup(start_sh: list[str], backup_path: str) -> int:
"""Restore a PortProton prefix backup."""
logger = get_logger(__name__)
@@ -236,6 +140,38 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
def main():
parsed_args = parse_args()
if parsed_args.list_games or parsed_args.game_info or parsed_args.edit_game:
import orjson
from portprotonqt.cli import edit_game, list_games
try:
if parsed_args.edit_game:
result = edit_game(parsed_args.edit_game, parsed_args.settings)
elif parsed_args.game_info:
games = list_games()
result = next((game for game in games if game["id"] == parsed_args.game_info), None)
if result is None:
get_logger(__name__).error("Game not found: %s", parsed_args.game_info)
return 1
else:
result = [game for game in list_games() if parsed_args.all_games or game.get("installed", False)]
if parsed_args.fields:
entries = result if isinstance(result, list) else [result]
selected = [{field: game.get(field) for field in parsed_args.fields} for game in entries]
result = selected if isinstance(result, list) else selected[0]
option = orjson.OPT_INDENT_2 if parsed_args.pretty else 0
sys.stdout.write(orjson.dumps(result, option=option).decode("utf-8") + "\n")
except OSError as error:
get_logger(__name__).error("Library command failed: %s", error)
return 1
except ValueError as error:
get_logger(__name__).error("Invalid library command: %s", error)
return 1
except RuntimeError as error:
get_logger(__name__).error("Failed to edit game settings: %s", error)
return 1
return 0
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
@@ -393,7 +329,7 @@ def main():
if theme_store_id is not None:
ipc_message = f"theme:{theme_store_id}"
from PySide6.QtCore import QThread, Signal
from PySide6.QtCore import QLockFile, QStandardPaths, QThread, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from requests import RequestException
from portprotonqt.appimage_updater import (
@@ -419,7 +355,10 @@ def main():
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
# --- Single-instance logic ---
server_name = __app_id__
runtime_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
server_name = os.path.join(runtime_dir, __app_id__)
instance_lock = QLockFile(server_name + ".lock")
owns_instance = instance_lock.tryLock(0)
socket = QLocalSocket()
socket.connectToServer(server_name)
@@ -432,7 +371,11 @@ def main():
logger.info("Restored existing instance from tray")
return
# Remove old socket if it exists
if not owns_instance:
logger.warning("PortProtonQt is already running but its IPC server is unavailable")
return
# Only the lock owner can remove a stale socket.
QLocalServer.removeServer(server_name)
local_server = QLocalServer()
@@ -548,10 +491,7 @@ def main():
if silent_game_request:
window._initialize_tray(__app_name__)
assert window.tray_manager is not None
if exe_path:
window.tray_manager.tray_icon.hide()
else:
window.tray_manager.set_minimal_mode()
window.tray_manager.set_minimal_mode()
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
exec_line = f"{source}://launch/{app_id}"
@@ -576,11 +516,32 @@ def main():
loader = getattr(window, f"_load_{source}_games_async")
loader(open_card)
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
restart_target = None
def handle_game_request(
target: str, silent: bool, log_mode: bool = False, confirm: bool = False,
) -> None:
nonlocal restart_target
if restart_target is not None:
return
if silent and (confirm or window.game_processes):
message_box = QMessageBox(window)
message_box.setIcon(QMessageBox.Icon.Question)
message_box.setWindowTitle(__app_name__)
message_box.setText(_("PortProtonQt is already running. Stop it and all games, then restart for the new launch?"))
message_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
restart_target = target
app.quit()
return
if is_launch_file(target):
path = normalize_launch_path(target)
if silent:
run_silent_tray(app, start_sh, path)
window.toggleGame(shlex.quote(path))
return
window.handle_launch_exe(path, log_mode=log_mode)
return
@@ -665,7 +626,7 @@ def main():
if msg.startswith("silent:"):
target = unquote(msg[7:].strip())
handle_game_request(target, True)
handle_game_request(target, True, confirm=True)
elif msg.startswith("open:") or msg.startswith("log:"):
log_mode = msg.startswith("log:")
launch_path = unquote(msg.split(":", 1)[1].strip())
@@ -807,13 +768,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 +862,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 ---
@@ -922,7 +885,44 @@ def main():
app.aboutToQuit.connect(cleanup_on_exit)
sys.exit(app.exec())
exit_code = app.exec()
if restart_target is not None:
if window.game_processes:
window._terminate_game_processes()
window.resetPlayButton()
window._stopBackgroundWorkers()
try:
children = psutil.Process().children(recursive=True)
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
continue
alive = psutil.wait_procs(children, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
for child in alive:
try:
child.kill()
except psutil.NoSuchProcess:
continue
alive = psutil.wait_procs(alive, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
if alive:
logger.error("Cannot restart while child processes are still running")
sys.exit(1)
if start_sh:
subprocess.run(start_sh + ["cli", "--stop"], check=True,
timeout=RESTART_STOP_TIMEOUT_SECONDS)
instance_lock.unlock()
os.execv(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", restart_target])
except OSError as error:
logger.error("Failed to restart PortProtonQt: %s", error)
exit_code = 1
except psutil.Error as error:
logger.error("Failed to stop PortProtonQt child processes: %s", error)
exit_code = 1
except subprocess.SubprocessError as error:
logger.error("Failed to stop PortProton before restart: %s", error)
exit_code = 1
sys.exit(exit_code)
if __name__ == "__main__":
main()
+288
View File
@@ -2,6 +2,7 @@ import argparse
import os
import re
import shutil
import shlex
import sys
from pathlib import Path
@@ -17,6 +18,27 @@ def parse_args():
Parses command-line arguments.
"""
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
library = parser.add_mutually_exclusive_group()
library.add_argument(
"--list-games", action="store_true",
help="Print local PortProton, Steam, GOG and EGS library data as JSON and exit"
)
library.add_argument(
"--game-info", metavar="ID",
help="Print one game as JSON using its source-qualified ID from --list-games"
)
library.add_argument("--edit-game", metavar="ID", help="Edit a game's PortProton settings and print its updated data")
parser.add_argument("--set", dest="settings", action="append", default=[], metavar="KEY=VALUE",
help="Set a game setting; repeat to change multiple values with --edit-game")
parser.add_argument("--all", dest="all_games", action="store_true",
help="Include uninstalled games from cached libraries in --list-games")
parser.add_argument("--fields", metavar="FIELD,...",
help="Select JSON fields for --list-games or --game-info (e.g. executable,icon,name)")
formatting = parser.add_mutually_exclusive_group()
formatting.add_argument("--pretty", dest="pretty", action="store_true", default=True,
help="Format JSON with indentation (default)")
formatting.add_argument("--raw", dest="pretty", action="store_false",
help="Print compact JSON without indentation")
parser.add_argument(
"--fullscreen",
action="store_true",
@@ -106,10 +128,276 @@ def parse_args():
args = parser.parse_args()
launch_args = []
if args.settings and not args.edit_game:
parser.error("--set requires --edit-game")
if args.edit_game and not args.settings:
parser.error("--edit-game requires at least one --set KEY=VALUE")
if args.all_games and not args.list_games:
parser.error("--all requires --list-games")
if args.fields is not None:
if not (args.list_games or args.game_info):
parser.error("--fields requires --list-games or --game-info")
args.fields = [field.strip() for field in args.fields.split(",")]
if any(not re.fullmatch(r"[a-z][a-z_0-9]*", field) for field in args.fields):
parser.error("--fields requires comma-separated field names without empty entries")
args.launch_args = launch_args
return args
def _read_game_settings(path: Path) -> dict[str, str]:
"""Read literal shell assignments without executing configuration code."""
from portprotonqt.logger import get_logger
settings: dict[str, str] = {}
if not path.is_file():
return settings
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
get_logger(__name__).warning("Failed to read %s: %s", path, error)
return settings
for line in content.splitlines():
match = re.fullmatch(r"(?:export\s+)?([A-Za-z_][A-Za-z_0-9]*)=(.*)", line)
if not match:
match = re.fullmatch(r"check_variables ([A-Za-z_][A-Za-z_0-9]*) (.*)", line)
if not match:
continue
value = match[2]
if any(token in value for token in ("$", "`")):
continue
try:
lexer = shlex.shlex(value, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
parts = list(lexer)
except ValueError:
continue
if len(parts) <= 1:
settings[match[1]] = parts[0] if parts else ""
return settings
def _get_game_metadata(name: str, exec_line: str, executable: str | None) -> dict:
"""Read custom artwork and cached metadata without fetching remote resources."""
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.localization import get_metadata_language, read_metadata_translations
from portprotonqt.steam_api.api import get_cached_steam_game_info
from portprotonqt.time_utils import (
get_last_launch_timestamp, get_playtime_for_exe, get_statistics_path,
)
metadata = get_cached_steam_game_info(name, exec_line)
metadata.setdefault("name", name)
metadata.setdefault("description", "")
metadata.setdefault("cover", "")
if not executable:
return metadata
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
folder = Path(resolve_custom_data_dir(str(data_home / "PortProtonQt/custom_data"), executable))
metadata["custom_data_directory"] = str(folder)
metadata_file = folder / "metadata.txt"
if metadata_file.is_file():
translations = read_metadata_translations(str(metadata_file), get_metadata_language())
metadata.update({key: value for key, value in translations.items() if value})
for extension in COVER_IMAGE_EXTENSIONS:
cover = folder / f"cover{extension}"
if cover.is_file():
metadata["cover"] = str(cover)
break
metadata["custom_icon"] = get_custom_icon_path(executable)
metadata["playtime_seconds"] = get_playtime_for_exe(get_statistics_path(), executable)
metadata["last_launch_timestamp"] = get_last_launch_timestamp(Path(executable).stem)
return metadata
def _get_game_runtime(
location: Path, executable: str | None, variables: dict[str, str], overrides: dict[str, str]
) -> dict:
"""Export configured runtime values; never source PPDB files or launch Wine."""
user_conf = location / "data/user.conf"
ppdb = Path(f"{executable}.ppdb") if executable else None
settings = _read_game_settings(ppdb) if ppdb else {}
runner = settings.get("PW_WINE_USE", overrides.get("PW_DEFAULT_WINE_USE", variables.get("PW_WINE_USE")))
prefix = settings.get("PW_PREFIX_NAME", overrides.get("PW_DEFAULT_PREFIX_NAME", variables.get("PW_PREFIX_NAME")))
settings.update(overrides)
runner = overrides.get("PW_WINE_VER") or overrides.get("PW_WINE_USE", runner)
prefix = overrides.get("PW_PREFIX_NAME", prefix)
resolved_runner = runner
if runner == "PROTON_LG":
resolved_runner = variables.get("PW_PROTON_LG_VER")
elif runner and (runner == "WINE_LG" or runner.startswith("WINE_") and runner.endswith("_LG")):
resolved_runner = variables.get("PW_WINE_LG_VER")
runner_path = None
if resolved_runner and resolved_runner != "USE_SYSTEM_WINE":
runner_path = Path(resolved_runner) if Path(resolved_runner).is_absolute() else (
location / "data/dist" / resolved_runner.upper()
)
elif resolved_runner == "USE_SYSTEM_WINE":
wine = shutil.which("wine")
runner_path = Path(wine).parent.parent if wine else None
wine_directory = runner_path
if wine_directory and (wine_directory / "files").is_dir() and not (wine_directory / "dist").is_dir():
wine_directory /= "files"
wine_bin = "bin-wow64" if settings.get("PW_USE_WOW64") == "1" and wine_directory and (
wine_directory / "bin-wow64"
).is_dir() else "bin"
prefix_path = location / "data/prefixes" / prefix if prefix else None
return {
"wine_path": str(wine_directory / wine_bin / "wine") if wine_directory else None,
"wineserver_path": str(wine_directory / wine_bin / "wineserver") if wine_directory else None,
"runner": resolved_runner,
"runner_path": str(runner_path) if runner_path else None,
"runner_installed": runner_path.is_dir() if runner_path else False,
"prefix": prefix, "prefix_path": str(prefix_path) if prefix_path else None,
"settings": settings, "user_config": str(user_conf),
"ppdb_exists": ppdb.is_file() if ppdb else False,
}
def _list_store_games(source: str, location: str | None, variables: dict, overrides: dict) -> list[dict]:
"""Export a cached GOG or Epic library using the existing store backends."""
from portprotonqt.egs_api import EGSAPI
from portprotonqt.gog_api import GOGAPI
from portprotonqt.main_window import MainWindow
api = GOGAPI() if source == "gog" else EGSAPI()
installed = api.load_installed()
games = []
for record in api.load_library():
app_id = str(record.get("app_id", ""))
if not app_id:
continue
is_installed = api.is_game_installed(app_id, installed)
target = api.get_launch_target(app_id) if is_installed else None
name, cover = MainWindow._get_custom_store_data(source, app_id, str(record.get("title", app_id)))
metadata = _get_game_metadata(name, target, target) if target else {}
metadata.pop("name", None)
icon = metadata.pop("custom_icon", "") or record.get("icon", "")
games.append({
**{key: value for key, value in record.items() if key not in {"app_id", "title"}},
**metadata,
"id": f"{source}:{app_id}", "source": source, "name": name,
"description": record.get("description") or metadata.get("description", ""),
"cover": cover or record.get("cover") or metadata.get("cover", ""),
"icon": icon, "installed": is_installed,
"exec": f"{source}://{'launch' if is_installed else 'install'}/{app_id}",
"executable": target, "directory": str(Path(target).parent) if target else None,
"runtime": _get_game_runtime(Path(location), target, variables, overrides) if location and target else None,
})
return games
def _list_steam_games() -> list[dict]:
"""Export installed Steam games and their locally cached metadata."""
from portprotonqt.main_window import MainWindow
from portprotonqt.steam_api import get_steam_installed_games
from portprotonqt.steam_api.api import get_cached_full_steam_game_info
games = []
for name, appid, last_played, playtime in get_steam_installed_games():
metadata = get_cached_full_steam_game_info(appid, name)
name, cover = MainWindow._get_custom_store_data("steam", appid, name)
games.append({
**metadata,
"id": f"steam:{appid}", "source": "steam", "name": name,
"cover": cover or metadata.get("cover", ""),
"installed": True, "exec": f"steam://rungameid/{appid}",
"last_launch_timestamp": last_played, "playtime_seconds": playtime,
})
return games
def _list_portproton_games(location: str | None, variables: dict, overrides: dict) -> list[dict]:
"""Return local PortProton shortcuts without launching games or downloading metadata."""
from portprotonqt.config.portproton import extract_exec_target_path, parse_desktop_entry
if not location:
return []
games = []
for desktop_path in sorted(Path(location).glob("*.desktop")):
entry = parse_desktop_entry(str(desktop_path))
if entry is None:
continue
exec_line = entry.get("Exec", "")
executable = extract_exec_target_path(exec_line)
metadata = _get_game_metadata(entry.get("Name", desktop_path.stem), exec_line, executable)
icon = metadata.pop("custom_icon", "") or entry.get("Icon", "")
games.append({
**metadata,
"id": f"portproton:{desktop_path.name}", "source": "portproton",
"installed": bool(executable and Path(executable).is_file()),
"name": metadata.get("name") or entry.get("Name", desktop_path.stem),
"runtime": _get_game_runtime(Path(location), executable, variables, overrides),
"desktop_file": str(desktop_path),
"exec": exec_line,
"executable": executable,
"directory": str(Path(executable).parent) if executable else None,
"icon": icon,
"ppdb": f"{executable}.ppdb" if executable else None,
})
return games
def list_games() -> list[dict]:
"""Return locally available library data from all supported game sources."""
from portprotonqt.config.portproton import get_portproton_location, get_portproton_scripts_path
location = get_portproton_location()
scripts = get_portproton_scripts_path()
variables = _read_game_settings(Path(scripts) / "var") if scripts else {}
overrides = _read_game_settings(Path(location) / "data/user.conf") if location else {}
games = _list_portproton_games(location, variables, overrides)
games.extend(_list_steam_games())
for source in ("gog", "egs"):
games.extend(_list_store_games(source, location, variables, overrides))
return games
def edit_game(game_id: str, changes: list[str]) -> dict:
"""Update a game's PPDB through PortProton and verify the saved settings."""
import subprocess
from portprotonqt.config.portproton import WINDOWS_LAUNCH_EXTENSIONS, get_portproton_start_command
from portprotonqt.settings_manager import ADVANCED_SETTING_KEYS, get_toggle_settings
game = next((game for game in list_games() if game["id"] == game_id), None)
if game is None:
raise ValueError(f"Game not found: {game_id}")
executable = game.get("executable")
if (game.get("source") not in {"portproton", "gog", "egs"} or not executable
or not Path(executable).is_file() or Path(executable).suffix.lower() not in WINDOWS_LAUNCH_EXTENSIONS):
raise ValueError("Editing requires an installed Windows game managed by PortProton, GOG or EGS")
toggles = set(get_toggle_settings()) | {"PW_MANGOHUD", "PW_VKBASALT", "PW_GAMESCOPE"}
allowed = set(ADVANCED_SETTING_KEYS) | toggles | set((game.get("runtime") or {}).get("settings", {}))
values: dict[str, str] = {}
for change in changes:
key, separator, value = change.partition("=")
if not separator or key not in allowed or not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key):
raise ValueError(f"Unknown or invalid game setting: {key}")
if any(character in value for character in ("$", "`", "|", "\n", "\r", "\0")):
raise ValueError(f"Unsupported shell syntax in setting: {key}")
if key in toggles and value not in {"0", "1"}:
raise ValueError(f"{key} must be 0 or 1")
if key == "PW_PREFIX_NAME" and (not value or value in {".", ".."} or "/" in value or "\\" in value):
raise ValueError("PW_PREFIX_NAME must be a prefix name, not a path")
values[key] = value
if not values:
raise ValueError("At least one setting is required")
command = get_portproton_start_command()
if not command:
raise ValueError("PortProton start.sh was not found")
result = subprocess.run(
[*command, "cli", "--edit-db", executable, *[f"{key}={value}" for key, value in values.items()]],
capture_output=True, text=True, errors="replace", check=False,
)
if result.returncode:
raise RuntimeError(f"PortProton failed to edit game settings: {result.stderr.strip()}")
saved = _read_game_settings(Path(f"{executable}.ppdb"))
if any(saved.get(key) != value for key, value in values.items()):
raise RuntimeError("PortProton did not save the requested settings; check PPDB permissions")
return next(game for game in list_games() if game["id"] == game_id)
def add_steam_compat_tool(force_install: bool = False) -> bool:
"""
Add PortProtonQt as a Steam compatibility tool.
+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
@@ -581,6 +581,14 @@ def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
return hashed_dir
def get_custom_icon_path(exe_path: str) -> str:
"""Return the existing custom icon for an executable."""
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
path = os.path.join(folder, "icon.png")
return path if os.path.isfile(path) else ""
def create_desktop_file(
exe_path: str,
game_name: str | None = None,
@@ -606,6 +614,7 @@ def create_desktop_file(
"applications-games" if is_store_uri and not has_icon_source
else os.path.join(base_path, "img", f"{icon_name}.png")
)
icon_path = get_custom_icon_path(exe_path) or icon_path
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
if icon_path != "applications-games":
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
+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)
+190 -48
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(
@@ -355,8 +363,8 @@ class ContextMenuManager:
)
msg_box.setIcon(QMessageBox.Icon.Warning)
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
try:
@@ -384,8 +392,8 @@ class ContextMenuManager:
)
msg_box.setIcon(QMessageBox.Icon.Warning)
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
try:
@@ -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(self._get_safe_icon("cancel"), _("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(self._get_safe_icon("edit"), _("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
@@ -698,16 +803,24 @@ class ContextMenuManager:
if os.path.exists(menu_path)
else self.add_to_menu(game_card.name, game_card.exec_line)
)
if get_steam_home() is not None:
is_in_steam = is_game_in_steam(game_card.name)
steam_icon_name = "delete" if is_in_steam else "menu_steam"
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
steam_action.triggered.connect(
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
if is_in_steam
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
)
if (
game_card.game_source in ("portproton", "gog", "egs")
and not is_uninstalled_store_game
and get_steam_home() is not None
):
is_in_steam = is_game_in_steam(game_card.name)
steam_icon_name = "delete" if is_in_steam else "menu_steam"
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
steam_action.triggered.connect(
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
if is_in_steam
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()
@@ -805,7 +918,9 @@ class ContextMenuManager:
if not entry:
continue
exec_line = entry.get("Exec", entry.get("exec", ""))
if script_name.lower() in exec_line.lower():
exe_path = extract_exec_target_path(exec_line)
if (exe_path and resolve_custom_data_dir(custom_root, exe_path)
== os.path.dirname(metadata_path)) or script_name.lower() in exec_line.lower():
return file_path
except OSError:
continue
@@ -1054,14 +1169,17 @@ 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"))
msg_box.setText(_("Are you sure you want to delete '{game_name}'? This will remove the .desktop file and custom data.").format(game_name=game_name))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
reply = msg_box.exec()
if reply != QMessageBox.StandardButton.Yes:
return
@@ -1311,7 +1429,8 @@ class ContextMenuManager:
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
return
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
game_name=game_name, cover_path=cover_path, steam_appid=app_id,
exe_path=f"{source}://launch/{app_id}" if source in {"gog", "egs"} else None)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
new_name = dialog.nameEdit.text().strip()
@@ -1425,6 +1544,27 @@ class ContextMenuManager:
except OSError as error:
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
def _save_local_shortcut_name(self, exe_path: str, name: str) -> None:
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
game_dir = resolve_custom_data_dir(custom_root, exe_path)
os.makedirs(game_dir, exist_ok=True)
metadata_path = os.path.join(game_dir, "metadata.txt")
metadata_lines = []
if os.path.isfile(metadata_path):
with open(metadata_path, encoding="utf-8") as metadata_file:
metadata_lines = [line for line in metadata_file if not line.startswith(("name=", "name_"))]
clean_name = " ".join(name.splitlines()).strip()
temp_path = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=game_dir, delete=False) as metadata_file:
temp_path = metadata_file.name
metadata_file.write(f"name={clean_name}\n" + "".join(metadata_lines))
os.replace(temp_path, metadata_path)
finally:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
"""
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
@@ -1465,7 +1605,12 @@ class ContextMenuManager:
_("Game name and executable path are required")
)
return
old_path = self._get_desktop_path(game_name)
old_entry = parse_desktop_entry(old_path)
original_name = old_entry.get("Name", game_name) if old_entry else game_name
dialog.nameEdit.setText(original_name)
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
dialog.nameEdit.setText(new_name)
if not desktop_entry or not new_desktop_path:
self.signals.show_warning_dialog.emit(
_("Error"),
@@ -1473,10 +1618,15 @@ class ContextMenuManager:
)
return
old_path = self._get_desktop_path(game_name)
new_desktop_path = old_path
moved_paths = []
try:
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
if exe_path == new_exe_path and os.path.isfile(old_path):
with open(old_path, encoding="utf-8") as source:
desktop_entry = source.read()
if new_name != game_name:
self._save_local_shortcut_name(new_exe_path, new_name)
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
@@ -1495,13 +1645,6 @@ class ContextMenuManager:
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
if exe_path != new_exe_path:
self._relocate_shortcut_statistics(exe_path, new_exe_path)
if game_name != new_name and os.path.exists(old_path):
self._remove_file(
old_path,
_("Failed to delete old .desktop file: {error}"),
_("Removed old .desktop file for '{game_name}'"),
game_name
)
saved_entry = parse_desktop_entry(new_desktop_path)
updated_exec_line = new_exe_path
if saved_entry:
@@ -1521,11 +1664,9 @@ class ContextMenuManager:
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_folder = os.path.join(
xdg_data_home,
"PortProtonQt",
"custom_data",
get_custom_data_dir_name(new_exe_path),
custom_folder = resolve_custom_data_dir(
os.path.join(xdg_data_home, "PortProtonQt", "custom_data"),
new_exe_path,
)
os.makedirs(custom_folder, exist_ok=True)
@@ -1616,23 +1757,26 @@ class ContextMenuManager:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
if not exec_line.startswith(("gog://launch/", "egs://launch/")):
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
logger.debug("Adding game '%s' to Steam", game_name)
try:
success, message = add_to_steam(game_name, exec_line, cover_path)
if exec_line.startswith(("gog://launch/", "egs://launch/")):
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
success, message = add_to_steam(game_name, exec_line, cover_path, icon_source)
else:
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,16 +1798,14 @@ 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"),
_("'{game_name}' was removed from 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 removed from Steam.").format(game_name=game_name)
)
return
else:
self.signals.show_warning_dialog.emit(
_("Error"),
+15 -2
View File
@@ -1,8 +1,8 @@
import os
from itertools import accumulate
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
from PySide6.QtCore import QObject, Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
from PySide6.QtGui import QMouseEvent, QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
from PySide6.QtSvg import QSvgRenderer
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config import ui_config
@@ -18,6 +18,19 @@ class CustomComboBox(QComboBox):
super().__init__(parent)
self.theme = theme
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
line_edit = self.lineEdit()
if (
watched is line_edit and line_edit is not None and line_edit.isReadOnly()
and isinstance(event, QMouseEvent) and event.button() == Qt.MouseButton.LeftButton
):
if event.type() == QEvent.Type.MouseButtonPress:
return True
if event.type() == QEvent.Type.MouseButtonRelease:
self.showPopup()
return True
return super().eventFilter(watched, event)
def contextMenuEvent(self, event):
line_edit = self.lineEdit()
if line_edit is None:
+9 -1
View File
@@ -93,6 +93,9 @@ class DetailPageManager:
def openGameDetailPage(self, game_data: dict) -> None:
"""Open detailed game information page."""
self._current_detail_source = ("game", dict(game_data))
self._return_to_library_card_key = (
game_data.get("name", ""), game_data.get("exec_line", "")
)
detail_page = QWidget()
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
@@ -1480,7 +1483,7 @@ class DetailPageManager:
msg_box.setWindowTitle(_("Log Saved"))
msg_box.setText(_("Debug log saved to:") + f"\n{log_file}")
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
msg_box.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
result = msg_box.exec()
@@ -1638,10 +1641,15 @@ class DetailPageManager:
container = getattr(self.main_window, "gamesListWidget", None)
if container is None:
return
manager = getattr(self.main_window, "game_library_manager", None)
card_key = getattr(self, "_return_to_library_card_key", None)
card = getattr(manager, "game_card_cache", {}).get(card_key)
cards = [
widget for widget in container.findChildren(QWidget)
if widget.__class__.__name__ == "GameCard"
]
if card is not None and card.isVisible():
cards = [card]
if not cards:
return
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
+484 -3
View File
@@ -1,25 +1,36 @@
"""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.config.portproton import get_custom_icon_path, resolve_custom_data_dir
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
@@ -28,6 +39,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,16 +137,241 @@ 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
self.theme = theme if theme else theme_manager.apply_theme(ui_config.get_theme())
self.edit_mode = edit_mode
if exe_path is None and steam_appid is not None:
exe_path = f"steam://rungameid/{steam_appid}"
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"))
@@ -195,6 +432,19 @@ class AddGameDialog(DraggableDialog):
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
layout.addRow(cover_button_label, coverBrowseButton)
self.iconEdit = CustomLineEdit(self, theme=self.theme)
self.iconEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.iconEdit.setText(get_custom_icon_path(exe_path) if exe_path else "")
icon_label = QLabel(_("Custom Icon:"))
icon_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
icon_row = QHBoxLayout()
icon_row.addWidget(self.iconEdit)
icon_browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("search", as_path=True))
icon_browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
icon_browse.clicked.connect(self.browseIcon)
icon_row.addWidget(icon_browse)
layout.addRow(icon_label, icon_row)
self.add_to_steam_checkbox = QCheckBox()
self.add_to_menu_checkbox = QCheckBox()
self.add_to_desktop_checkbox = QCheckBox()
@@ -327,10 +577,44 @@ class AddGameDialog(DraggableDialog):
def accept(self):
"""Handle Apply button."""
icon_path = self.iconEdit.text().strip()
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
from PySide6.QtWidgets import QMessageBox
image = QPixmap(icon_path)
if image.isNull():
QMessageBox.warning(self, _("Error"), _("Invalid image"))
return
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
temporary_path = None
try:
os.makedirs(folder, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
temporary_path = temporary.name
if not image.save(temporary_path, "PNG"):
raise OSError("Failed to save icon")
os.replace(temporary_path, os.path.join(folder, "icon.png"))
except OSError as error:
logger.warning("Failed to save custom icon: %s", error)
QMessageBox.warning(self, _("Error"), str(error))
return
finally:
if temporary_path:
Path(temporary_path).unlink(missing_ok=True)
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().accept()
def browseIcon(self) -> None:
from portprotonqt.dialogs.file_explorer import FileExplorer
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
initial_path=os.path.dirname(self.iconEdit.text()) or None)
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
explorer.show()
def browseExe(self):
"""Open file manager to select executable or disc image."""
try:
@@ -338,6 +622,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 +641,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)
@@ -497,6 +976,8 @@ class AddGameDialog(DraggableDialog):
file_icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(launch_path)[1].lower())
file_icon_path = theme_manager.get_icon(file_icon_name, as_path=True) if file_icon_name else ""
if get_custom_icon_path(exe_path):
return desktop_entry, desktop_path
if not self.last_cover_path and file_icon_path:
desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", f"Icon={file_icon_path}\n")
elif not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128):
+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")),
]
+3 -1
View File
@@ -607,7 +607,9 @@ class FileExplorer(DraggableDialog):
if not self.directory_only:
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
if self.file_filter:
if isinstance(self.file_filter, str):
if callable(self.file_filter):
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
elif isinstance(self.file_filter, str):
files = [f for f in files if f.lower().endswith(self.file_filter)]
elif isinstance(self.file_filter, tuple):
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
+14 -23
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
@@ -356,14 +352,9 @@ class ProtonManager(DraggableDialog):
logger.error(f"Wine data loading failed: {error_msg}")
if hasattr(self, 'content_stack'):
self.content_stack.setCurrentIndex(1)
error_label = QLabel(_("Error loading WINE/Proton: {error}").format(error=error_msg))
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
if hasattr(self, 'tab_widget'):
error_tab = QWidget()
error_layout = QVBoxLayout(error_tab)
error_layout.addWidget(error_label)
self.tab_widget.addTab(error_tab, _("Error"))
self.tab_widget.clear()
self.create_installed_tab()
def process_metadata(self, metadata):
self.cpu_level = get_cpu_level()
@@ -683,7 +674,7 @@ class ProtonManager(DraggableDialog):
version_item = QTableWidgetItem(version_name)
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row_index, 1, version_item)
size_item = QTableWidgetItem(_("Unknown"))
size_item = QTableWidgetItem(_("Loading…"))
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row_index, 2, size_item)
for col in range(table.columnCount()):
@@ -962,8 +953,8 @@ class ProtonManager(DraggableDialog):
msg_box.setText(_("Are you sure you want to delete {} selected WINE/Proton?\n\nThis action cannot be undone.").format(len(versions_to_remove)))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
reply = msg_box.exec()
if reply != QMessageBox.StandardButton.Yes:
return
+265 -60
View File
@@ -29,6 +29,8 @@ from PySide6.QtWidgets import (
QLineEdit,
QMenu,
QMessageBox,
QSlider,
QPushButton,
QStackedWidget,
QTabWidget,
QTableWidget,
@@ -198,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 = {}
@@ -282,6 +290,11 @@ class ExeSettingsDialog(
"""Resolve run-after executable relative to the main executable."""
if not exe_path:
return exe_path
if "," in exe_path and not os.path.isfile(os.path.expanduser(exe_path)):
return ",".join(
self._resolve_run_after_exe_path(path.strip())
for path in exe_path.split(",") if path.strip()
)
normalized = os.path.normpath(os.path.expanduser(exe_path))
if os.path.isabs(normalized):
return normalized
@@ -292,6 +305,70 @@ class ExeSettingsDialog(
return normalized
return os.path.normpath(os.path.join(game_dir, normalized))
def _toggle_run_after_entries(self, target: QLineEdit) -> None:
table = self.advanced_table
row = next(index for index in range(table.rowCount())
if table.cellWidget(index, 1) is target.parentWidget())
expanded = bool(target.property("ppqt_run_after_expanded"))
target.setProperty("ppqt_run_after_expanded", not expanded)
if expanded:
while row + 1 < table.rowCount():
item = table.item(row + 1, 0)
if item is None or item.data(Qt.ItemDataRole.UserRole) != "run_after_entry":
break
table.removeRow(row + 1)
return
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
if not target.text():
entries = [""]
for index, path in enumerate(entries):
entry_row = row + index + 1
table.insertRow(entry_row)
item = QTableWidgetItem(path)
item.setData(Qt.ItemDataRole.UserRole, "run_after_entry")
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
table.setItem(entry_row, 0, item)
entry_edit = QLineEdit(path)
entry_edit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
entry_edit.installEventFilter(self)
entry_edit.textChanged.connect(
lambda value, offset=index, widget=target: self._update_run_after_entry(widget, offset, value)
)
table.setCellWidget(entry_row, 0, entry_edit)
button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.clicked.connect(
lambda _checked=False, offset=index, widget=target: self._remove_run_after_entry(widget, offset)
)
table.setCellWidget(entry_row, 1, button)
table.resizeRowsToContents()
def _update_run_after_entry(self, target: QLineEdit, index: int, value: str) -> None:
entries = target.text().split(",")
if index < len(entries):
entries[index] = value
target.setText(",".join(entries))
def _remove_run_after_entry(self, target: QLineEdit, index: int) -> None:
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
entries.pop(index)
self._toggle_run_after_entries(target)
target.setText(",".join(entries))
if entries:
self._toggle_run_after_entries(target)
target.setFocus(Qt.FocusReason.OtherFocusReason)
def _clear_run_after_entries(self, target: QLineEdit) -> None:
table = self.advanced_table
for row in reversed(range(table.rowCount())):
item = table.item(row, 0)
if item and item.data(Qt.ItemDataRole.UserRole) == "run_after_entry":
table.removeRow(row)
target.setProperty("ppqt_run_after_expanded", False)
target.clear()
if target.isVisible():
target.setFocus(Qt.FocusReason.OtherFocusReason)
def _get_setting_file_selector_path(self, current_path: str) -> str:
"""Get initial path for setting file selectors."""
initial_path = os.path.expanduser("~")
@@ -395,7 +472,7 @@ class ExeSettingsDialog(
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.advanced_table.horizontalHeader().resizeSection(1, 230)
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
self.advanced_table.setWordWrap(True)
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
@@ -472,7 +549,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)
@@ -480,25 +556,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)
@@ -506,6 +577,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)
@@ -625,6 +697,8 @@ class ExeSettingsDialog(
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."""
@@ -667,8 +741,8 @@ class ExeSettingsDialog(
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
@@ -688,8 +762,8 @@ class ExeSettingsDialog(
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
@@ -711,8 +785,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):
@@ -859,13 +933,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'])
@@ -887,7 +967,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)
@@ -938,10 +1020,12 @@ class ExeSettingsDialog(
if setting['key'] == 'PW_RUN_AFTER_EXE':
text_container = QWidget()
text_container.setProperty("ppqt_run_after_exe_widget", True)
text_layout = QHBoxLayout(text_container)
text_layout.setContentsMargins(0, 0, 0, 0)
text_layout.setSpacing(6)
text_layout.addWidget(line_edit)
container_layout = QVBoxLayout(text_container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.addWidget(line_edit)
line_edit.hide()
text_layout = QHBoxLayout()
container_layout.addLayout(text_layout)
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
@@ -953,26 +1037,38 @@ class ExeSettingsDialog(
def open_run_after_exe_selector(
_checked: bool = False,
target_line_edit=line_edit,
file_filter="",
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
):
from portprotonqt.dialogs.file_explorer import FileExplorer
initial_path = self._get_setting_file_selector_path(
target_line_edit.text().strip()
)
file_explorer = FileExplorer(
self,
theme=self.theme,
file_filter=file_filter,
initial_path=initial_path,
initial_path=self._get_setting_file_selector_path(
target_line_edit.text().rsplit(",", 1)[-1].strip()
),
)
file_explorer.file_signal.file_selected.connect(
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
lambda path, target=target_line_edit: target.setText(
",".join(filter(None, [target.text().strip(), os.path.normpath(path)]))
)
)
file_explorer.exec()
browse_button.clicked.connect(open_run_after_exe_selector)
expand_button = AutoSizeButton("≡")
expand_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
expand_button.clicked.connect(
lambda _checked=False, target=line_edit: self._toggle_run_after_entries(target)
)
clear_button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
clear_button.clicked.connect(
lambda _checked=False, target=line_edit: self._clear_run_after_entries(target)
)
text_layout.addWidget(clear_button)
text_layout.addWidget(expand_button)
text_layout.addWidget(browse_button)
self.advanced_table.setCellWidget(row, 1, text_container)
else:
@@ -1095,6 +1191,8 @@ class ExeSettingsDialog(
break
source_header = source_table.horizontalHeader()
if source_header.sectionResizeMode(1) == QHeaderView.ResizeMode.ResizeToContents:
source_table.resizeColumnToContents(1)
target_header = self.favorites_table.horizontalHeader()
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
target_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
@@ -1200,8 +1298,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", "")
@@ -1211,9 +1309,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:
@@ -1249,9 +1352,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():
@@ -1267,25 +1368,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:
@@ -1317,6 +1424,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:
@@ -1324,6 +1432,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():
@@ -1393,8 +1502,59 @@ class ExeSettingsDialog(
self._filter_vkbasalt_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
@@ -1426,6 +1586,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 = ''
@@ -1510,26 +1672,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
@@ -1540,33 +1718,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)
@@ -1625,15 +1815,20 @@ class ExeSettingsDialog(
item = table.item(row, 0)
if item is None:
continue
widget = table.cellWidget(row, 0)
if widget is not None and not isinstance(widget, QLabel):
continue
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
if text is None:
text = item.text()
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
item.setText("")
label = cast(QLabel | None, table.cellWidget(row, 0))
if label is None:
text = item.text()
label = QLabel()
label.setTextFormat(Qt.TextFormat.RichText)
label.setWordWrap(True)
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
item.setText("")
table.setCellWidget(row, 0, label)
label.setAutoFillBackground(False)
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
@@ -1646,8 +1841,8 @@ class ExeSettingsDialog(
palette.setBrush(QPalette.ColorRole.Text, item.foreground())
label.setAutoFillBackground(selected)
label.setPalette(palette)
label.setForegroundRole(QPalette.ColorRole.Text)
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
if label.foregroundRole() != QPalette.ColorRole.Text:
label.setForegroundRole(QPalette.ColorRole.Text)
name, separator, description = text.partition("\n")
formatted_text = escape(name)
if separator:
@@ -1663,7 +1858,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:
@@ -1678,10 +1877,10 @@ class ExeSettingsDialog(
return False
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),
@@ -1759,8 +1958,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:
@@ -1790,6 +1990,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)
+10 -4
View File
@@ -266,9 +266,11 @@ class EGSAPI:
data = self._load_json(self.config_dir / "installed.json", {})
return data if isinstance(data, dict) else {}
def is_game_installed(self, app_id: str) -> bool:
def is_game_installed(
self, app_id: str, installed_data: dict[str, dict] | None = None
) -> bool:
"""Return whether Legendary records a usable installation."""
return self.get_launch_target(app_id) is not None
return self.get_launch_target(app_id, installed_data) is not None
@staticmethod
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
@@ -285,9 +287,13 @@ class EGSAPI:
return True
return False
def get_launch_target(self, app_id: str) -> str | None:
def get_launch_target(
self, app_id: str, installed_data: dict[str, dict] | None = None
) -> str | None:
"""Return the installed game's Windows executable."""
installed = self.load_installed().get(app_id, {})
installed = (
installed_data if installed_data is not None else self.load_installed()
).get(app_id, {})
install_path = Path(str(installed.get("install_path", "")))
executable = str(installed.get("executable", "")).replace("\\", "/")
target = install_path.joinpath(*executable.split("/"))
+30 -12
View File
@@ -16,6 +16,7 @@ from PySide6.QtGui import (
QEnterEvent,
QFocusEvent,
QMouseEvent,
QKeyEvent,
QPaintEvent,
)
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
@@ -286,6 +287,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 +380,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()
@@ -873,10 +879,10 @@ class GameCard(AnimatedCard):
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
source_ribbons = [
(self.steam_visible and not self.vertical_layout, self.steamLabel),
(self.gog_visible and not self.vertical_layout, self.gogLabel),
(self.egs_visible and not self.vertical_layout, self.egsLabel),
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
(self.steam_visible, self.steamLabel),
(self.gog_visible, self.gogLabel),
(self.egs_visible, self.egsLabel),
(self.portproton_visible, self.portprotonLabel),
]
for is_visible, ribbon in source_ribbons:
@@ -1046,15 +1052,11 @@ class GameCard(AnimatedCard):
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
hidden_badges = self.badge_view_mode == "hidden"
show_source_ribbons = self.vertical_layout is not True
try:
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
self.portprotonLabel.setVisible(
self.portproton_visible and show_source_ribbons
)
self.steamLabel.setVisible(self.steam_visible)
self.gogLabel.setVisible(self.gog_visible)
self.egsLabel.setVisible(self.egs_visible)
self.portprotonLabel.setVisible(self.portproton_visible)
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
@@ -1256,6 +1258,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
@@ -1285,6 +1293,16 @@ class GameCard(AnimatedCard):
self.click()
super().mousePressEvent(event)
def keyPressEvent(self, event: QKeyEvent) -> None:
if event.key() == Qt.Key.Key_Space and self.context_menu_manager:
if self.context_menu_manager.card_selection_mode:
self.context_menu_manager.clear_card_selection()
else:
self.context_menu_manager.start_card_selection(self)
event.accept()
return
super().keyPressEvent(event)
def cleanup(self):
"""Clean up animations to prevent memory leaks when the card is destroyed."""
if hasattr(self, 'coverLabel') and self.coverLabel is not None:
+4
View File
@@ -8,6 +8,7 @@ import io
import pefile
from PIL import Image
from portprotonqt.config.base import CACHE_DIR
from portprotonqt.config.portproton import get_custom_icon_path
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -49,6 +50,9 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
def get_exe_icon_cache_path(exe_path: str) -> str:
"""Return the shared cache path for an executable icon."""
custom_icon = get_custom_icon_path(exe_path)
if custom_icon:
return custom_icon
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
return os.path.join(
CACHE_DIR,
+1 -1
View File
@@ -498,7 +498,7 @@ def load_pixmap_async(
logger.error(f"URL processing error {cover}: {e}")
# SteamGridDB (SGDB)
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
if cover and cover.startswith(("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/")):
try:
parts = cover.split("/")
filename = parts[-1] if parts else "sgdb_cover.png"
+22 -7
View File
@@ -86,9 +86,8 @@ class MouseEmulationThread(QThread):
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
}, name="Virtual DPad Mouse")
self.finished.emit(True)
# Store device in thread result
self._device = ui
self.finished.emit(True)
except PermissionError as e:
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
@@ -214,10 +213,6 @@ class InputManager(
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled:
# Initialize mouse emulation asynchronously to avoid blocking startup
QTimer.singleShot(0, self._async_enable_mouse_emulation)
# FileExplorer specific attributes
self.file_explorer = None
self.nav_timer = QTimer(self)
@@ -265,6 +260,8 @@ class InputManager(
def _on_mouse_emulation_finished(self, success: bool):
"""Handle mouse emulation thread completion."""
if not self.mouse_emulation_enabled:
return
if success and self.mouse_emulation_thread:
device = self.mouse_emulation_thread.get_device()
if device:
@@ -281,6 +278,22 @@ class InputManager(
self.emulation_active = self.mouse_emulation_enabled
if not self.emulation_active:
self.emulation_triggered = False
thread = self.mouse_emulation_thread
if thread and thread.isRunning():
return
if self.emulation_triggered:
if self.ui is None:
self._async_enable_mouse_emulation()
elif self.ui is not None:
try:
self.ui.close()
except Exception as e:
logger.error("EMUL: Error closing virtual mouse: %s", e)
self.ui = None
self.mouse_emulation_thread = None
self.stick_x_raw = 0
self.stick_y_raw = 0
self.scroll_accumulator = 0.0
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
"""Common navigation logic for game cards in a container."""
@@ -414,8 +427,10 @@ class InputManager(
# Stop the thread if still running
thread = self.mouse_emulation_thread
if thread and thread.isRunning():
if thread:
thread.wait()
if self.ui is None:
self.ui = thread.get_device()
self.mouse_emulation_thread = None
if self.ui:
+21 -9
View File
@@ -2,12 +2,14 @@ import time
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QDialog,
QLineEdit,
QListView,
QMenu,
QPushButton,
QSlider,
QStackedWidget,
QTableWidget,
@@ -133,10 +135,15 @@ class ButtonInputMixin(InputMixin):
return True
def _handle_combo_button(self, focused: QWidget | None, button_code: int) -> bool:
if isinstance(focused, QLineEdit) and focused.isReadOnly():
focused = self._find_parent_combo(focused)
if not isinstance(focused, QComboBox):
return False
if button_code in BUTTONS['confirm']:
focused.showPopup()
if focused.view().isVisible():
self._activate_list_view_selection(focused.view())
else:
focused.showPopup()
return True
if button_code in BUTTONS['back'] and focused.view().isVisible():
focused.hidePopup()
@@ -160,12 +167,11 @@ 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)
)
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
keyboard = getattr(active, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
if (
button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit)
and not focused.isReadOnly()
):
if keyboard:
keyboard.show_for_widget(focused)
return True
@@ -176,7 +182,7 @@ class ButtonInputMixin(InputMixin):
combo = self._find_parent_combo(focused)
if combo is not None and combo.isEditable():
line_edit = combo.lineEdit()
if line_edit is not None and keyboard:
if line_edit is not None and not line_edit.isReadOnly() and keyboard:
line_edit.setFocus()
keyboard.show_for_widget(line_edit)
return True
@@ -196,7 +202,7 @@ class ButtonInputMixin(InputMixin):
parent = parent.parentWidget()
return None
def _activate_list_view_selection(self, view: QListView) -> None:
def _activate_list_view_selection(self, view: QAbstractItemView) -> None:
index = view.currentIndex()
if not index.isValid():
return
@@ -205,6 +211,7 @@ class ButtonInputMixin(InputMixin):
combo.setCurrentIndex(index.row())
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
combo.activated.emit(index.row())
return
view.activated.emit(index)
view.clicked.emit(index)
@@ -246,6 +253,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()
+5 -1
View File
@@ -138,9 +138,13 @@ class FileExplorerInputMixin(InputMixin):
current_item = self.file_explorer.file_list.currentItem()
if current_item:
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
self.file_explorer.show_folder_context_menu(item_rect.center())
self.file_explorer.show_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
+6 -8
View File
@@ -4,7 +4,7 @@ from typing import cast
from shiboken6 import isValid
from PySide6.QtCore import QEvent, QObject, QPoint, Qt
from PySide6.QtGui import QKeySequence
from PySide6.QtGui import QKeyEvent, QKeySequence
from PySide6.QtWidgets import (
QAbstractScrollArea,
QApplication,
@@ -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")
@@ -134,7 +130,7 @@ class KeyboardInputMixin(InputMixin):
return True
if event.type() == QEvent.Type.KeyPress:
if self._redirect_gamecard_input_to_search(event):
if isinstance(event, QKeyEvent) and self._redirect_gamecard_input_to_search(event):
return True
key = self._qt_event_to_input_key(event)
if key is not None:
@@ -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)
@@ -239,7 +235,9 @@ class KeyboardInputMixin(InputMixin):
1: 'autoInstallSearchLineEdit',
}
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
def _redirect_gamecard_input_to_search(self, event: QKeyEvent) -> bool:
if event.key() == Qt.Key.Key_Space:
return False
focused = self._focused_widget()
if not isinstance(focused, GameCard):
return False
+39 -15
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
@@ -476,7 +476,14 @@ class SettingsInputMixin(InputMixin):
if table != self.settings_dialog.advanced_table:
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
entry_widget = table.cellWidget(row, 0)
if isinstance(entry_widget, QLineEdit):
entry_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
cell_widget = table.cellWidget(row, 1)
if isinstance(cell_widget, QPushButton):
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if isinstance(cell_widget, QComboBox):
line_edit = cell_widget.lineEdit()
if cell_widget.isEditable() and line_edit is not None:
@@ -492,15 +499,33 @@ class SettingsInputMixin(InputMixin):
and cell_widget.property("ppqt_run_after_exe_widget")
):
line_edit = cell_widget.findChild(QLineEdit)
if line_edit and line_edit.isEnabled():
if line_edit and line_edit.isEnabled() and not line_edit.isHidden():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return
button = cell_widget.findChild(QPushButton)
if button and button.isEnabled():
button.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
"""Move focus between inline line edit and button in settings cell."""
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
return False
focused = QApplication.focusWidget()
for row in range(table.rowCount()):
entry_widget = table.cellWidget(row, 0)
delete_button = table.cellWidget(row, 1)
if not isinstance(entry_widget, QLineEdit) or not isinstance(delete_button, QPushButton):
continue
if focused not in (entry_widget, delete_button):
continue
widget = delete_button if focused == entry_widget else entry_widget
if not widget.isEnabled():
return False
table.setCurrentCell(row, 1)
widget.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if table.currentColumn() != 1:
return False
@@ -508,15 +533,14 @@ class SettingsInputMixin(InputMixin):
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
return False
line_edit = cell_widget.findChild(QLineEdit)
button = cell_widget.findChild(QPushButton)
widgets = [cell_widget.findChild(QLineEdit), *cell_widget.findChildren(QPushButton)]
widgets = [widget for widget in widgets if widget and widget.isEnabled() and not widget.isHidden()]
focused = QApplication.focusWidget()
if value > 0 and focused == line_edit and button and button.isEnabled():
button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if focused in widgets:
index = widgets.index(focused) + (1 if value > 0 else -1)
if 0 <= index < len(widgets):
widgets[index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
return False
def _focus_first_row_in_current_settings_table(self):
@@ -22,14 +22,22 @@ msgstr ""
"X-Generator: Weblate 2026.5\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
msgstr ""
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
msgstr ""
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
msgid "Yes"
msgstr ""
msgid "No"
msgstr ""
msgid "Update complete"
msgstr ""
@@ -111,12 +119,6 @@ msgid ""
"cannot be undone."
msgstr ""
msgid "Yes"
msgstr ""
msgid "No"
msgstr ""
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr ""
@@ -142,9 +144,15 @@ msgstr ""
msgid "Failed to rename file: {error}"
msgstr ""
msgid "Select multiple"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
@@ -269,14 +277,6 @@ msgstr ""
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr ""
@@ -294,10 +294,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 ""
@@ -308,10 +304,6 @@ msgid ""
" take effect."
msgstr ""
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr ""
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr ""
@@ -471,6 +463,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 ""
@@ -886,6 +888,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 ""
@@ -901,6 +919,9 @@ msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Custom Icon:"
msgstr ""
msgid "Add shortcut to:"
msgstr ""
@@ -913,6 +934,30 @@ msgstr ""
msgid "Invalid image"
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 "Failed to download cover"
msgstr ""
@@ -928,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"
@@ -952,9 +1003,6 @@ msgstr ""
msgid "Next Tab"
msgstr ""
msgid "Save"
msgstr ""
msgid "Search"
msgstr ""
@@ -1096,9 +1144,6 @@ msgstr ""
msgid "Successfully removed {} WINE/Proton."
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "All selected WINE/Proton have been downloaded!"
msgstr ""
@@ -1186,9 +1231,6 @@ msgstr ""
msgid "Failed to open file:\n"
msgstr ""
msgid "Information"
msgstr ""
msgid "Confirm PPDB Clear"
msgstr ""
@@ -2117,9 +2159,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
msgid "Download process failed"
msgstr ""
msgid "Quick Launch"
msgstr ""
@@ -2192,6 +2231,9 @@ msgid ""
"will remove the .desktop files and custom data."
msgstr ""
msgid "Failed to install theme"
msgstr ""
msgid "PortProton Settings"
msgstr ""
@@ -2255,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 ""
@@ -2475,16 +2520,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-18 23:41+0500\n"
"PO-Revision-Date: 2026-10-12 19:09+0000\n"
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/de/>\n"
@@ -21,14 +21,24 @@ msgstr ""
"X-Generator: Weblate 2026.10.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
msgstr "Spiel beenden"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
msgstr "Kein Lese/Schreibzugriff auf den Ordner. Wähle anderen Benutzerordner."
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
"PortProtonQt läuft bereits. Möchtest du es sowie alle Spiele beenden und "
"anschließend PortProtonQt neu starten?"
msgid "Yes"
msgstr "Ja"
msgid "No"
msgstr "Nein"
msgid "Update complete"
msgstr "Aktualisierung abgeschlossen"
@@ -114,12 +124,6 @@ msgstr ""
"Soll der Ordner '{folder_name}' wirklich gelöscht werden? Dieser Vorgang ist "
"unwiderruflich."
msgid "Yes"
msgstr "Ja"
msgid "No"
msgstr "Nein"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "Ordner konnte nicht gelöscht werden: {error}"
@@ -147,9 +151,15 @@ msgstr "Die Datei existiert bereits: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Datei konnte nicht umbenannt werden: {error}"
msgid "Select multiple"
msgstr "Mehrere auswählen"
msgid "Delete from PortProton"
msgstr "Von PortProton löschen"
msgid "Stop Game"
msgstr "Spiel beenden"
msgid "Launch Game"
msgstr "Spiel starten"
@@ -279,14 +289,6 @@ msgstr "Fehler beim Generieren der .desktop-Datei"
msgid "Failed to save .desktop file: {error}"
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Die alte .desktop-Datei für '{game_name}' wurde entfernt"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
@@ -306,10 +308,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}"
@@ -322,10 +320,6 @@ msgstr ""
"'{game_name}' wurde von Steam entfernt. Bitte starte Steam neu, damit die "
"Änderungen wirksam werden."
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "'{game_name}' wurde von Steam entfernt."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "Das Spiel '{game_name}' konnte nicht von Steam entfernt werden: {error}"
@@ -483,6 +477,20 @@ 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 ""
"In den Pfaden wurden kyrillische Zeichen gefunden. Wine funktioniert unter "
"Umständen nicht zuverlässig. Verschiebe das Spiel oder PortProton an einen "
"Pfad, der keine kyrillischen Zeichen enthält:\n"
"\n"
msgid "Don't remind again"
msgstr "Nicht erneut erinnern"
msgid "Game not found."
msgstr "Spiel nicht gefunden."
@@ -958,6 +966,22 @@ msgstr "AppImage aktualisieren..."
msgid "Launching {0}"
msgstr "Start {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr "Scannen: {folder}"
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr "Überprüfung vorhandener Verknüpfungen: {path}"
#, python-brace-format
msgid "Adding: {name}"
msgstr "Hinzufügen: {name}"
#, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Verknüpfung konnte nicht erstellt werden: {name}"
msgid "Name:"
msgstr "Titel:"
@@ -973,6 +997,10 @@ msgstr "Eigenes Titelbild:"
msgid "Enter local path or URL for cover image"
msgstr "Lokaler Pfad oder die URL für das Titelbild"
#, fuzzy
msgid "Custom Icon:"
msgstr "Eigenes Titelbild:"
msgid "Add shortcut to:"
msgstr "Verknüpfung hinzufügen zu:"
@@ -985,6 +1013,32 @@ msgstr "Anwenden"
msgid "Invalid image"
msgstr "Ungültiges Bild"
msgid "Select folder"
msgstr "Ordner auswählen"
msgid "Processing games…"
msgstr "Spiele werden verarbeitet…"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
"Verarbeitet {completed}/{total}\n"
"{current}"
msgid "Information"
msgstr "Information"
msgid "No games found in the selected folder."
msgstr "Im ausgewählten Ordner wurden keine Spiele gefunden."
msgid "Select games"
msgstr "Spiele auswählen"
msgid "Close"
msgstr "Schließen"
msgid "Failed to download cover"
msgstr "Herunterladen des Titelbildes fehlgeschlagen"
@@ -1002,8 +1056,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 +1086,6 @@ msgstr "Vorheriger Tab"
msgid "Next Tab"
msgstr "Nächster Tab"
msgid "Save"
msgstr "Speichern"
msgid "Search"
msgstr "Suchen"
@@ -1176,9 +1233,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 +1320,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 +2266,6 @@ msgstr "Fehlgeschlagen"
msgid "Repaired"
msgstr "Repariert"
msgid "Download process failed"
msgstr "Download-Vorgang ist fehlgeschlagen"
msgid "Quick Launch"
msgstr "Schnellstart"
@@ -2292,6 +2340,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 +2406,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 "Warnung bei kyrillischen Pfaden deaktivieren"
msgid "Disable ALT Linux i586 dependency check"
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
@@ -2579,15 +2633,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,9 +21,6 @@ msgstr ""
"X-Generator: Weblate 2026.8.1\n"
"Generated-By: Babel 2.17.0\n"
msgid "Stop Game"
msgstr "Detener Juego"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
@@ -31,6 +28,17 @@ msgstr ""
"La carpeta de datos de PortProton no tiene permisos de lectura ni escritura. "
"Elija otra carpeta para los datos de PortProton."
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
msgid "Yes"
msgstr "Sí"
msgid "No"
msgstr "No"
msgid "Update complete"
msgstr "Actualización completada"
@@ -116,12 +124,6 @@ msgstr ""
"¿Estás seguro de que quieres eliminar la carpeta «{folder_name}»? Esta "
"acción no se puede deshacer."
msgid "Yes"
msgstr "Sí"
msgid "No"
msgstr "No"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "No se ha podido eliminar la carpeta: {error}"
@@ -149,9 +151,16 @@ 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"
msgid "Stop Game"
msgstr "Detener Juego"
msgid "Launch Game"
msgstr "Iniciar juego"
@@ -281,14 +290,6 @@ msgstr "No se han podido generar los datos del archivo .desktop"
msgid "Failed to save .desktop file: {error}"
msgstr "No se ha podido guardar el archivo .desktop: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Se ha eliminado el antiguo archivo .desktop de «{game_name}»"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "No se ha podido copiar la imagen de portada: {error}"
@@ -308,10 +309,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}"
@@ -324,10 +321,6 @@ msgstr ""
"«{game_name}» se ha eliminado de Steam. Por favor, reinicia Steam para que "
"los cambios surtan efecto."
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "«{game_name}» se ha eliminado de Steam."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "No se ha podido eliminar el juego «{game_name}» de Steam: {error}"
@@ -488,6 +481,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."
@@ -977,6 +980,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:"
@@ -992,6 +1011,10 @@ msgstr "Portada personalizada:"
msgid "Enter local path or URL for cover image"
msgstr "Ingrese la ruta local o la URL de la imagen de portada"
#, fuzzy
msgid "Custom Icon:"
msgstr "Portada personalizada:"
msgid "Add shortcut to:"
msgstr "Agregar acceso directo a:"
@@ -1004,6 +1027,33 @@ msgstr "Aplicar"
msgid "Invalid image"
msgstr "Imagen no válida"
#, 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 "Failed to download cover"
msgstr "No se ha podido descargar la portada"
@@ -1019,7 +1069,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 +1100,6 @@ msgstr "Pestaña anterior"
msgid "Next Tab"
msgstr "Pestaña siguiente"
msgid "Save"
msgstr "Guardar"
msgid "Search"
msgstr "Buscar"
@@ -1191,9 +1245,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 +1335,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 +2274,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
#, fuzzy
msgid "Download process failed"
msgstr "Descargar seleccionados"
msgid "Quick Launch"
msgstr ""
@@ -2307,6 +2351,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 +2417,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 +2642,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 ""
+84 -45
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-24 18:13+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,14 +18,22 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
msgstr ""
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
msgstr ""
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
msgid "Yes"
msgstr ""
msgid "No"
msgstr ""
msgid "Update complete"
msgstr ""
@@ -107,12 +115,6 @@ msgid ""
"cannot be undone."
msgstr ""
msgid "Yes"
msgstr ""
msgid "No"
msgstr ""
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr ""
@@ -138,9 +140,15 @@ msgstr ""
msgid "Failed to rename file: {error}"
msgstr ""
msgid "Select multiple"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
@@ -265,14 +273,6 @@ msgstr ""
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr ""
@@ -290,10 +290,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 ""
@@ -304,10 +300,6 @@ msgid ""
" take effect."
msgstr ""
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr ""
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr ""
@@ -467,6 +459,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 ""
@@ -882,6 +884,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 ""
@@ -897,6 +915,9 @@ msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Custom Icon:"
msgstr ""
msgid "Add shortcut to:"
msgstr ""
@@ -909,6 +930,30 @@ msgstr ""
msgid "Invalid image"
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 "Failed to download cover"
msgstr ""
@@ -924,7 +969,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 +999,6 @@ msgstr ""
msgid "Next Tab"
msgstr ""
msgid "Save"
msgstr ""
msgid "Search"
msgstr ""
@@ -1092,9 +1140,6 @@ msgstr ""
msgid "Successfully removed {} WINE/Proton."
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "All selected WINE/Proton have been downloaded!"
msgstr ""
@@ -1182,9 +1227,6 @@ msgstr ""
msgid "Failed to open file:\n"
msgstr ""
msgid "Information"
msgstr ""
msgid "Confirm PPDB Clear"
msgstr ""
@@ -2113,9 +2155,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
msgid "Download process failed"
msgstr ""
msgid "Quick Launch"
msgstr ""
@@ -2188,6 +2227,9 @@ msgid ""
"will remove the .desktop files and custom data."
msgstr ""
msgid "Failed to install theme"
msgstr ""
msgid "PortProton Settings"
msgstr ""
@@ -2251,6 +2293,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 +2517,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-11-01 00:08+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,12 +18,9 @@ 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 "Stop Game"
msgstr "Parar o Jogo"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
@@ -31,6 +28,19 @@ msgstr ""
"A pasta de dados do PortProton não pode ser lida nem gravada. Escolha "
"outra pasta para os dados do PortProton."
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
"O PortProtonQt já está em execução. Deseja encerrá-lo junto com todos os "
"jogos abertos e reiniciá-lo para iniciar uma nova sessão?"
msgid "Yes"
msgstr "Sim"
msgid "No"
msgstr "Não"
msgid "Update complete"
msgstr "Atualização concluída"
@@ -114,12 +124,6 @@ msgstr ""
"Tem certeza de que deseja excluir a pasta '{folder_name}'? Esta ação não "
"pode ser desfeita."
msgid "Yes"
msgstr "Sim"
msgid "No"
msgstr "Não"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "Falha ao excluir a pasta: {error}"
@@ -147,9 +151,15 @@ msgstr "A pasta já existe: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Falha ao renomear o arquivo: {error}"
msgid "Select multiple"
msgstr "Selecionar vários"
msgid "Delete from PortProton"
msgstr "Excluir do PortProton"
msgid "Stop Game"
msgstr "Parar o Jogo"
msgid "Launch Game"
msgstr "Iniciar Jogo"
@@ -279,14 +289,6 @@ msgstr "Falha ao gerar os dados do arquivo .desktop"
msgid "Failed to save .desktop file: {error}"
msgstr "Falha ao salvar o arquivo .desktop: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Arquivo .desktop antigo de '{game_name}' removido"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Falha ao copiar a imagem de capa: {error}"
@@ -306,10 +308,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}"
@@ -322,10 +320,6 @@ msgstr ""
"'{game_name}' foi removido da Steam. Reinicie à Steam para que as "
"alterações tenham efeito."
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "'{game_name}' foi removido da Steam."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "Falha ao remover o jogo '{game_name}' da Steam: {error}"
@@ -482,6 +476,20 @@ 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 ""
"Foram encontrados caracteres cirílicos nos seguintes caminhos. O Wine pode "
"funcionar de forma instável. Mova o jogo ou o PortProton para um caminho sem "
"caracteres cirílicos:\n"
"\n"
msgid "Don't remind again"
msgstr "Não lembrar novamente"
msgid "Game not found."
msgstr "Jogo não encontrado."
@@ -959,6 +967,22 @@ msgstr "Atualizando AppImage..."
msgid "Launching {0}"
msgstr "Iniciando {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr "Verificado: {folder}"
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr "Verificando os atalhos existentes: {path}"
#, python-brace-format
msgid "Adding: {name}"
msgstr "Adicionando: {name}"
#, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Falha ao criar atalho: {name}"
msgid "Name:"
msgstr "Nome:"
@@ -974,6 +998,9 @@ msgstr "Capa Personalizada:"
msgid "Enter local path or URL for cover image"
msgstr "Informe o caminho local ou a URL da imagem de capa"
msgid "Custom Icon:"
msgstr "Ícone personalizado:"
msgid "Add shortcut to:"
msgstr "Adicionar atalho em:"
@@ -986,6 +1013,32 @@ msgstr "Aplicar"
msgid "Invalid image"
msgstr "Imagem inválida"
msgid "Select folder"
msgstr "Selecionar pasta"
msgid "Processing games…"
msgstr "Processando jogos…"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
"Processados: {completed}/{total}\n"
"{current}"
msgid "Information"
msgstr "Informação"
msgid "No games found in the selected folder."
msgstr "Nenhum jogo encontrado na pasta selecionada."
msgid "Select games"
msgstr "Selecionar jogos"
msgid "Close"
msgstr "Fechar"
msgid "Failed to download cover"
msgstr "Falha ao baixar a capa"
@@ -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,8 +2403,11 @@ 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 Cyrillic path warning"
msgstr "Desativar aviso de caminho em cirílico"
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
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-26 12: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,12 +19,9 @@ 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 "Stop Game"
msgstr "Остановить игру"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
@@ -32,6 +29,19 @@ msgstr ""
"Папка с данными PortProton не доступна для чтения и записи. Выберите "
"другую папку для данных PortProton."
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
"PortProtonQt уже запущен. Остановить его и все игры, а затем перезапустить "
"для нового запуска?"
msgid "Yes"
msgstr "Да"
msgid "No"
msgstr "Нет"
msgid "Update complete"
msgstr "Обновление завершено"
@@ -115,12 +125,6 @@ msgstr ""
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
"отменить."
msgid "Yes"
msgstr "Да"
msgid "No"
msgstr "Нет"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "Не удалось удалить папку: {error}"
@@ -147,9 +151,15 @@ msgstr "Файл уже существует: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Не удалось переименовать файл: {error}"
msgid "Select multiple"
msgstr "Выбрать несколько"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
msgid "Stop Game"
msgstr "Остановить игру"
msgid "Launch Game"
msgstr "Запустить игру"
@@ -277,14 +287,6 @@ msgstr "Не удалось сгенерировать данные файла .
msgid "Failed to save .desktop file: {error}"
msgstr "Не удалось сохранить файл .desktop: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "Не удалось удалить старый файл .desktop: {error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Удалён старый файл .desktop для '{game_name}'"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Не удалось скопировать обложку: {error}"
@@ -304,10 +306,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}"
@@ -320,10 +318,6 @@ msgstr ""
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
" чтобы изменения вступили в силу."
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "'{game_name}' был(а) удален(а) из Steam."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "Не удалось удалить игру '{game_name}' из Steam: {error}"
@@ -480,6 +474,19 @@ 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 ""
"В следующих путях обнаружены символы кириллицы. Wine может работать "
"неправильно. Переместите игру или PortProton в путь без символов кириллицы:\n"
"\n"
msgid "Don't remind again"
msgstr "Больше не напоминать"
msgid "Game not found."
msgstr "Игра не найдена."
@@ -747,14 +754,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"
@@ -958,6 +965,22 @@ msgstr "Обновление AppImage..."
msgid "Launching {0}"
msgstr "Запуск {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr "Сканирование: {folder}"
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr "Проверка существующих ярлыков: {path}"
#, python-brace-format
msgid "Adding: {name}"
msgstr "Добавление: {name}"
#, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Не удалось создать ярлык: {name}"
msgid "Name:"
msgstr "Название:"
@@ -973,6 +996,9 @@ msgstr "Пользовательская обложка:"
msgid "Enter local path or URL for cover image"
msgstr "Введите локальный путь или URL обложки"
msgid "Custom Icon:"
msgstr "Пользовательский значок:"
msgid "Add shortcut to:"
msgstr "Добавить ярлык:"
@@ -985,6 +1011,32 @@ msgstr "Применить"
msgid "Invalid image"
msgstr "Недопустимое изображение"
msgid "Select folder"
msgstr "Выбрать папку"
msgid "Processing games…"
msgstr "Обработка игр…"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
"Обработано {completed}/{total}\n"
"{current}"
msgid "Information"
msgstr "Информация"
msgid "No games found in the selected folder."
msgstr "В выбранной папке игры не найдены."
msgid "Select games"
msgstr "Выбрать игры"
msgid "Close"
msgstr "Закрыть"
msgid "Failed to download cover"
msgstr "Не удалось скачать обложку"
@@ -1002,8 +1054,14 @@ msgstr ""
"Программа остановилась. Информация о совместимости была собрана "
"автоматически."
msgid "Close"
msgstr "Закрыть"
msgid "Downloading…"
msgstr "Скачивание…"
msgid "Download process failed"
msgstr "Ошибка процесса загрузки"
msgid "Downloading Complete"
msgstr "Скачивание завершено"
msgid "Open"
msgstr "Открыть"
@@ -1026,9 +1084,6 @@ msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
msgid "Save"
msgstr "Сохранить"
msgid "Search"
msgstr "Поиск"
@@ -1176,9 +1231,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 +1317,6 @@ msgstr "Не удалось открыть файл PPDB:\n"
msgid "Failed to open file:\n"
msgstr "Не удалось открыть файл:\n"
msgid "Information"
msgstr "Информация"
msgid "Confirm PPDB Clear"
msgstr "Подтвердите очистку PPDB"
@@ -1547,7 +1596,7 @@ msgid "Failed to save custom preset."
msgstr "Ошибка сохранения пользовательских настроек."
msgid "Overlay position"
msgstr "Позиция MangoHud"
msgstr "Положение MangoHud"
msgid "Device battery"
msgstr "Батарея устройства"
@@ -1562,7 +1611,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 +1980,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 +2255,6 @@ msgstr "Ошибка"
msgid "Repaired"
msgstr "Исправлено"
msgid "Download process failed"
msgstr "Ошибка процесса загрузки"
msgid "Quick Launch"
msgstr "Быстрый запуск"
@@ -2284,6 +2329,9 @@ msgstr ""
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
"приведёт к удалению файлов .desktop и пользовательских данных."
msgid "Failed to install theme"
msgstr "Не удалось установить тему"
msgid "PortProton Settings"
msgstr "Настройки PortProtonQt"
@@ -2347,6 +2395,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 +2618,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 "Выбрать тему:"
@@ -7,9 +7,9 @@
msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
"Language-Team: Chinese (Simplified Han script) <https://"
"translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/>\n"
@@ -18,17 +18,25 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.6.1\n"
"X-Generator: Weblate 2026.10.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
msgstr "停止游戏"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择其他文件夹。"
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr "PortProtonQt 已 stacked运行。请关闭它及所有游戏,然后重新启动以应用新设置?"
msgid "Yes"
msgstr "是"
msgid "No"
msgstr "否"
msgid "Update complete"
msgstr "更新完成"
@@ -110,12 +118,6 @@ msgid ""
"cannot be undone."
msgstr "确定要删除文件夹\"{folder_name}\"吗?此操作无法撤销。"
msgid "Yes"
msgstr "是"
msgid "No"
msgstr "否"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "删除文件夹失败:{error}"
@@ -141,15 +143,20 @@ msgstr "文件已存在:{file_name}"
msgid "Failed to rename file: {error}"
msgstr "重命名文件失败:{error}"
msgid "Select multiple"
msgstr "多选"
msgid "Delete from PortProton"
msgstr "从 PortProton 中删除"
msgid "Stop Game"
msgstr "停止游戏"
msgid "Launch Game"
msgstr "启动游戏"
#, fuzzy
msgid "Repair"
msgstr "已配对"
msgstr "修复"
msgid "Remove from Desktop"
msgstr "从桌面移除"
@@ -167,7 +174,7 @@ msgid "Delete"
msgstr "删除"
msgid "Import"
msgstr ""
msgstr "导入"
msgid "Edit Shortcut"
msgstr "编辑快捷方式"
@@ -251,9 +258,9 @@ msgstr "桌面"
msgid "Failed to save custom data: {error}"
msgstr "保存自定义数据失败:{error}"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Executable not found"
msgstr "找不到可执行文件:{0}"
msgstr "未找到可执行文件"
#, python-brace-format
msgid "Executable not found: {0}"
@@ -273,14 +280,6 @@ msgstr "生成 .desktop 文件数据失败"
msgid "Failed to save .desktop file: {error}"
msgstr "保存 .desktop 文件失败:{error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "删除旧的 .desktop 文件失败:{error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "已移除\"{game_name}\"的旧版 .desktop 文件"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "复制封面图像失败:{error}"
@@ -298,10 +297,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}"
@@ -312,10 +307,6 @@ msgid ""
" take effect."
msgstr "\"{game_name}\"已从 Steam 中移除。请重启 Steam 以使更改生效。"
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "\"{game_name}\"已从 Steam 中移除。"
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "无法从 Steam 中移除游戏\"{game_name}\":{error}"
@@ -463,19 +454,32 @@ msgstr "缺少 32 位依赖项。现在安装吗?"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
msgstr "导入('{0}'及其安装文件夹中的所有文件)?"
#, fuzzy
msgid "Select Epic game folder"
msgstr "删除文件夹"
msgid "Another Epic operation is running"
msgstr ""
msgstr "另一个 Epic 操作正在运行中"
#, fuzzy
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 ""
"在以下路径中找到了西里尔字母字符。Wine 可能会运行不稳定。请将游戏或 "
"PortProton 移动到不包含西里尔字母字符的路径:\n"
"\n"
msgid "Don't remind again"
msgstr "不再提示"
msgid "Game not found."
msgstr "未找到游戏。"
@@ -925,6 +929,22 @@ msgstr "正在更新 AppImage..."
msgid "Launching {0}"
msgstr "正在启动 {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr "正在扫描:{folder}"
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr "正在检查现有快捷方式:{path}"
#, python-brace-format
msgid "Adding: {name}"
msgstr "正在添加:{name}"
#, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "创建快捷方式失败:{name}"
msgid "Name:"
msgstr "名称:"
@@ -940,6 +960,10 @@ msgstr "自定义封面:"
msgid "Enter local path or URL for cover image"
msgstr "输入封面图像的本地路径或 URL"
#, fuzzy
msgid "Custom Icon:"
msgstr "自定义封面:"
msgid "Add shortcut to:"
msgstr "添加快捷方式到:"
@@ -952,6 +976,35 @@ msgstr "应用"
msgid "Invalid image"
msgstr "图像无效"
#, fuzzy
msgid "Select folder"
msgstr "删除文件夹"
msgid "Processing games…"
msgstr "正在处理游戏…"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
"已处理:{completed}/{total}\n"
"{current}"
msgid "Information"
msgstr "信息"
msgid "No games found in the selected folder."
msgstr "在所选文件夹中未找到任何游戏。"
#, fuzzy
msgid "Select games"
msgstr "最近游戏"
#, fuzzy
msgid "Close"
msgstr "鼠标"
msgid "Failed to download cover"
msgstr "下载封面失败"
@@ -962,14 +1015,20 @@ msgid "No cover selected"
msgstr "未选择封面"
msgid "Compatibility report"
msgstr ""
msgstr "兼容性报告"
msgid "The program stopped. Compatibility details were collected automatically."
msgstr ""
msgstr "程序已停止。已自动收集兼容性详细信息。"
msgid "Downloading…"
msgstr "正在下载…"
#, fuzzy
msgid "Close"
msgstr "鼠标"
msgid "Download process failed"
msgstr "下载进行中"
msgid "Downloading Complete"
msgstr "下载完成"
msgid "Open"
msgstr "打开"
@@ -992,9 +1051,6 @@ msgstr "上一标签页"
msgid "Next Tab"
msgstr "下一标签页"
msgid "Save"
msgstr "保存"
msgid "Search"
msgstr "搜索"
@@ -1142,9 +1198,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 已下载完成!"
@@ -1191,7 +1244,7 @@ msgid "Exe Settings"
msgstr "可执行文件设置"
msgid "Edit user.conf"
msgstr ""
msgstr "编辑 user.conf"
msgid "Search settings…"
msgstr "搜索设置…"
@@ -1217,9 +1270,8 @@ msgstr "编辑 PPDB"
msgid "Clear PPDB"
msgstr "清除 PPDB"
#, fuzzy
msgid "Failed to load global game settings."
msgstr "加载主题失败"
msgstr "加载全局游戏设置失败"
msgid "Executable path is not available."
msgstr "可执行文件路径不可用。"
@@ -1230,12 +1282,8 @@ msgstr "PPDB 文件不存在于: "
msgid "Failed to open PPDB file:\n"
msgstr "打开 PPDB 文件失败:\n"
#, fuzzy
msgid "Failed to open file:\n"
msgstr "打开 PPDB 文件失败:\n"
msgid "Information"
msgstr "信息"
msgstr "打开文件失败:\n"
msgid "Confirm PPDB Clear"
msgstr "确认清除 PPDB"
@@ -1255,9 +1303,8 @@ msgstr "默认"
msgid "disabled"
msgstr "已禁用"
#, fuzzy
msgid "Download Wine/Proton..."
msgstr "将 WINE/Proton 下载到 Steam"
msgstr "下载 Wine/Proton…"
msgid "Enter prefix name"
msgstr "输入前缀名称"
@@ -2056,13 +2103,11 @@ msgstr "全部清除"
msgid "Now Downloading"
msgstr "正在下载"
#, fuzzy
msgid "Waiting…"
msgstr "警告"
msgstr "请稍候…"
#, fuzzy
msgid "Disk: "
msgstr "链接:"
msgstr "磁盘: "
#, fuzzy
msgid "Started at"
@@ -2076,10 +2121,10 @@ msgid "Store"
msgstr "存储"
msgid "Copy the link to clipboard"
msgstr ""
msgstr "将链接复制到剪贴板"
msgid "Failed to log out of GOG account"
msgstr ""
msgstr "退出 GOG 账号失败"
msgid "Refreshing…"
msgstr "正在刷新…"
@@ -2101,27 +2146,27 @@ msgid "Log out"
msgstr "注销"
msgid "Open login page"
msgstr ""
msgstr "打开登录页面"
msgid "GOG account not connected"
msgstr ""
msgstr "未连接 GOG 账号"
#, python-brace-format
msgid "Refreshing GOG library… {0}/{1}"
msgstr ""
msgstr "正在刷新 GOG 藏品库… {0}/{1}"
#, fuzzy, python-brace-format
msgid "Failed to refresh GOG library: {0}"
msgstr "启动游戏失败:{0}"
msgid "Failed to log out of Epic account"
msgstr ""
msgstr "退出 Epic 账号失败"
msgid "Copy the authorization code to clipboard"
msgstr ""
msgstr "将授权码复制到剪贴板"
msgid "Epic authorization…"
msgstr ""
msgstr "Epic 授权中…"
#, fuzzy, python-brace-format
msgid "Epic login failed: {0}"
@@ -2135,33 +2180,33 @@ msgstr "已连接"
msgid "Epic account not connected"
msgstr "已连接"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Refreshing Epic library… {0}/{1}"
msgstr "启动游戏失败:{0}"
msgstr "正在刷新 Epic 游戏库… {0}/{1}"
#, fuzzy, python-brace-format
msgid "Failed to refresh Epic library: {0}"
msgstr "启动游戏失败:{0}"
msgid "Select installation folder"
msgstr ""
msgstr "选择安装文件夹"
#, fuzzy
msgid "Another download is running"
msgstr "正在下载"
msgid "Another GOG operation is already running"
msgstr ""
msgstr "另一个 GOG 操作正在运行中"
msgid "GOG installation not found"
msgstr ""
msgstr "未找到 GOG 安装"
#, fuzzy
msgid "Select GOG game folder"
msgstr "删除文件夹"
msgid "Selected folder is not this GOG game"
msgstr ""
msgstr "所选文件夹不是此 GOG 游戏"
#, fuzzy
msgid "Starting"
@@ -2173,11 +2218,11 @@ msgstr "正在下载… {0}%"
#, python-brace-format
msgid "Disk: {0} MiB/s"
msgstr ""
msgstr "磁盘:{0} MiB/s"
#, fuzzy, python-brace-format
msgid "ETA: {0}"
msgstr ",预计剩余时间:{}秒"
msgstr "ETA: {0}"
#, fuzzy
msgid "Failed"
@@ -2187,10 +2232,6 @@ msgstr "详细"
msgid "Repaired"
msgstr "已配对"
#, fuzzy
msgid "Download process failed"
msgstr "下载进行中"
msgid "Quick Launch"
msgstr "快速启动"
@@ -2235,7 +2276,7 @@ msgid "Theme"
msgstr "主题"
msgid "Grid"
msgstr ""
msgstr "网格"
#, fuzzy
msgid "List"
@@ -2245,21 +2286,17 @@ msgstr "浅色"
msgid "Vertical"
msgstr "部分"
#, fuzzy
msgid "Horizontal Blocks"
msgstr "水平 HUD 布局"
msgstr "水平块"
#, fuzzy
msgid "Horizontal Cards"
msgstr "水平 HUD 布局"
msgstr "水平卡片"
#, fuzzy
msgid "Horizontal endless Blocks"
msgstr "水平 HUD 布局"
msgstr "水平无限块"
#, fuzzy
msgid "Horizontal endless Cards"
msgstr "水平 HUD 布局"
msgstr "水平无限卡片"
#, fuzzy
msgid "Layout"
@@ -2273,6 +2310,9 @@ msgstr ""
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
"据。"
msgid "Failed to install theme"
msgstr "安装主题失败"
msgid "PortProton Settings"
msgstr "PortProton 设置"
@@ -2284,7 +2324,7 @@ msgid "Auto"
msgstr "自动"
msgid "Account"
msgstr ""
msgstr "账号"
msgid "Brief"
msgstr "简要"
@@ -2312,7 +2352,7 @@ msgid "Auto Fullscreen on Gamepad connected"
msgstr "连接手柄时自动全屏"
msgid "Disable Boot Animation"
msgstr ""
msgstr "禁用开机动画"
msgid "Minimize to tray on close"
msgstr "关闭时最小化到系统托盘"
@@ -2327,19 +2367,22 @@ msgid "Hide Auto-Install Tab"
msgstr "隐藏自动安装标签页"
msgid "Hide Control Hints"
msgstr ""
msgstr "隐藏操作提示"
msgid "Force English language"
msgstr ""
msgstr "强制使用英语"
msgid "Enable UI Sounds"
msgstr "启用界面音效"
msgid "Show compatibility report after game crash"
msgstr ""
msgstr "游戏崩溃后显示兼容性报告"
msgid "Disable Cyrillic path warning"
msgstr "禁用西里尔字母路径警告"
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgstr "禁用 ALT Linux i586 依赖项检查"
msgid "Disable runtime download"
msgstr "禁用运行时下载"
@@ -2348,7 +2391,7 @@ msgid "Add to Steam compatibility tools"
msgstr "添加到 Steam 兼容工具"
msgid "Disable Steam Proton scanning"
msgstr ""
msgstr "禁用 Steam Proton 扫描"
msgid "Download WINE/Proton to Steam"
msgstr "将 WINE/Proton 下载到 Steam"
@@ -2437,7 +2480,7 @@ msgstr "确定要清除缓存吗?此操作无法撤销。"
#, python-brace-format
msgid "AppImage integrated at: {path}"
msgstr ""
msgstr "AppImage 已集成于:{path}"
#, fuzzy, python-brace-format
msgid "Failed to integrate AppImage: {error}"
@@ -2560,15 +2603,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 "选择主题:"
@@ -2591,13 +2628,12 @@ msgstr "作者:"
msgid "Link:"
msgstr "链接:"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Delete theme '{0}'?"
msgstr "选择主题:"
msgstr "删除主题“{0}”?"
#, fuzzy
msgid "Failed to delete theme."
msgstr "加载主题失败"
msgstr "删除主题失败。"
msgid "Prefix:"
msgstr "前缀:"
+13 -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,18 @@ def get_system_locale():
if ui_config.get_force_english():
return 'en'
loc = locale.getlocale()[0]
return loc if loc else 'en'
for language in os.getenv("LANGUAGE", "").split(":"):
if not language:
continue
try:
return str(Locale.parse(language))
except (UnknownLocaleError, ValueError):
continue
try:
loc = locale.getlocale()[0]
return str(Locale.parse(loc)) if loc else 'en'
except (UnknownLocaleError, ValueError):
return 'en'
def get_steam_language():
try:
+113 -31
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
@@ -17,6 +18,7 @@ import psutil
import orjson
from portprotonqt.logger import get_logger
from portprotonqt.config.portproton import get_custom_icon_path
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
@@ -115,7 +117,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:
@@ -1069,9 +1071,13 @@ class MainWindow(
self._load_gog_games_async(gog_callback)
self._load_egs_games_async(egs_callback)
else:
# For 'all' filter - load games from different sources in parallel to prevent blocking
# Load selected sources in parallel.
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
completed = dict.fromkeys(results, False)
selected_sources = set(display_filter.split(","))
completed = {
source: bool(selected_sources & results.keys()) and source not in selected_sources
for source in results
}
def on_all_games():
seen = set()
@@ -1113,11 +1119,13 @@ class MainWindow(
completed['egs'] = True
check_completion()
# Load all sources in parallel
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
self._load_gog_games_async(gog_callback)
self._load_egs_games_async(egs_callback)
callbacks = {
"portproton": portproton_callback, "steam": steam_callback,
"gog": gog_callback, "egs": egs_callback,
}
for source, callback in callbacks.items():
if not completed[source]:
getattr(self, f"_load_{source}_games_async")(callback)
# Run loading immediately to show status without delay
start_loading()
@@ -1129,6 +1137,11 @@ class MainWindow(
library = [game for game in api.load_library() if game.get("app_id")]
if self._upgrade_legacy_gog_library(api, library, callback):
return
if only_installed:
library = [
game for game in library
if api.is_game_installed(str(game["app_id"]), installed)
]
games = []
if not library:
callback(games)
@@ -1192,6 +1205,23 @@ class MainWindow(
library = [game for game in self.egs_api.load_library() if game.get("app_id")]
only_installed = game_config.get_only_installed()
games = []
if not library:
callback(games)
return
installed_data = None
installed_by_id = {}
if only_installed:
installed_data = self.egs_api.load_installed()
installed_by_id = {
str(game["app_id"]): self.egs_api.is_game_installed(
str(game["app_id"]), installed_data
)
for game in library
}
library = [
game for game in library
if installed_by_id[str(game["app_id"])]
]
if not library:
callback(games)
return
@@ -1205,9 +1235,19 @@ class MainWindow(
name, custom_cover = MainWindow._get_custom_store_data(
"egs", app_id, str(game.get("title", app_id))
)
installed = self.egs_api.is_game_installed(app_id)
installed = (
installed_by_id[app_id] if only_installed
else self.egs_api.is_game_installed(app_id)
)
if not only_installed or installed:
target = self.egs_api.get_launch_target(app_id) if installed else None
target = None
if installed:
if only_installed:
target = self.egs_api.get_launch_target(
app_id, installed_data
)
else:
target = self.egs_api.get_launch_target(app_id)
description = str(game.get("description", ""))
if not description or description == str(game.get("title", "")):
description = str(steam_info.get("description", ""))
@@ -1382,6 +1422,9 @@ class MainWindow(
executor.submit(self._process_desktop_file_async, file_path, on_desktop_processed)
def _generate_missing_portproton_icon(self, game_exe: str, icon_path: str, desktop_name: str) -> str:
custom_icon = get_custom_icon_path(game_exe)
if custom_icon:
return custom_icon
if not self.portproton_location:
return ""
if not game_exe.lower().endswith(".exe") or not os.path.isfile(game_exe):
@@ -2493,11 +2536,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:
@@ -2561,8 +2605,8 @@ class MainWindow(
msg_box.setText(_("Prefix '{0}' was not found. Install recommended libraries?").format(prefix_name))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.No:
env_vars["DISABLE_CP_DEFPFX"] = "1"
@@ -2679,8 +2723,8 @@ class MainWindow(
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
)
msg_box.setDefaultButton(QMessageBox.StandardButton.Ok)
msg_box.setButtonText(QMessageBox.StandardButton.Ok, _("OK"))
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
msg_box.button(QMessageBox.StandardButton.Ok).setText(_("OK"))
msg_box.button(QMessageBox.StandardButton.Cancel).setText(_("Cancel"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Ok:
return self._install_alt_i586_dependencies()
@@ -2764,22 +2808,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.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
self._start_egs_operation(
app_id, ["uninstall", app_id, "-y"], _("Delete")
)
@@ -2870,6 +2915,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 +2973,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 +3035,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 +3143,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
View File
@@ -186,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',
+1 -1
View File
@@ -175,7 +175,7 @@ def _show_portdata_warning(parent: QWidget, theme: Any, text: str, allow_choose:
message_box.setText(text)
if allow_choose:
message_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
message_box.setButtonText(QMessageBox.StandardButton.Open, _("Choose Folder"))
message_box.button(QMessageBox.StandardButton.Open).setText(_("Choose Folder"))
main_style = getattr(theme, "MAIN_WINDOW_STYLE", "")
message_box_style = getattr(theme, "MESSAGE_BOX_STYLE", "")
message_box.setStyleSheet(main_style + message_box_style)
+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
+6 -10
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)
@@ -1873,11 +1869,11 @@ def build_command(args: argparse.Namespace) -> list[str]:
"""Build the command list to execute in the PTY."""
if args.e:
if len(args.e) == 1:
return [
default_shell(),
"-lc",
_with_portproton_functions(args.e[0]),
]
command = args.e[0]
# functions_helper is bash-only, user shell may be zsh or fish
if _needs_portproton_functions(command):
return [CONF_FALLBACK_SHELL, "-lc", _with_portproton_functions(command)]
return [default_shell(), "-lc", command]
return args.e
return [default_shell()]
@@ -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())
+58 -13
View File
@@ -1,5 +1,6 @@
"""Steam API functions for PortProtonQt."""
import hashlib
import os
import shlex
import threading
@@ -15,6 +16,7 @@ from portprotonqt.logger import get_logger
from portprotonqt.downloader import Downloader
from portprotonqt.localization import get_steam_language
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
from portprotonqt.config.portproton import get_custom_icon_path
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.steam_api.utils import decode_text, get_local_steam_cover
from portprotonqt.steam_api.cache import (
@@ -46,10 +48,12 @@ SGDB_CACHE_DURATION = 24 * 60 * 60
STEAM_CLIENT_ICON_SIZES = (16, 32, 64, 128, 256)
def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> None:
def fetch_sgdb_cover_async(
game_name: str, callback: Callable[[str], None], image_type: str = "grids"
) -> None:
"""Asynchronously fetch cover image URL from steamgrid.usebottles.com."""
encoded = urllib.parse.quote(game_name)
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
encoded = urllib.parse.quote(game_name, safe="")
url = f"https://steamgrid.usebottles.com/api/search/{encoded}/{image_type}"
def process_response(result: str | None) -> None:
if not result or not os.path.exists(result):
@@ -66,6 +70,14 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
if text.startswith('"') and text.endswith('"'):
text = text[1:-1]
if text:
from portprotonqt.portproton_api import get_user_conf_setting
if get_user_conf_setting("MIRROR") == "CLOUD":
text = text.replace(
"https://cdn2.steamgriddb.com/",
"https://deckyloader.ru/sgdb-cdn/",
1,
)
logger.debug("Got SGDB cover URL for %s: %s", game_name, text)
callback(text)
except Exception as e:
@@ -74,7 +86,7 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
cache_manager = CacheManager()
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}.json"
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}_{image_type}.json"
if cache_file.exists() and time.time() - cache_file.stat().st_mtime < SGDB_CACHE_DURATION:
process_response(str(cache_file))
return
@@ -182,6 +194,11 @@ def fetch_client_icon_async(appid: int | str, callback: Callable[[str], None]) -
callback("")
return
custom_icon = get_custom_icon_path(f"steam://rungameid/{appid_str}")
if custom_icon:
callback(custom_icon)
return
cache_dir = CacheManager().cache_dir
info_path = cache_dir / f"steamcmd_app_{appid_str}_{time.time_ns()}.json"
url = f"https://api.steamcmd.net/v1/info/{appid_str}"
@@ -224,7 +241,12 @@ def get_protondb_tier_async(appid: int, callback: Callable[[str], None]) -> None
callback(cached.get("tier", ""))
return
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
from portprotonqt.portproton_api import get_user_conf_setting
if get_user_conf_setting("MIRROR") == "CLOUD":
url = f"https://deckyloader.ru/protondb/{appid}.json"
else:
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
def process_response(result: str | None) -> None:
if not result or not os.path.exists(result):
@@ -623,6 +645,19 @@ def get_steam_game_info_async(
from portprotonqt.steam_api.cache import get_exiftool_data
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
cache_manager = CacheManager()
match_key = "steam_match_" + hashlib.sha256(
orjson.dumps([desktop_name, exec_line])
).hexdigest()
cached_match = (
cache_manager.load_json(match_key)
if cache_manager.is_fresh(match_key, CACHE_DURATION) else None
)
if not isinstance(cached_match, dict) or type(cached_match.get("appid")) is not int:
cached_match = None
elif cached_match["appid"] <= 0:
cached_match = None
is_autoinstall = exec_line.startswith("autoinstall:")
is_store_uri = exec_line.startswith(("gog://", "egs://"))
@@ -646,7 +681,7 @@ def get_steam_game_info_async(
else:
logger.error("Command file not found: %s", game_exe)
if is_store_uri:
if cached_match is not None or is_store_uri:
meta_data = {}
elif not game_exe.lower().endswith('.exe'):
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
@@ -685,9 +720,9 @@ def get_steam_game_info_async(
data_and_index: tuple[list | None, dict | None]
) -> None:
steam_apps, steam_apps_index = data_and_index
matching_app = None
matching_app = cached_match
if not steam_apps or not steam_apps_index:
if matching_app is None and (not steam_apps or not steam_apps_index):
game_name = desktop_name or exe_name
def on_sgdb_cover_failure(cover: str) -> None:
@@ -716,9 +751,11 @@ def get_steam_game_info_async(
return
for candidate in candidates_ordered:
if matching_app:
break
if not candidate:
continue
matching_app = search_app(candidate, steam_apps_index)
matching_app = search_app(candidate, steam_apps_index or {})
if matching_app:
break
@@ -749,18 +786,23 @@ def get_steam_game_info_async(
return
appid = matching_app["appid"]
if cached_match is None:
cache_manager.save_json(match_key, matching_app)
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", ""),
)
@@ -797,4 +839,7 @@ def get_steam_game_info_async(
fetch_app_info_async(appid, on_app_info)
get_steam_apps_and_index_async(on_steam_apps_and_index)
if cached_match is not None:
on_steam_apps_and_index((None, None))
else:
get_steam_apps_and_index_async(on_steam_apps_and_index)
+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
+56 -13
View File
@@ -7,13 +7,16 @@ import re
import shlex
import shutil
import zlib
from functools import partial
from pathlib import Path
from urllib.parse import urlsplit
import orjson
import requests
import vdf
import websocket
from portprotonqt.config.portproton import get_custom_icon_path
from portprotonqt.logger import get_logger
from portprotonqt.downloader import Downloader
from portprotonqt.icon_extractor import generate_thumbnail
@@ -33,6 +36,7 @@ from portprotonqt.steam_api.utils import (
)
from portprotonqt.steam_api.api import (
get_steam_game_info_async,
fetch_sgdb_cover_async,
)
logger = get_logger(__name__)
@@ -328,11 +332,12 @@ def _add_shortcut_via_vdf(
def _download_covers(
appid: int,
steam_appid: int,
steam_appid: tuple[int | None, str],
grid_dir: str,
was_api_used: bool
) -> None:
"""Download cover images for the game."""
source_appid, game_name = steam_appid
cover_types = [
("p.jpg", "library_600x900_2x.jpg"),
("_hero.jpg", "library_hero.jpg"),
@@ -349,7 +354,7 @@ def _download_covers(
with open(result_path, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
ext = Path(steam_name).suffix.lstrip('.')
ext = Path(result_path).suffix.lstrip('.')
call_steam_api("setGrid", appid, index, ext, img_b64)
except Exception as e:
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
@@ -358,18 +363,54 @@ def _download_covers(
except Exception as e:
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
def download_sgdb(index: int, cover_file: str, steam_name: str) -> None:
image_type = ("grids", "heroes", "logos", "hgrids")[index]
def on_url(url: str) -> None:
if not url.startswith((
"https://cdn2.steamgriddb.com/",
"https://deckyloader.ru/sgdb-cdn/",
)):
on_cover_download(None, steam_name, index)
return
extension = Path(urlsplit(url).path).suffix.lower()
if extension not in (".png", ".jpg", ".jpeg"):
logger.warning("Unsupported SGDB artwork format: %s", extension)
on_cover_download(None, steam_name, index)
return
destination = str(Path(cover_file).with_suffix(extension))
downloader.download_async(
url, destination, timeout=5,
callback=partial(on_cover_download, steam_name=steam_name, index=index),
)
fetch_sgdb_cover_async(game_name, on_url, image_type)
def on_steam_download(
index: int, steam_name: str, cover_file: str, result: str | None
) -> None:
if result:
on_cover_download(result, steam_name, index)
else:
download_sgdb(index, cover_file, steam_name)
for i, (suffix, steam_name) in enumerate(cover_types):
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
if not source_appid:
download_sgdb(i, cover_file, steam_name)
continue
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{source_appid}/{steam_name}"
downloader.download_async(
cover_url,
cover_file,
timeout=5,
callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
callback=partial(on_steam_download, i, steam_name, cover_file)
)
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
def add_to_steam(
game_name: str, exec_line: str, cover_path: str, icon_source: str | None = None
) -> tuple[bool, str]:
"""
Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
and download Steam Grid covers with correct sizes and names.
@@ -382,7 +423,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
if not exe_path:
return (False, error)
if not os.path.exists(exe_path):
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
if not is_store_uri and not os.path.exists(exe_path):
logger.error(f"Executable not found: {exe_path}")
return (False, f"Executable file not found: {exe_path}")
@@ -396,7 +438,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
if error:
return (False, error)
icon_path = _generate_icon(exe_path, portproton_dir)
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(exe_path)
icon_source = custom_icon or icon_source or (None if is_store_uri else exe_path)
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
steam_home = get_steam_home()
if not steam_home:
@@ -471,10 +515,10 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
nonlocal steam_appid
steam_appid = game_info.get("appid")
if not steam_appid or not isinstance(steam_appid, int):
logger.info("No valid Steam appid found, skipping cover download")
_download_covers(appid, (None, game_name), str(grid_dir), was_api_used)
return
logger.info(f"Found Steam AppID {steam_appid} for cover download")
_download_covers(appid, steam_appid, str(grid_dir), was_api_used)
_download_covers(appid, (steam_appid, game_name), str(grid_dir), was_api_used)
get_steam_game_info_async(game_name, exec_line, on_game_info)
@@ -487,10 +531,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
def _delete_cover_files(grid_dir: str, appid: int) -> None:
"""Delete cover files for a game."""
cover_files = [
os.path.join(grid_dir, f"{appid}.jpg"),
os.path.join(grid_dir, f"{appid}p.jpg"),
os.path.join(grid_dir, f"{appid}_hero.jpg"),
os.path.join(grid_dir, f"{appid}_logo.png")
os.path.join(grid_dir, f"{appid}{suffix}{extension}")
for suffix in ("", "p", "_hero", "_logo")
for extension in (".jpg", ".png", ".jpeg")
]
for cover_file in cover_files:
if os.path.exists(cover_file):
+8
View File
@@ -397,6 +397,11 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
"""Rebuild auto-install cards after a live library layout change."""
if not hasattr(self, "autoInstallContainer"):
return
if self.stackedWidget.currentIndex() != self.auto_install_tab_index:
self._autoinstall_layout_pending = True
self.autoInstallLoaded = False
return
self._autoinstall_layout_pending = False
mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
@@ -487,6 +492,9 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
return
def _start_autoinstall_load(self, force_refresh: bool = False) -> None:
if getattr(self, "_autoinstall_layout_pending", False):
self.refresh_autoinstall_layout()
return
if self.autoInstallLoaded or self.autoInstallLoading:
return
if not hasattr(self, "_on_autoinstall_games_loaded"):
+1
View File
@@ -226,6 +226,7 @@ class MainWindowControlHintsMixin:
keyboard_hints = [
("key_enter", _("Select"), "confirm"),
("key_space", _("Select multiple"), None),
("key_backspace", _("Back"), "back"),
("key_e", _("Add a shortcut"), "add_game"),
("key_context", _("Menu"), "context_menu"),
+83 -32
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 = ""
@@ -818,9 +849,17 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
return
api = self.egs_api if source == "egs" else self.gog_api
worker = StoreDLCWorker(api, str(game["app_id"]))
worker.loaded.connect(lambda dlcs: self._on_store_dlcs_loaded(source, game, dlcs))
worker.failed.connect(lambda error: QMessageBox.warning(self, _("Error"), error))
results: list[list[dict]] = []
errors: list[str] = []
worker.loaded.connect(results.append)
worker.failed.connect(errors.append)
worker.finished.connect(self._on_store_dlc_worker_finished)
worker.finished.connect(
lambda: self._on_store_dlcs_loaded(source, game, results[0]) if results else None
)
worker.finished.connect(
lambda: QMessageBox.warning(self, _("Error"), errors[0]) if errors else None
)
self.store_dlc_worker = worker
worker.start()
@@ -1051,38 +1090,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.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
message_box.button(QMessageBox.StandardButton.No).setText(_("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 +1146,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 +1180,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 +1214,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 +1230,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:
+110 -18
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__)
@@ -95,7 +97,24 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
def _on_library_filter_changed(self, index: int) -> None:
if index < 0 or index >= len(self.filter_keys):
return
display_filter = self.filter_keys[index]
selected = set(game_config.get_display_filter().split(","))
key = self.filter_keys[index]
if key in ("all", "favorites"):
selected = {key}
else:
selected -= {"all", "favorites"}
selected.symmetric_difference_update({key})
display_filter = ",".join(key for key in self.filter_keys if key in selected) or "all"
selected = set(display_filter.split(","))
combo = self.gamesDisplayCombo
combo.setCurrentIndex(self.filter_keys.index(display_filter.split(",")[0]))
for row, source in enumerate(self.filter_keys):
combo.setItemData(row, Qt.CheckState.Checked if source in selected
else Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
combo.setCurrentText(" + ".join(
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
if source in selected
))
game_config.set_display_filter(display_filter)
self.onlyInstalledCheckBox.setVisible(
display_filter not in ("steam", "portproton")
@@ -150,9 +169,17 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
card_orientation = "vertical"
elif layout_mode in {"horizontal", "horizontal_top"}:
card_orientation = "horizontal"
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
previous_mode = ui_config.get_library_layout_mode(theme_mode)
previous_orientation = ui_config.get_horizontal_card_orientation("theme")
ui_config.set_library_layout_mode(layout_mode)
ui_config.set_horizontal_card_orientation(card_orientation)
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
if (
previous_mode == ui_config.get_library_layout_mode(theme_mode)
and (previous_mode not in {"horizontal", "horizontal_top"}
or previous_orientation == card_orientation)
):
return
self.game_library_manager.rebuild_library_layout(
ui_config.get_library_layout_mode(theme_mode)
)
@@ -346,11 +373,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
]
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
self._set_combo_current_key(
self.gamesDisplayCombo,
self.filter_keys,
display_filter,
self.gamesDisplayCombo, self.filter_keys, display_filter.split(",")[0]
)
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
self.gamesDisplayCombo.setEditable(True)
line_edit = self.gamesDisplayCombo.lineEdit()
if line_edit is not None:
line_edit.setReadOnly(True)
line_edit.installEventFilter(self.gamesDisplayCombo)
self.gamesDisplayCombo.setStyleSheet(
self.gamesDisplayCombo.styleSheet() + self.theme.SOURCE_FILTER_STYLE
)
selected = set(display_filter.split(","))
for row, source in enumerate(self.filter_keys):
self.gamesDisplayCombo.setItemData(
row, Qt.CheckState.Checked if source in selected else Qt.CheckState.Unchecked,
Qt.ItemDataRole.CheckStateRole,
)
self.gamesDisplayCombo.setCurrentText(" + ".join(
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
if source in selected
))
self.gamesDisplayCombo.activated.connect(self._on_library_filter_changed)
self.gamesDisplayCombo.activated.connect(self._delay_library_controls_hover_close)
self.onlyInstalledCheckBox = QCheckBox(_("Show only installed games"))
@@ -496,9 +539,13 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
source_refresh()
if not callable(source_loader):
if self.gog_api.auth_path.is_file():
if (
display_filter in ("all", "favorites") or "gog" in display_filter.split(",")
) and self.gog_api.auth_path.is_file():
self._refresh_gog_library()
if self.egs_api.user_path.is_file():
if (
display_filter in ("all", "favorites") or "egs" in display_filter.split(",")
) and self.egs_api.user_path.is_file():
self._refresh_egs_library()
# Reload games using the existing loadGames functionality
@@ -536,8 +583,8 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
)
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
for game in missing_games:
@@ -604,18 +651,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 +681,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()
+36 -6
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()
@@ -893,8 +920,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
msg_box.setText(_("Are you sure you want to reset all settings? This action cannot be undone."))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Yes:
if reset_settings():
@@ -913,8 +940,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
msg_box.setText(_("Migrate old PortProton shortcuts to PortProtonQt format?"))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
reply = msg_box.exec()
if reply != QMessageBox.StandardButton.Yes:
return
@@ -930,8 +957,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
msg_box.setText(_("Are you sure you want to clear the cache? This action cannot be undone."))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Yes:
cache_config.clear_cache()
@@ -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()
+2 -2
View File
@@ -1530,8 +1530,8 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
msg_box.setText(request.get("message", ""))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
button = msg_box.exec()
worker.submit_pairing_response("yes" if button == QMessageBox.StandardButton.Yes else "no")
return
+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)
+2 -2
View File
@@ -287,8 +287,8 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
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"))
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
removal_results = [
+4 -4
View File
@@ -336,8 +336,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
msg_box.setText(_("Are you sure you want to clear prefix '{}'?").format(selected_prefix))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
reply = msg_box.exec()
if reply != QMessageBox.StandardButton.Yes:
return
@@ -443,8 +443,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
msg_box.setText(_("Are you sure you want to delete prefix '{}'?").format(selected_prefix))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Yes:
+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)
@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#3f424d"/></svg>

After

Width:  |  Height:  |  Size: 386 B

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 383 B

@@ -727,6 +727,25 @@ COMBOBOX_STYLE = f"""
}}
"""
SOURCE_FILTER_STYLE = f"""
QComboBox QAbstractItemView {{
background: {color_surface};
}}
QListView::indicator {{
width: {sourceFilterIndicatorSize}px;
height: {sourceFilterIndicatorSize}px;
border: {border_thin} {color_border_input};
border-radius: {sourceFilterIndicatorRadius}px;
background: {color_bg};
image: none;
}}
QListView::indicator:checked {{
background: {color_accent};
border: {border_thin} {color_text};
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
}}
"""
SCROLL_STYLE = f"""
QScrollBar:vertical {{
width: 10px;
@@ -10,6 +10,8 @@ bootAnimationTimeoutMs = 30000
cursorHideTimeoutMs = 5000
startupLibraryLoadDelayMs = 500
autoSizeButtonPadding = (10, 20)
sourceFilterIndicatorSize = 18
sourceFilterIndicatorRadius = 2
favoriteLabelSize = 48, 48
favoriteLabelIconSize = 32
detailCompactCoverFrameSize = 128
@@ -366,3 +368,7 @@ GAME_CARD_ANIMATION = {
WINETRICKS_CHECK_COLUMN_WIDTH = 80
WINETRICKS_ROW_HEIGHT = 36
GAME_FOLDER_IMPORT_DIALOG_SIZE = (800, 600)
RUN_AFTER_VALUE_WIDTH = 230
+3
View File
@@ -177,6 +177,9 @@ def test_app_restarts_installed_appimage_after_integration(
lambda: SimpleNamespace(
debug_level="NOTSET",
reinstall_steam_compat_tool=True,
list_games=False,
game_info=None,
edit_game=None,
),
)
monkeypatch.setattr(
+35
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,18 +196,45 @@ 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(
monkeypatch: pytest.MonkeyPatch, system_locale: str | None,
force_english: bool, expected: str,
) -> None:
monkeypatch.delenv("LANGUAGE", raising=False)
monkeypatch.setattr(localization.locale, "getlocale", lambda: (system_locale, "UTF-8"))
monkeypatch.setattr("portprotonqt.config.ui_config.get_force_english", lambda: force_english)
assert localization.get_system_locale() == expected
@pytest.mark.parametrize("language,expected", [
("de", "german"),
("ru", "russian"),
("ru_RU.UTF-8", "russian"),
("de:ru", "german"),
("unknown:de", "german"),
("", "russian"),
("unknown", "russian"),
])
def test_steam_language_from_environment(
monkeypatch: pytest.MonkeyPatch, language: str, expected: str,
) -> None:
monkeypatch.setenv("LANGUAGE", language)
monkeypatch.setenv("FULL_LN", "")
monkeypatch.setattr(localization.locale, "getlocale", lambda: ("ru_RU", "UTF-8"))
monkeypatch.setattr("portprotonqt.config.ui_config.get_force_english", lambda: False)
assert localization.get_steam_language() == expected
assert localization.os.environ["FULL_LN"] == expected
def test_translation_can_switch_to_english_without_restart(
monkeypatch: pytest.MonkeyPatch,
):
+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}
+536 -60
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,
)
@@ -37,53 +35,9 @@ class TestParseArgs:
assert args.file_or_url == "file:///tmp/game.exe"
@pytest.mark.parametrize("stopped", [False, True])
def test_silent_tray_toggles_pause(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stopped: bool) -> None:
import signal
import psutil
from PySide6.QtWidgets import QApplication
from portprotonqt import app, time_utils
qt_app = QApplication.instance() or QApplication([])
assert isinstance(qt_app, QApplication)
tray = MagicMock()
timer = MagicMock()
launcher = MagicMock(pid=123)
launcher.poll.return_value = None
child = MagicMock(pid=124)
status = psutil.STATUS_STOPPED if stopped else psutil.STATUS_RUNNING
child.status.return_value = status
parent = MagicMock()
parent.children.return_value = [child]
monkeypatch.setattr(app, "QSystemTrayIcon", MagicMock(return_value=tray))
monkeypatch.setattr(app, "QTimer", MagicMock(return_value=timer))
monkeypatch.setattr(app, "get_portproton_tray_icon", MagicMock())
monkeypatch.setattr(app.subprocess, "Popen", MagicMock(return_value=launcher))
monkeypatch.setattr(app.psutil, "Process", MagicMock(return_value=parent))
monkeypatch.setattr(time_utils, "save_last_launch", MagicMock())
monkeypatch.setattr(time_utils, "save_playtime", MagicMock())
app.run_silent_tray(qt_app, ["start.sh"], str(tmp_path / "game.exe"))
menu = tray.setContextMenu.call_args.args[0]
menu.aboutToShow.emit()
pause, stop = menu.actions()
assert pause.isEnabled()
assert pause.text() == app._("Resume Game" if stopped else "Pause Game")
assert stop.text() == app._("Stop Game")
pause.trigger()
child.send_signal.assert_called_once_with(signal.SIGCONT if stopped else signal.SIGSTOP)
parent.children.assert_called_with(recursive=True)
parent.children.return_value = []
menu.aboutToShow.emit()
assert not pause.isEnabled()
pause.trigger()
assert child.send_signal.call_count == 1
@pytest.fixture
def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
from PySide6 import QtNetwork
from PySide6 import QtCore, QtNetwork
from portprotonqt import app, main_window, port_data_path_selector
monkeypatch.delenv("STEAM_COMPAT", raising=False)
@@ -104,19 +58,21 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
qt_app = MagicMock()
qt_app.exec.return_value = 0
monkeypatch.setattr(app, "QApplication", MagicMock(return_value=qt_app))
lock = MagicMock()
lock.tryLock.return_value = True
monkeypatch.setattr(QtCore, "QLockFile", MagicMock(return_value=lock))
monkeypatch.setattr(QtCore.QStandardPaths, "writableLocation", lambda _location: str(tmp_path))
socket = MagicMock()
socket.waitForConnected.return_value = False
server = MagicMock()
monkeypatch.setattr(QtNetwork, "QLocalSocket", MagicMock(return_value=socket))
monkeypatch.setattr(QtNetwork, "QLocalServer", server)
tray = MagicMock()
window = MagicMock(side_effect=RuntimeError("main window reached"))
monkeypatch.setattr(app, "run_silent_tray", tray)
monkeypatch.setattr(main_window, "MainWindow", window)
exe = tmp_path / "my game.exe"
exe.touch()
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server,
tray=tray, window=window, exe=exe)
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server, lock=lock,
window=window, exe=exe)
@pytest.mark.parametrize("file_uri", [False, True])
@@ -130,7 +86,6 @@ def test_silent_exe_keeps_main_window_for_emulation(
with pytest.raises(RuntimeError, match="main window reached"):
startup.app.main()
startup.tray.assert_not_called()
startup.window.assert_called_once()
assert startup.window.call_args.kwargs["launch_exe"] == str(startup.exe)
assert os.environ["MANGOHUD"] == "1"
@@ -147,11 +102,114 @@ def test_silent_exe_uses_existing_instance(
startup.app.main()
startup.socket.write.assert_called_once_with(f"silent:{startup.exe}".encode())
startup.tray.assert_not_called()
startup.window.assert_not_called()
startup.qt_app.exec.assert_not_called()
def test_existing_instance_with_unavailable_ipc_does_not_start_another(
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch,
) -> None:
startup = silent_startup
startup.lock.tryLock.return_value = False
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--silent", str(startup.exe)])
startup.app.main()
startup.server.removeServer.assert_not_called()
startup.server.assert_not_called()
startup.window.assert_not_called()
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("target", ["exe", "gog://launch/123", "egs://launch/example"])
@pytest.mark.parametrize("decision", [
("No", True, False), ("Yes", True, False), ("No", False, False),
("Yes", False, False), ("Yes", True, True),
])
def test_silent_ipc_confirms_restarting_application(
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch,
target: str, decision: tuple[str, bool, bool],
) -> None:
from PySide6.QtCore import QThread
from PySide6.QtWidgets import QMessageBox
startup = silent_startup
reply, running, stop_failed = decision
window = MagicMock(game_processes=[])
startup.window.side_effect = None
startup.window.return_value = window
timers = MagicMock()
monkeypatch.setattr(startup.app, "QTimer", timers)
monkeypatch.setattr(QThread, "start", MagicMock())
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(sys, "argv", ["portprotonqt", "--silent", str(startup.exe)])
question = MagicMock()
question.exec.return_value = getattr(QMessageBox.StandardButton, reply)
message_box = MagicMock(return_value=question, StandardButton=QMessageBox.StandardButton,
Icon=QMessageBox.Icon)
monkeypatch.setattr(startup.app, "QMessageBox", message_box)
restart, stop = MagicMock(), MagicMock()
monkeypatch.setattr(startup.app.os, "execv", restart)
monkeypatch.setattr(startup.app.psutil, "Process", MagicMock(return_value=MagicMock(children=lambda **kw: [])))
monkeypatch.setattr(startup.app.psutil, "wait_procs", MagicMock(return_value=([], [])))
stop.side_effect = OSError("stop failed") if stop_failed else None
monkeypatch.setattr(startup.app.subprocess, "run", stop)
target = str(startup.exe) if target == "exe" else target
def event_loop() -> int:
timers.singleShot.call_args_list[0].args[1]()
message_box.assert_not_called()
window.game_processes = [MagicMock()] if running else []
conn = MagicMock()
conn.readAll.return_value.data.return_value = f"silent:{target}".encode()
startup.server.return_value.nextPendingConnection.return_value = conn
startup.server.return_value.newConnection.connect.call_args.args[0]()
timers.singleShot.call_args.args[1]()
return 0
startup.qt_app.exec.side_effect = event_loop
with pytest.raises(SystemExit):
startup.app.main()
question.setDefaultButton.assert_called_once_with(QMessageBox.StandardButton.No)
window.toggleGame.assert_called_once_with("'" + str(startup.exe) + "'")
assert startup.qt_app.quit.called is (reply == "Yes")
assert (stop.called, window._stopBackgroundWorkers.called) == (reply == "Yes",) * 2
if reply == "Yes" and not stop_failed:
restart.assert_called_once_with(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", target])
else:
assert not restart.called and not startup.lock.unlock.called
@pytest.mark.parametrize("flags,target", [
([], "exe"),
(["--silent", "--log"], "exe"),
@@ -171,7 +229,6 @@ def test_other_launch_modes_keep_main_window(
with pytest.raises(RuntimeError, match="main window reached"):
startup.app.main()
startup.tray.assert_not_called()
startup.window.assert_called_once()
@@ -260,9 +317,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 +329,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):
@@ -357,3 +408,428 @@ class TestParseResolution:
def test_empty_string(self):
assert parse_resolution("") is None
def test_list_games_json_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _list_portproton_games
from portprotonqt.config import portproton
executable = tmp_path / "Game with spaces.exe"
shortcut = tmp_path / "game.desktop"
shortcut.write_text(
'[Desktop Entry]\nName=Test Game\n'
f'Exec=portprotonqt --silent "{executable}"\nIcon=test-icon\n',
encoding="utf-8",
)
monkeypatch.setattr(portproton, "get_portproton_location", lambda: str(tmp_path))
monkeypatch.setattr(portproton, "get_portproton_scripts_path", lambda: None)
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {
"name": "Test Game", "description": "Description", "cover": "cover.png",
"appid": 123, "custom_icon": "",
})
games = _list_portproton_games(str(tmp_path), {}, {})
assert len(games) == 1
assert games[0]["description"] == "Description"
assert games[0]["cover"] == "cover.png"
assert games[0]["appid"] == 123
assert not {"metadata", "desktop_entry", "custom_icon"} & games[0].keys()
assert {key: games[0][key] for key in (
"id", "name", "desktop_file", "exec", "executable", "directory", "icon", "ppdb"
)} == {
"id": "portproton:game.desktop", "name": "Test Game",
"desktop_file": str(shortcut),
"exec": f'portprotonqt --silent "{executable}"',
"executable": str(executable), "directory": str(tmp_path),
"icon": "test-icon", "ppdb": f"{executable}.ppdb",
}
def test_list_games_unconfigured(monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _list_portproton_games
from portprotonqt.config import portproton
monkeypatch.setattr(portproton, "get_portproton_location", lambda: None)
assert _list_portproton_games(None, {}, {}) == []
def test_parse_list_games(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games"])
assert parse_args().list_games
def test_export_custom_artwork(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _get_game_metadata
from portprotonqt.config.portproton import resolve_custom_data_dir
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_steam_game_info", lambda *args: {
"name": "Cached", "description": "Cached description", "appid": "123",
})
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "en")
monkeypatch.setattr("portprotonqt.time_utils.get_playtime_for_exe", lambda *args: 42)
monkeypatch.setattr("portprotonqt.time_utils.get_last_launch_timestamp", lambda *args: 100)
executable = str(tmp_path / "Game.exe")
folder = Path(resolve_custom_data_dir(str(tmp_path / "PortProtonQt/custom_data"), executable))
folder.mkdir(parents=True)
(folder / "metadata.txt").write_text("name_en=Custom\ndescription_en=Description\n")
(folder / "cover.png").touch()
(folder / "icon.png").touch()
metadata = _get_game_metadata("Original", "launch", executable)
assert metadata["name"] == "Custom"
assert metadata["description"] == "Description"
assert metadata["cover"] == str(folder / "cover.png")
assert metadata["custom_icon"] == str(folder / "icon.png")
assert metadata["appid"] == "123"
assert metadata["playtime_seconds"] == 42
def test_export_runtime(tmp_path: Path) -> None:
from portprotonqt.cli import _get_game_runtime
executable = str(tmp_path / "Game.exe")
Path(f"{executable}.ppdb").write_text(
'export PW_WINE_USE="PROTON_LG"\nexport PW_PREFIX_NAME="GAME"\n'
)
runner = tmp_path / "data/dist/PROTON_TEST"
runner.mkdir(parents=True)
runtime = _get_game_runtime(tmp_path, executable, {"PW_PROTON_LG_VER": "PROTON_TEST"}, {})
assert runtime["runner"] == "PROTON_TEST"
assert "runner_version" not in runtime
assert runtime["runner_path"] == str(runner)
assert runtime["runner_installed"]
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/GAME")
def test_settings_do_not_execute_shell(tmp_path: Path) -> None:
from portprotonqt.cli import _read_game_settings
marker = tmp_path / "should-not-exist"
settings = tmp_path / "settings"
settings.write_text(
f'export PW_WINE_USE="$(touch {marker})"\n'
'export PW_PREFIX_NAME="Game prefix"\n'
'check_variables PW_VULKAN_USE "1"\n'
)
assert _read_game_settings(settings) == {"PW_PREFIX_NAME": "Game prefix", "PW_VULKAN_USE": "1"}
assert not marker.exists()
def test_list_games_exits_before_gui(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(list_games=True, game_info=None, edit_game=None, pretty=True, fields=None, all_games=True))
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{"name": "Игра"}])
gui = MagicMock(side_effect=AssertionError("GUI must not start"))
monkeypatch.setattr(app, "QApplication", gui)
assert app.main() == 0
output = capsys.readouterr().out
assert orjson.loads(output) == [{"name": "Игра"}]
assert output == '[\n {\n "name": "Игра"\n }\n]\n'
gui.assert_not_called()
@pytest.mark.parametrize("external", [False, True])
def test_runtime_wine_layout(tmp_path: Path, external: bool) -> None:
from portprotonqt.cli import _get_game_runtime
runner = tmp_path / "External Wine" if external else tmp_path / "data/dist/PROTON_TEST"
(runner / "files/bin-wow64").mkdir(parents=True)
selected = str(runner) if external else "PROTON_TEST"
runtime = _get_game_runtime(tmp_path, None, {}, {
"PW_WINE_USE": selected, "PW_USE_WOW64": "1", "PW_PREFIX_NAME": "CUSTOM",
})
assert runtime["wine_path"] == str(runner / "files/bin-wow64/wine")
assert runtime["wineserver_path"] == str(runner / "files/bin-wow64/wineserver")
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/CUSTOM")
@pytest.mark.parametrize("source,backend", [("gog", "gog_api.GOGAPI"), ("egs", "egs_api.EGSAPI")])
def test_list_store_games(source: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _list_store_games
api = MagicMock()
api.load_library.return_value = [
{"app_id": "123", "title": "Installed", "description": "Details", "cover": "store.jpg"},
{"app_id": "456", "title": "Owned"},
]
api.load_installed.return_value = {"123": {}}
api.is_game_installed.side_effect = lambda app_id, installed: app_id in installed
api.get_launch_target.return_value = "/games/game.exe"
monkeypatch.setattr(f"portprotonqt.{backend}", lambda: api)
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
lambda source, app_id, name: (name, "custom.jpg"))
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {"custom_icon": "icon.png"})
games = _list_store_games(source, None, {}, {})
assert [game["id"] for game in games] == [f"{source}:123", f"{source}:456"]
assert all(game["source"] == source for game in games)
assert games[0]["description"] == "Details"
assert games[0]["cover"] == "custom.jpg"
assert games[0]["icon"] == "icon.png"
assert games[0]["exec"] == f"{source}://launch/123"
assert games[1]["exec"] == f"{source}://install/456"
assert games[1]["executable"] is None
api.get_launch_target.assert_called_once_with("123")
def test_list_steam_games(monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _list_steam_games
monkeypatch.setattr("portprotonqt.steam_api.get_steam_installed_games", lambda: [("Game", 123, 100, 42)])
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_full_steam_game_info",
lambda *args: {"description": "Details", "cover": "cached.jpg"})
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
lambda *args: ("Custom", "custom.jpg"))
game = _list_steam_games()[0]
assert game["id"] == "steam:123"
assert game["source"] == "steam"
assert game["name"] == "Custom"
assert game["cover"] == "custom.jpg"
assert game["exec"] == "steam://rungameid/123"
assert game["playtime_seconds"] == 42
def test_list_all_sources_without_portproton(monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import list_games
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: None)
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_scripts_path", lambda: None)
monkeypatch.setattr("portprotonqt.cli._list_steam_games", lambda: [{"source": "steam"}])
monkeypatch.setattr("portprotonqt.cli._list_store_games", lambda source, *args: [{"source": source}])
assert [game["source"] for game in list_games()] == ["steam", "gog", "egs"]
@pytest.mark.parametrize("format_flag,pretty", [("--pretty", True), ("--raw", False)])
def test_json_format_args(format_flag: str, pretty: bool, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--game-info", "steam:123", format_flag])
args = parse_args()
assert args.game_info == "steam:123"
assert args.pretty is pretty
@pytest.mark.parametrize("game_id", ["steam:123", "gog:123", "missing"])
def test_game_info_output(game_id: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(
list_games=False, game_info=game_id, edit_game=None, pretty=False, fields=None,
))
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
{"id": "steam:123", "source": "steam"}, {"id": "gog:123", "source": "gog"},
])
code = app.main()
output = capsys.readouterr().out
if game_id == "missing":
assert code == 1
assert output == ""
else:
assert code == 0
assert orjson.loads(output)["id"] == game_id
assert len(output.splitlines()) == 1
@pytest.mark.parametrize("flags", [
["--pretty", "--raw"], ["--list-games", "--game-info", "steam:123"], ["--game-info"],
])
def test_library_conflicting_args(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
with pytest.raises(SystemExit) as error:
parse_args()
assert error.value.code == 2
@pytest.fixture
def editable_game(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
from portprotonqt.cli import _read_game_settings
executable = tmp_path / "Game with spaces.exe"
executable.touch()
ppdb = Path(f"{executable}.ppdb")
ppdb.write_text('# Keep this comment\nexport PW_MANGOHUD="0"\nexport PW_WINDOWS_VER="10"\n')
game = {"id": "portproton:game.desktop", "source": "portproton", "executable": str(executable)}
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
{**game, "runtime": {"settings": _read_game_settings(ppdb)}},
])
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
functions = "\n".join(
name + helper.split(name, 1)[1].split("\n}\n", 1)[0] + "\n}"
for name in ("set_several_variables () {", "edit_db_from_gui () {")
)
script = tmp_path / "start.sh"
script.write_text(
'#!/usr/bin/env bash\nset -e\n' + functions + '\n'
'print_info () { printf "%s\\n" "$*"; }\n'
'print_error () { printf "%s\\n" "$*" >&2; }\n'
'PORTWINE_DB_FILE="${3}.ppdb"\n'
'set_several_variables "${@:4}"\nedit_db_from_gui $keys_all\n'
)
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command",
lambda: ["bash", str(script)])
return {**game, "ppdb": ppdb, "script": script}
def test_edit_game_roundtrip(editable_game: dict) -> None:
from portprotonqt.cli import edit_game
result = edit_game(editable_game["id"], [
"PW_MANGOHUD=1", 'LAUNCH_PARAMETERS=--option=value "quoted"',
"PW_PREFIX_NAME=GAME", "WINEDLLOVERRIDES=d3d11=n,b;dxgi=n",
])
settings = result["runtime"]["settings"]
assert settings["PW_MANGOHUD"] == "1"
assert settings["LAUNCH_PARAMETERS"] == '--option=value "quoted"'
assert settings["WINEDLLOVERRIDES"] == "d3d11=n,b;dxgi=n"
assert settings["PW_PREFIX_NAME"] == "GAME"
assert settings["PW_WINDOWS_VER"] == "10"
assert editable_game["ppdb"].read_text().startswith("# Keep this comment\n")
@pytest.mark.parametrize("change", [
"PW_MANGOHUD=2", "NOT_A_SETTING=1", "PW_MANGOHUD", "PW_PREFIX_NAME=../other",
"PW_PREFIX_NAME=", "PW_MANGOHUD=$(touch /tmp/forbidden)", "PW_MANGOHUD=1\ntrue",
"LAUNCH_PARAMETERS=a|b", "LAUNCH_PARAMETERS=`id`",
])
def test_edit_game_rejects_invalid_values(editable_game: dict, change: str, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import edit_game
run = MagicMock()
monkeypatch.setattr("subprocess.run", run)
before = editable_game["ppdb"].read_bytes()
with pytest.raises(ValueError):
edit_game(editable_game["id"], ["PW_MANGOHUD=1", change])
run.assert_not_called()
assert editable_game["ppdb"].read_bytes() == before
@pytest.mark.parametrize("source", ["portproton", "gog", "egs"])
def test_edit_game_sources(editable_game: dict, source: str, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import edit_game
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{**editable_game, "source": source}])
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
assert 'export PW_MANGOHUD="1"' in editable_game["ppdb"].read_text()
@pytest.mark.parametrize("mode", ["missing", "steam", "uninstalled", "no-script", "failed", "not-saved"])
def test_edit_game_errors(editable_game: dict, mode: str, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import edit_game
if mode == "missing":
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
elif mode in {"steam", "uninstalled"}:
game = {**editable_game, "source": "steam"} if mode == "steam" else {**editable_game, "executable": None}
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [game])
elif mode == "no-script":
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command", lambda: None)
else:
editable_game["script"].write_text("exit 4\n" if mode == "failed" else "exit 0\n")
expected = RuntimeError if mode in {"failed", "not-saved"} else ValueError
with pytest.raises(expected):
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
@pytest.mark.parametrize("flags", [
["--set", "PW_MANGOHUD=1"], ["--edit-game", "gog:123"],
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--list-games"],
])
def test_edit_game_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
with pytest.raises(SystemExit) as error:
parse_args()
assert error.value.code == 2
@pytest.mark.parametrize("failure", [False, True])
def test_edit_game_cli_output(failure: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
monkeypatch.setattr(sys, "argv", [
"portprotonqt", "--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--raw",
])
edit = MagicMock(return_value={"id": "gog:123"})
if failure:
edit.side_effect = RuntimeError("write failed")
monkeypatch.setattr("portprotonqt.cli.edit_game", edit)
assert app.main() == (1 if failure else 0)
output = capsys.readouterr().out
assert output == "" if failure else orjson.loads(output) == {"id": "gog:123"}
edit.assert_called_once_with("gog:123", ["PW_MANGOHUD=1"])
@pytest.mark.parametrize("single", [False, True])
@pytest.mark.parametrize("pretty", [False, True])
def test_library_selected_fields(single: bool, pretty: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
command = ["--game-info", "gog:123"] if single else ["--list-games"]
monkeypatch.setattr(sys, "argv", [
"portprotonqt", *command, "--fields", "executable, icon,name,source",
"--pretty" if pretty else "--raw",
])
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
{"id": "gog:123", "name": "Game", "icon": "icon.png", "source": "gog",
"executable": "/games/game.exe", "description": "Not selected", "installed": True},
{"id": "steam:456", "name": "Steam Game", "source": "steam", "installed": True},
])
assert app.main() == 0
output = capsys.readouterr().out
expected = {"executable": "/games/game.exe", "icon": "icon.png", "name": "Game", "source": "gog"}
assert orjson.loads(output) == (expected if single else [
expected, {"executable": None, "icon": None, "name": "Steam Game", "source": "steam"},
])
assert (len(output.splitlines()) > 1) is pretty
def test_library_selected_fields_empty(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
from portprotonqt import app
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games", "--fields", "name"])
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
assert app.main() == 0
assert capsys.readouterr().out == "[]\n"
@pytest.mark.parametrize("flags", [
["--fields", "name"], ["--list-games", "--fields", ""],
["--list-games", "--fields", "name,,icon"], ["--list-games", "--fields", "name,"],
["--list-games", "--fields", "name;icon"],
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--fields", "name"],
])
def test_library_selected_fields_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
with pytest.raises(SystemExit) as error:
parse_args()
assert error.value.code == 2
@pytest.mark.parametrize("include_all", [False, True])
def test_list_games_installed_filter(include_all: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
arguments = ["portprotonqt", "--list-games", "--fields", "id", "--raw"]
if include_all:
arguments.append("--all")
monkeypatch.setattr(sys, "argv", arguments)
games = [
{"id": "portproton:missing.desktop", "installed": False},
{"id": "steam:123", "installed": True},
{"id": "gog:456", "installed": False},
{"id": "egs:789", "installed": True},
]
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: games)
assert app.main() == 0
expected = [game for game in games if include_all or game["installed"]]
assert orjson.loads(capsys.readouterr().out) == [{"id": game["id"]} for game in expected]
@pytest.mark.parametrize("flags", [["--all"], ["--game-info", "gog:123", "--all"]])
def test_all_requires_list_games(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
with pytest.raises(SystemExit) as error:
parse_args()
assert error.value.code == 2
+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)
+51 -3
View File
@@ -1,3 +1,4 @@
import argparse
import time
from types import SimpleNamespace
@@ -6,7 +7,53 @@ from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import QApplication
from portprotonqt.scripts_utils.easyterm import TerminalWidget
from portprotonqt.scripts_utils.easyterm import (
CONF_FALLBACK_SHELL,
TerminalWidget,
build_command,
)
PROCESS_TIMEOUT_SECONDS = 30
HELPER_PATH = "/usr/share/portproton/scripts/functions_helper"
USER_SHELL = "/usr/bin/zsh"
@pytest.fixture
def user_shell(monkeypatch: pytest.MonkeyPatch) -> str:
monkeypatch.setattr(
"portprotonqt.scripts_utils.easyterm.default_shell", lambda: USER_SHELL
)
monkeypatch.setattr(
"portprotonqt.scripts_utils.easyterm._portproton_functions_helper_path",
lambda: HELPER_PATH,
)
return USER_SHELL
def test_build_command_uses_bash_for_portproton_functions(user_shell: str) -> None:
command = build_command(argparse.Namespace(e=["pw_run Game"]))
assert command == [
CONF_FALLBACK_SHELL,
"-lc",
f"source {HELPER_PATH}; pw_run Game",
]
def test_build_command_uses_bash_for_exported_functions(
user_shell: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("BASH_FUNC_launch_game%%", "() { :; }")
command = build_command(argparse.Namespace(e=["launch_game"]))
assert command[0] == CONF_FALLBACK_SHELL
def test_build_command_keeps_user_shell_for_plain_commands(user_shell: str) -> None:
command = build_command(argparse.Namespace(e=["ls -l"]))
assert command == [user_shell, "-lc", "ls -l"]
def test_terminal_keeps_failed_command_output(
@@ -25,9 +72,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
+52
View File
@@ -1,14 +1,17 @@
"""Tests for themed file explorer icons."""
import os
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock
from pytest import MonkeyPatch
from PySide6.QtCore import QSize
from PySide6.QtGui import QColor, QIcon, QPixmap
from PySide6.QtWidgets import (
QApplication,
QDialog,
QListWidget,
QListWidgetItem,
QStyle,
@@ -16,6 +19,8 @@ from PySide6.QtWidgets import (
)
import portprotonqt.dialogs.file_explorer as file_explorer_module
from portprotonqt.input_manager.file_explorer import FileExplorerInputMixin
from portprotonqt.input_manager.constants import BUTTONS
from portprotonqt.dialogs.file_explorer import (
FileExplorer,
FileIconDelegate,
@@ -25,6 +30,53 @@ from portprotonqt.dialogs.file_explorer import (
_application = QApplication.instance() or QApplication([])
def test_executable_filter_keeps_exe_and_linux_executables(tmp_path: Path) -> None:
from PySide6.QtCore import QMimeDatabase
from PySide6.QtWidgets import QLabel
for name in ("Game.EXE", "launcher", "notes.txt"):
(tmp_path / name).touch()
(tmp_path / "launcher").chmod(0o755)
(tmp_path / "folder").mkdir()
explorer = SimpleNamespace(
file_list=QListWidget(), current_path=str(tmp_path), directory_only=False,
thumbnail_cache={}, pending_thumbnails={}, deferred_thumbnails={}, file_items={},
path_history={}, path_label=QLabel(), mime_db=QMimeDatabase(),
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
_get_list_icon=lambda name: None, _get_file_type_icon=lambda path: None,
schedule_thumbnail_loading=lambda: None,
)
FileExplorer.update_file_list(cast(FileExplorer, explorer))
assert [explorer.file_list.item(i).text() for i in range(explorer.file_list.count())] == [
"../", "folder/", "Game.EXE", "launcher",
]
def test_gamepad_opens_file_explorer_context_menu(monkeypatch: MonkeyPatch) -> None:
explorer = FileExplorer.__new__(FileExplorer)
QDialog.__init__(explorer)
explorer.file_list = QListWidget(explorer)
explorer.file_list.addItem("folder/")
explorer.file_list.setCurrentRow(0)
context_manager = MagicMock()
explorer.context_menu_manager = context_manager
manager = SimpleNamespace(file_explorer=explorer, _parent=SimpleNamespace())
monkeypatch.setattr(QApplication, "activeWindow", lambda: explorer)
monkeypatch.setattr(QApplication, "activePopupWidget", lambda: None)
FileExplorerInputMixin.handle_file_explorer_button(
cast(FileExplorerInputMixin, manager), next(iter(BUTTONS["context_menu"])), 1,
)
item = explorer.file_list.currentItem()
assert item is not None
context_manager.show_file_explorer_context_menu.assert_called_once_with(
explorer, explorer.file_list.visualItemRect(item).center(),
)
class FakeIcon:
"""Record icon files assigned to Qt modes."""
+28 -3
View File
@@ -8,7 +8,7 @@ from typing import Any, cast
from unittest.mock import patch
from PIL import Image
from pytest import MonkeyPatch, raises
from pytest import MonkeyPatch, raises, mark
import portprotonqt.game_card as game_card_module
import portprotonqt.image_utils as image_utils
@@ -608,9 +608,16 @@ def test_game_card_uses_exe_fallback_without_valid_cover(
]
@mark.parametrize("url, filename", [
("https://example.org/game.jpg", "game.jpg"),
("https://cdn2.steamgriddb.com/grid/unique-cover.png", "unique-cover.png"),
("https://deckyloader.ru/sgdb-cdn/grid/unique-cover.png", "unique-cover.png"),
])
def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
tmp_path: Path,
monkeypatch: MonkeyPatch,
url: str,
filename: str,
) -> None:
exe_path = tmp_path / "game.exe"
exe_path.write_bytes(b"MZ")
@@ -626,6 +633,8 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
timeout: int,
callback: Callable[[str | None], None],
) -> None:
assert _url == url
assert Path(_path).name == filename
callbacks.append(callback)
monkeypatch.setattr("portprotonqt.icon_extractor.CACHE_DIR", tmp_path / "cache" / "PortProtonQt")
@@ -642,14 +651,30 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
)
image_utils.load_pixmap_async(
"https://example.org/game.jpg", 100, 150,
url, 100, 150,
lambda pixmap: loaded_paths.append(cast(FakePixmap, pixmap).path),
app_name="game", fallback_exe=str(exe_path),
fallback_icon_path=str(fallback_path),
)
callbacks[0](None)
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / "game.jpg"
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / filename
downloaded_path.write_bytes(b"cover")
callbacks[0](str(downloaded_path))
assert loaded_paths == [str(fallback_path), str(downloaded_path)]
def test_custom_icon_overrides_extracted_icon(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
from portprotonqt.config.portproton import get_custom_data_dir_name, get_custom_icon_path
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
executable = "/games/custom/game.exe"
folder = tmp_path / "PortProtonQt" / "custom_data" / get_custom_data_dir_name(executable)
folder.mkdir(parents=True)
icon = folder / "icon.png"
icon.write_bytes(b"custom icon")
assert get_custom_icon_path(executable) == str(icon)
assert get_exe_icon_cache_path(executable) == str(icon)
icon.unlink()
assert get_custom_icon_path(executable) == ""
assert get_exe_icon_cache_path(executable) != str(icon)
+283 -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,248 @@ 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()
@mark.parametrize("row", [0, 1])
def test_gamepad_combo_confirm_emits_activation(row: int) -> None:
application = QApplication.instance() or QApplication([])
combo = QComboBox()
combo.addItems(["GOG", "PortProton"])
activated = []
combo.activated.connect(activated.append)
combo.show()
combo.showPopup()
combo.view().setCurrentIndex(combo.model().index(row, 0))
manager = InputManager.__new__(InputManager)
confirm = next(iter(input_manager.BUTTONS["confirm"]))
assert manager._handle_list_view_button(combo.view(), confirm)
assert activated == [row]
assert combo.currentIndex() == row
assert not combo.view().isVisible()
combo.close()
application.processEvents()
def test_readonly_combo_confirm_opens_popup_without_keyboard() -> None:
application = QApplication.instance() or QApplication([])
combo = QComboBox()
combo.addItems(["GOG", "PortProton"])
combo.setEditable(True)
field = combo.lineEdit()
assert field is not None
field.setReadOnly(True)
keyboard = MagicMock()
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=keyboard))
confirm = next(iter(input_manager.BUTTONS["confirm"]))
combo.show()
assert not manager._route_button_to_text_input(combo, field, confirm)
assert manager._handle_combo_button(field, confirm)
assert combo.view().isVisible()
keyboard.show_for_widget.assert_not_called()
combo.hidePopup()
combo.close()
application.processEvents()
def test_confirm_on_open_combo_activates_highlighted_row() -> None:
application = QApplication.instance() or QApplication([])
combo = QComboBox()
combo.addItems(["GOG", "PortProton"])
activated = []
combo.activated.connect(activated.append)
combo.show()
combo.showPopup()
combo.view().setCurrentIndex(combo.model().index(1, 0))
manager = InputManager.__new__(InputManager)
confirm = next(iter(input_manager.BUTTONS["confirm"]))
assert manager._handle_combo_button(combo, confirm)
assert activated == [1]
assert not combo.view().isVisible()
combo.close()
application.processEvents()
@mark.parametrize("direction", [-1, 1])
@mark.parametrize("column", [0, 1])
def test_run_after_dpad_reaches_delete_from_entry(direction: int, column: int) -> None:
from portprotonqt.input_manager.settings import SettingsInputMixin
app = QApplication.instance() or QApplication([])
table = QTableWidget(1, 2)
entry = QLineEdit("game.exe")
remove = QPushButton("delete")
table.setCellWidget(0, 0, entry)
table.setCellWidget(0, 1, remove)
manager = SimpleNamespace(settings_dialog=SimpleNamespace(advanced_table=table))
table.show()
table.activateWindow()
table.setCurrentCell(0, column)
entry.setFocus()
app.processEvents()
assert SettingsInputMixin._move_settings_inline_button_focus(cast(SettingsInputMixin, manager), table, direction)
assert QApplication.focusWidget() == remove
assert SettingsInputMixin._move_settings_inline_button_focus(cast(SettingsInputMixin, manager), table, -direction)
assert QApplication.focusWidget() == entry
table.close()
table.deleteLater()
def test_mouse_device_follows_emulation_toggle(monkeypatch: MonkeyPatch) -> None:
manager = InputManager.__new__(InputManager)
QObject.__init__(manager)
manager.mouse_emulation_enabled = True
manager.emulation_triggered = False
manager.mouse_emulation_thread = None
manager.ui = None
create = MagicMock()
monkeypatch.setattr(manager, "_async_enable_mouse_emulation", create)
manager._update_emulation_flag()
create.assert_not_called()
manager.emulation_triggered = True
manager._update_emulation_flag()
create.assert_called_once_with()
device = MagicMock()
manager.ui = device
manager.emulation_triggered = False
manager._update_emulation_flag()
device.close.assert_called_once_with()
assert manager.ui is None
assert manager.mouse_emulation_enabled
manager.emulation_triggered = True
manager._update_emulation_flag()
assert create.call_count == 2
def test_mouse_shutdown_collects_pending_device() -> None:
manager = InputManager.__new__(InputManager)
QObject.__init__(manager)
device = MagicMock()
thread = MagicMock()
thread.get_device.return_value = device
manager.mouse_emulation_thread = thread
manager.mouse_emulation_enabled = True
manager.ui = None
manager.disable_mouse_emulation()
manager._on_mouse_emulation_finished(True)
thread.wait.assert_called_once_with()
device.close.assert_called_once_with()
assert manager.ui is None
assert manager.mouse_emulation_thread is None
+120 -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)])
@@ -455,6 +451,7 @@ def test_failed_protondb_completion_preserves_library_scroll(
) -> None:
from portprotonqt.main_window import MainWindow
from portprotonqt.steam_api import api
import portprotonqt.portproton_api as portproton_api
manager, _ = library_scene
manager.rebuild_library_layout(scenario[0])
@@ -463,6 +460,7 @@ def test_failed_protondb_completion_preserves_library_scroll(
main.getColorPalette_from_pixmap = MagicMock()
main.game_library_manager = manager
main._get_portproton_desktop_files = lambda: set()
monkeypatch.setattr(portproton_api, "get_user_conf_setting", lambda _key: None)
monkeypatch.setattr(api, "load_protondb_status", lambda _appid: None)
monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace(cache_dir=tmp_path))
responses: list[Callable[[str | None], None]] = []
@@ -545,3 +543,110 @@ 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_space_enters_and_toggles_card_selection_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)
from portprotonqt.context_menu_manager import ContextMenuManager
from PySide6.QtGui import QKeyEvent
selection_manager = object.__new__(ContextMenuManager)
selection_manager.game_library_manager = manager
selection_manager.card_selection_mode = False
manager.game_card_cache[(card.name, card.exec_line)] = card
card.context_menu_manager = selection_manager
event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Space, Qt.KeyboardModifier.NoModifier)
card.keyPressEvent(event)
assert card.batch_selected
assert selection_manager.card_selection_mode
event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Space, Qt.KeyboardModifier.NoModifier)
card.keyPressEvent(event)
assert not card.batch_selected
assert not selection_manager.card_selection_mode
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()
+414 -23
View File
@@ -11,9 +11,10 @@ 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.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
QComboBox,
@@ -22,6 +23,9 @@ from PySide6.QtWidgets import (
QLabel,
QMenu,
QMainWindow,
QMessageBox,
QStyle,
QStyleOptionComboBox,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
@@ -31,10 +35,11 @@ from PySide6.QtWidgets import (
from portprotonqt.animations.library_controls import _animation_duration
from portprotonqt.animations.game_card import GameCardAnimations
from portprotonqt.config import game_config
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton, CustomComboBox
from portprotonqt.detail_pages import DetailPageManager
from portprotonqt.game_card import GameCard, SourceCorner
from portprotonqt.game_library_manager import FullLibraryTile, GameLibraryManager
from portprotonqt.input_manager import InputManager
from portprotonqt.main_window import MainWindow
from portprotonqt.search_utils import SearchOptimizer, search_index
from portprotonqt.tray_manager import TrayManager
@@ -69,6 +74,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 +316,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()
@@ -310,7 +434,7 @@ def test_minimal_tray_exits_after_stopping_game(monkeypatch: MonkeyPatch) -> Non
manager.tray_icon.hide.assert_called_once_with()
quit_app.assert_called_once_with()
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, False)])
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, True)])
def test_source_ribbon_visibility(
monkeypatch: MonkeyPatch,
vertical_layout: bool,
@@ -667,6 +791,9 @@ def test_vertical_theme_rebuilds_autoinstall_layout() -> None:
window.auto_size_slider.maximum.return_value = 250
window.autoInstallLoading = False
window._start_autoinstall_load = MagicMock()
window.auto_install_tab_index = 1
window.stackedWidget = MagicMock()
window.stackedWidget.currentIndex.return_value = 1
window.refresh_autoinstall_layout()
@@ -1158,6 +1285,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))
@@ -1297,7 +1425,7 @@ def test_library_source_filter_stays_top_aligned_without_checkbox(
_application = QApplication.instance() or QApplication([])
window = MainWindow.__new__(MainWindow)
test_window = cast(Any, window)
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="")
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE="")
test_window._create_library_combo = lambda labels, _tooltip: QComboBox()
test_window._set_combo_current_key = lambda *_args: None
test_window._register_gamepad_tooltip = lambda *_args: None
@@ -1760,7 +1888,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 +1900,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 +1969,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 +1979,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 +1997,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)
@@ -2133,10 +2264,12 @@ def test_steam_launch_plays_game_launch_sound(monkeypatch: MonkeyPatch) -> None:
assert launch_events == ["popen", "game_launch"]
@mark.parametrize("custom_name", [None, "Edited Local Game"])
def test_process_portproton_desktop_calls_callback_without_asset_download(
tmp_config_dir: Path,
tmp_path: Path,
monkeypatch: Any,
custom_name: str | None,
) -> None:
exe_path = tmp_path / "Game.exe"
exe_path.write_text("", encoding="utf-8")
@@ -2152,8 +2285,15 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
window = MainWindow.__new__(MainWindow)
window.portproton_location = str(tmp_path)
results: list[tuple | None] = []
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
if custom_name:
from portprotonqt.config import get_custom_data_dir_name
game_dir = custom_data_path / get_custom_data_dir_name(str(exe_path))
game_dir.mkdir(parents=True)
(game_dir / "metadata.txt").write_text(f"name={custom_name}\n", encoding="utf-8")
def fake_steam_info(_name: str, _exec_line: str, callback: Any) -> None:
assert _name == "Test Game"
callback({})
monkeypatch.setattr(
@@ -2173,10 +2313,9 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
assert len(results) == 1
assert results[0] is not None
assert results[0][0] == "Test Game"
assert results[0][0] == (custom_name or "Test Game")
assert results[0][5] == f"portproton {exe_path}"
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
assert not custom_data_path.exists()
assert custom_data_path.exists() == bool(custom_name)
def test_remove_empty_custom_data_dirs_keeps_non_empty_dirs(tmp_config_dir: Path) -> None:
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
@@ -2477,7 +2616,7 @@ def test_horizontal_layout_menu_maps_endless_modes(
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
monkeypatch.setattr(library_tab_module.ui_config, "get_library_layout_mode", lambda _: "theme")
window = cast(Any, SimpleNamespace(
theme=SimpleNamespace(CHECKBOX_STYLE=""),
theme=SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=""),
_create_library_combo=lambda labels, tooltip: QComboBox(),
_set_combo_current_key=MagicMock(),
_register_gamepad_tooltip=MagicMock(),
@@ -2518,7 +2657,7 @@ def test_library_layout_control_rebuilds_both_libraries(
monkeypatch.setattr(
library_tab_module.ui_config,
"get_library_layout_mode",
lambda theme_mode: "grid",
MagicMock(side_effect=["list", "grid", "grid"]),
)
LibraryMixin._on_library_layout_changed(window, 1)
@@ -2749,15 +2888,22 @@ def test_library_refresh_updates_connected_gog_library(
("load", {"force_load": True}),
]
@mark.parametrize("display_filter, stores", [
("all", ["gog", "egs"]),
("gog,portproton", ["gog"]),
("steam,portproton", []),
])
def test_library_refresh_updates_connected_stores_for_all_filter(
tmp_path: Path,
monkeypatch: MonkeyPatch,
display_filter: str,
stores: list[str],
) -> None:
gog_auth_path = tmp_path / "gog_auth.json"
egs_user_path = tmp_path / "egs_user.json"
gog_auth_path.touch()
egs_user_path.touch()
monkeypatch.setattr(game_config, "get_display_filter", lambda: "all")
monkeypatch.setattr(game_config, "get_display_filter", lambda: display_filter)
monkeypatch.setattr(
library_tab_module.QTimer,
"singleShot",
@@ -2779,11 +2925,7 @@ def test_library_refresh_updates_connected_stores_for_all_filter(
LibraryMixin.refreshGames(cast(Any, window))
assert calls == [
"gog",
"egs",
("load", {"force_load": True}),
]
assert calls == [*stores, ("load", {"force_load": True})]
def test_library_refresh_skips_gog_for_portproton_filter(
tmp_path: Path,
@@ -2986,4 +3128,253 @@ def test_autoinstall_table_columns_match_header(
assert abs(heading.mapTo(window, heading.rect().center()).x()
- 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 == []
@mark.parametrize("mode", ["grid", "list", "horizontal", "horizontal_top"])
def test_hidden_autoinstall_defers_layout_rebuild(mode: str, monkeypatch: MonkeyPatch) -> None:
window = cast(Any, AutoInstallMixin())
window.theme = SimpleNamespace(LIBRARY_LAYOUT_MODE=mode)
window.autoInstallContainer = MagicMock()
window.stackedWidget = MagicMock()
window.stackedWidget.currentIndex.return_value = 0
window.auto_install_tab_index = 1
window.autoInstallLoaded = True
window._set_autoinstall_container_layout = MagicMock()
window._start_autoinstall_load = MagicMock()
monkeypatch.setattr(autoinstall_tab_module.ui_config, "get_library_layout_mode", lambda _: mode)
window.refresh_autoinstall_layout()
window.refresh_autoinstall_layout()
assert window._autoinstall_layout_pending is True
assert window.autoInstallLoaded is False
window._set_autoinstall_container_layout.assert_not_called()
window._start_autoinstall_load.assert_not_called()
def test_autoinstall_load_applies_pending_layout_before_loading() -> None:
window = cast(Any, AutoInstallMixin())
window._autoinstall_layout_pending = True
window.autoInstallLoaded = True
window.refresh_autoinstall_layout = MagicMock()
window.portproton_api = MagicMock()
window._start_autoinstall_load()
window.refresh_autoinstall_layout.assert_called_once_with()
window.portproton_api.start_autoinstall_games_load.assert_not_called()
def test_library_theme_grid_switch_preserves_cards(
monkeypatch: MonkeyPatch, tmp_config_dir: Path,
) -> None:
window = cast(Any, SimpleNamespace(
library_layout_keys=["theme", "grid"],
theme=SimpleNamespace(LIBRARY_LAYOUT_MODE="grid"),
game_library_manager=SimpleNamespace(rebuild_library_layout=MagicMock()),
refresh_autoinstall_layout=MagicMock(),
))
monkeypatch.setattr(library_tab_module.ui_config, "get_library_layout_mode", lambda _: "grid")
monkeypatch.setattr(library_tab_module.ui_config, "set_library_layout_mode", MagicMock())
monkeypatch.setattr(library_tab_module.ui_config, "set_horizontal_card_orientation", MagicMock())
LibraryMixin._on_library_layout_changed(window, 1)
LibraryMixin._on_library_layout_changed(window, 0)
window.game_library_manager.rebuild_library_layout.assert_not_called()
window.refresh_autoinstall_layout.assert_not_called()
def test_deferred_autoinstall_layout_loads_latest_mode_once(
monkeypatch: MonkeyPatch,
) -> None:
window = cast(Any, AutoInstallMixin())
window.theme = SimpleNamespace(LIBRARY_LAYOUT_MODE="grid")
window.autoInstallContainer = MagicMock()
window.autoInstallStatusHeader = MagicMock()
window.auto_size_slider = MagicMock()
window.auto_size_slider.maximum.return_value = 250
window.stackedWidget = MagicMock()
window.stackedWidget.currentIndex.return_value = 0
window.auto_install_tab_index = 1
window.autoInstallLoaded = True
window.autoInstallLoading = False
window._set_autoinstall_container_layout = MagicMock()
window._on_autoinstall_games_loaded = MagicMock()
window.portproton_api = MagicMock()
mode = ["list"]
monkeypatch.setattr(autoinstall_tab_module.ui_config, "get_library_layout_mode", lambda _: mode[0])
window.refresh_autoinstall_layout()
mode[0] = "vertical"
window.refresh_autoinstall_layout()
window.stackedWidget.currentIndex.return_value = 1
window._start_autoinstall_load()
window._set_autoinstall_container_layout.assert_called_once_with("vertical")
window.portproton_api.start_autoinstall_games_load.assert_called_once_with(
window._on_autoinstall_games_loaded, force_refresh=False,
)
assert window._autoinstall_layout_pending is False
assert window.autoInstallLoading is True
@mark.parametrize("initial, index, expected", [
("all", 2, "gog"),
("gog", 4, "gog,portproton"),
("gog,portproton", 2, "portproton"),
("portproton", 4, "all"),
("gog,portproton", 0, "all"),
("gog,portproton", 5, "favorites"),
("favorites", 2, "gog"),
])
def test_library_source_multiselection(
initial: str, index: int, expected: str, monkeypatch: MonkeyPatch,
) -> None:
_application = QApplication.instance() or QApplication([])
combo = QComboBox()
labels = ["All", "Steam", "GOG", "Epic Games", "PortProton", "Favorites"]
combo.addItems(labels)
combo.setEditable(True)
saved = MagicMock()
monkeypatch.setattr(game_config, "get_display_filter", lambda: initial)
monkeypatch.setattr(game_config, "set_display_filter", saved)
window = cast(Any, SimpleNamespace(
filter_keys=["all", "steam", "gog", "egs", "portproton", "favorites"],
filter_labels=labels, gamesDisplayCombo=combo,
onlyInstalledCheckBox=MagicMock(),
_position_library_controls_widget=MagicMock(),
_on_only_installed_changed=MagicMock(),
))
combo.activated.connect(lambda row: LibraryMixin._on_library_filter_changed(window, row))
combo.view().setCurrentIndex(combo.model().index(index, 0))
manager = InputManager.__new__(InputManager)
manager._activate_list_view_selection(combo.view())
saved.assert_called_once_with(expected)
assert combo.currentText() == " + ".join(
label for key, label in zip(window.filter_keys, labels, strict=True)
if key in expected.split(",")
)
for row, key in enumerate(window.filter_keys):
assert combo.itemData(row, Qt.ItemDataRole.CheckStateRole) == (
Qt.CheckState.Checked if key in expected.split(",") else Qt.CheckState.Unchecked
)
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch) -> None:
_application = QApplication.instance() or QApplication([])
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog,portproton")
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
callbacks = {}
loaded = MagicMock()
window = cast(Any, SimpleNamespace(
_loading_games=False, launch_exe=None, games_loaded=SimpleNamespace(emit=loaded),
_load_gog_games_async=lambda callback: callbacks.update(gog=callback),
_load_portproton_games_async=lambda callback: callbacks.update(portproton=callback),
_load_steam_games_async=MagicMock(), _load_egs_games_async=MagicMock(),
))
MainWindow.loadGames(window, force_load=True)
pp_game = ("PP", "", "", "", "", "pp.exe")
gog_game = ("GOG", "", "", "", "", "gog://launch/1")
callbacks["gog"]([gog_game])
loaded.assert_not_called()
callbacks["portproton"]([pp_game])
loaded.assert_called_once_with([pp_game, gog_game])
window._load_steam_games_async.assert_not_called()
window._load_egs_games_async.assert_not_called()
@mark.parametrize("click_target", ["text", "arrow"])
def test_library_source_filter_opens_across_entire_field(
click_target: str, monkeypatch: MonkeyPatch,
) -> None:
application = QApplication.instance() or QApplication([])
window = MainWindow.__new__(MainWindow)
test_window = cast(Any, window)
source_style = "QListView::indicator { image: none; }"
test_window.theme = SimpleNamespace(
CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=source_style,
COMBOBOX_STYLE="QComboBox { combobox-popup: 0; }", SCROLL_STYLE="",
)
test_window._register_gamepad_tooltip = lambda *_args: None
test_window._delay_library_controls_hover_close = lambda: None
test_window._position_library_controls_widget = lambda: None
test_window._on_only_installed_changed = lambda _checked: None
saved = MagicMock()
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog")
monkeypatch.setattr(game_config, "set_display_filter", saved)
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
controls = QWidget()
LibraryMixin._add_library_filter_controls(window, QGridLayout(controls))
controls.show()
application.processEvents()
combo = test_window.gamesDisplayCombo
line_edit = combo.lineEdit()
option = QStyleOptionComboBox()
combo.initStyleOption(option)
arrow = combo.style().subControlRect(
QStyle.ComplexControl.CC_ComboBox, option, QStyle.SubControl.SC_ComboBoxArrow, combo
)
global_point = combo.mapToGlobal(
line_edit.geometry().center() if click_target == "text" else arrow.center()
)
target = QApplication.widgetAt(global_point)
assert isinstance(target, QWidget) and target is (line_edit if click_target == "text" else combo)
assert source_style in combo.styleSheet()
QTest.mouseMove(target, target.mapFromGlobal(global_point))
QTest.mouseClick(target, Qt.MouseButton.LeftButton, pos=target.mapFromGlobal(global_point))
application.processEvents()
assert combo.view().isVisible()
index = combo.model().index(test_window.filter_keys.index("portproton"), 0)
QTest.mouseMove(combo.view().viewport(), combo.view().visualRect(index).center())
QTest.mouseClick(combo.view().viewport(), Qt.MouseButton.LeftButton,
pos=combo.view().visualRect(index).center())
application.processEvents()
saved.assert_called_once_with("gog,portproton")
assert combo.currentText() == "GOG + PortProton"
controls.close()
@mark.parametrize("readonly", [True, False])
def test_custom_combo_opens_readonly_field_on_mouse_release(readonly: bool) -> None:
application = QApplication.instance() or QApplication([])
combo = CustomComboBox()
combo.addItems(["GOG", "PortProton"])
combo.setEditable(True)
line_edit = combo.lineEdit()
assert line_edit is not None
line_edit.setReadOnly(readonly)
line_edit.installEventFilter(combo)
combo.show()
application.processEvents()
QTest.mousePress(line_edit, Qt.MouseButton.LeftButton, pos=line_edit.rect().center())
assert not combo.view().isVisible()
QTest.mouseRelease(line_edit, Qt.MouseButton.LeftButton, pos=line_edit.rect().center())
application.processEvents()
assert combo.view().isVisible() is readonly
combo.hidePopup()
combo.close()
-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:
+117 -6
View File
@@ -43,20 +43,22 @@ gui_config () {
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str) -> None:
@mark.parametrize("copies", [1, 3])
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str, copies: int) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
condition = helper.split(' if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]', 1)[1].split(
condition = helper.split(' local -a run_after_exes=', 1)[1].split(
" then", 1,
)[0]
condition = ' local -a run_after_exes=' + condition
main_exe = tmp_path / "Game.exe"
second_exe = tmp_path / second_name
main_exe.touch()
second_exe.touch()
result = subprocess.run(
["bash", "-c", 'if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]' + condition
+ "then exit 0; else exit 1; fi"],
["bash", "-c", 'check_run_after() {\n' + condition
+ "then return 0; else return 1; fi; }; check_run_after"],
env={**os.environ, "PW_EXE_FILE": str(main_exe),
"PW_RUN_AFTER_EXE": str(second_exe)},
"PW_RUN_AFTER_EXE": ",".join([str(second_exe)] * copies)},
capture_output=True, text=True, check=False,
)
assert result.returncode == 0, result.stderr
@@ -112,7 +114,9 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
)[0]
main_path = "D:\\Games\\Test Game\\Test Game.exe"
result = subprocess.run(
["bash", "-c", 'pw_run() { env; }\ncat > "$run_after_bat" <<EOF\n' + block],
["bash", "-c", 'pw_run() { env; }\nlaunch() {\n'
'local -a run_after_exes=(unused) run_after_environment=()\n'
'cat > "$run_after_bat" <<EOF\n' + block + '\n}; launch'],
env={**os.environ, "run_after_bat": str(tmp_path / "launch.bat"),
"pw_exe_file_win": main_path, "run_after_exe_win": trainer_path,
"wait_delay_int": "3", "LAUNCH_PARAMETERS": ""},
@@ -120,6 +124,7 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
)
assert f"PW_RUN_AFTER_MAIN_PATH={main_path}" in result.stdout
assert f"PW_RUN_AFTER_SECOND_PATH={trainer_path}" in result.stdout
assert "PW_RUN_AFTER_EXE=\n" in result.stdout
batch = (tmp_path / "launch.bat").read_text(encoding="ascii")
assert 'start "" "%PW_RUN_AFTER_SECOND_PATH%"' in batch
@@ -192,3 +197,109 @@ 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
def test_run_after_batch_preserves_duplicate_entries(tmp_path: Path) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
block = helper.split(' cat > "${run_after_bat}" <<EOF\n', 1)[1].split(
' try_remove_file "${run_after_bat}"', 1,
)[0]
exe = tmp_path / "EVE online.exe"
exe.touch()
script = '''
loader() { printf 'Z:%s' "$3"; }
pw_run() { env; }
launch() {
local -a run_after_exes=("$EXE" "$EXE" "$EXE") run_after_environment=()
local run_after_index run_after_entry
WINELOADER=loader
cat > "$run_after_bat" <<EOF
''' + block + '\n}; launch'
batch_path = tmp_path / "launch.bat"
result = subprocess.run(
["bash", "-c", script], capture_output=True, text=True, check=True,
env={**os.environ, "EXE": str(exe), "run_after_bat": str(batch_path),
"pw_exe_file_win": "Z:\\EVE online.exe",
"run_after_exe_win": "Z:\\EVE online.exe", "wait_delay_int": "3"},
)
batch = batch_path.read_text()
assert batch.count('start ""') == 4
assert '%PW_RUN_AFTER_PATH_1%' in batch
assert '%PW_RUN_AFTER_PATH_2%' in batch
expected = "Z:" + str(exe).replace("/", "\\")
assert f"PW_RUN_AFTER_PATH_1={expected}" in result.stdout
assert f"PW_RUN_AFTER_PATH_2={expected}" in result.stdout
def test_run_after_wrapper_runs_script_list_with_duplicates(tmp_path: Path) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
wrapper = helper.split("pw_launch_wrapper () {", 1)[1].split(
"export -f pw_launch_wrapper", 1,
)[0]
first = tmp_path / "first script.sh"
second = tmp_path / "second.sh"
for script, message in [(first, "first"), (second, "second")]:
script.write_text(f"#!/bin/bash\nprintf '{message}\\n'\n")
script.chmod(0o700)
result = subprocess.run(
["bash", "-c", "pw_launch_wrapper () {" + wrapper
+ "\npw_launch_wrapper printf 'game\\n'"],
env={**os.environ, "PW_RUN_AFTER_EXE": f"{first},{second},{first}"},
capture_output=True, text=True, check=True,
)
assert result.stdout.splitlines() == ["first", "second", "first", "game"]
def test_run_after_wrapper_preserves_command_with_comma(tmp_path: Path) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
wrapper = helper.split("pw_launch_wrapper () {", 1)[1].split(
"export -f pw_launch_wrapper", 1,
)[0]
result = subprocess.run(
["bash", "-c", "pw_launch_wrapper () {" + wrapper
+ "\npw_launch_wrapper printf 'game\\n'"],
cwd=tmp_path,
env={**os.environ, "PW_RUN_AFTER_EXE": "printf '%s\\n' first,second"},
capture_output=True, text=True, check=True,
)
assert result.stdout.splitlines() == ["first,second", "game"]
+117 -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)
@@ -174,3 +219,74 @@ def test_disabled_steam_proton_scanning_keeps_local_versions(
assert manager.get_installed_versions() == [("LocalWine", str(local_wine))]
compat.assert_not_called()
home.assert_not_called()
def test_metadata_error_preserves_local_versions(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
from PySide6.QtWidgets import QLabel, QStackedWidget, QTabWidget
import portprotonqt.dialogs.proton_manager as module
app = QApplication.instance() or QApplication([])
local_version = tmp_path / "GE-Proton"
local_version.mkdir()
manager = SimpleNamespace(
content_stack=QStackedWidget(),
tab_widget=QTabWidget(),
theme=SimpleNamespace(GETWINE_WINDOW_STYLE=""),
)
manager.content_stack.addWidget(QWidget())
manager.content_stack.addWidget(QWidget())
manager.tab_widget.addTab(QWidget(), "Loading")
def create_installed_tab() -> None:
assert local_version.is_dir()
manager.tab_widget.addTab(QLabel(local_version.name), "Installed")
manager.create_installed_tab = create_installed_tab
monkeypatch.setattr(module, "_", lambda text: text)
module.ProtonManager.on_wine_data_load_error(cast(Any, manager), "ConnectionError: " + "x" * 2000)
assert manager.content_stack.currentIndex() == 1
assert manager.tab_widget.count() == 1
assert manager.tab_widget.currentIndex() == 0
assert manager.tab_widget.widget(0).text() == local_version.name
manager.tab_widget.deleteLater()
manager.content_stack.deleteLater()
app.processEvents()
def test_installed_sizes_update_after_metadata_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
from PySide6.QtCore import QEventLoop, QTimer
from PySide6.QtWidgets import QPushButton, QTableWidget
from portprotonqt.theme_manager import ThemeWrapper, load_theme
import portprotonqt.dialogs.proton_manager as module
app = QApplication.instance() or QApplication([])
version = tmp_path / "GE-Proton"
version.mkdir()
(version / "wine").write_bytes(b"x" * 2048)
monkeypatch.setattr(module.ProtonManager, "start_loading_wine_data", lambda self: None)
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **kwargs: QPushButton(text))
monkeypatch.setattr(module.ProtonManager, "get_installed_versions", lambda self: [("GE-Proton", str(version))])
manager = module.ProtonManager(portproton_location=str(tmp_path), theme=ThemeWrapper(load_theme("standart")))
manager.on_wine_data_load_error("Offline")
workers = list(manager._installed_size_tables)
loop = QEventLoop()
workers[0].finished.connect(loop.quit)
QTimer.singleShot(3000, loop.quit)
loop.exec()
app.processEvents()
try:
installed_tab = manager.tab_widget.widget(0)
assert installed_tab is not None
table = installed_tab.findChild(QTableWidget)
assert table is not None
size_item = table.item(0, 2)
assert size_item is not None
assert size_item.text() == "2.0 KiB"
finally:
from shiboken6 import isValid
for worker in workers:
if isValid(worker):
worker.wait()
manager.close()
app.processEvents()
+407 -7
View File
@@ -3,7 +3,7 @@
import subprocess
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from typing import Any, cast
import pytest
from PySide6.QtCore import QEvent, QRect, Qt, QTimer
@@ -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,
@@ -40,6 +39,7 @@ from portprotonqt.dialogs.settings_mangohud import (
from portprotonqt.dialogs.settings_vkbasalt import VkBasaltSettingsMixin
PAINT_SETTLE_MS = 250
_application = QApplication.instance() or QApplication([])
@@ -458,13 +458,29 @@ def test_settings_table_descriptions_and_centered_controls(theme_name: str) -> N
table.selectRow(1)
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
assert isinstance(table.cellWidget(1, 0), QLabel)
label = table.cellWidget(1, 0)
ExeSettingsDialog._set_favorite_text_cells(
cast(ExeSettingsDialog, dialog), 1, "key", "Updated", "New <description>",
)
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
assert table.cellWidget(1, 0) is label
assert isinstance(label, QLabel)
assert label.text() == theme.SETTINGS_SELECTED_TEXT_HTML.format(
text="Updated<br>" + theme.SETTINGS_SELECTED_DESCRIPTION_HTML.format(
description="New &lt;description&gt;",
),
)
updated_item = table.item(1, 0)
assert updated_item is not None
assert updated_item.data(Qt.ItemDataRole.AccessibleTextRole) == "Updated\nNew <description>"
assert updated_item.text() == ""
table.close()
@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
@@ -472,7 +488,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)
@@ -481,9 +497,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()
@@ -491,10 +514,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:
@@ -509,6 +533,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
@@ -631,3 +761,273 @@ 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()
def test_run_after_normalization_preserves_duplicates(tmp_path: Path) -> None:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
dialog = SimpleNamespace(exe_path=str(tmp_path / "Game.exe"))
dialog._resolve_run_after_exe_path = lambda value: ExeSettingsDialog._resolve_run_after_exe_path(cast(ExeSettingsDialog, dialog), value)
value = "EVE.exe,EVE.exe,Trainer.exe"
assert dialog._resolve_run_after_exe_path(value) == ",".join(
str(tmp_path / name) for name in value.split(",")
)
def test_run_after_inline_entries_preserve_duplicates(tmp_path: Path) -> None:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
owner = cast(Any, QWidget())
owner.advanced_table = QTableWidget(2, 3)
owner.theme = SimpleNamespace(ACTION_BUTTON_STYLE="", LINE_EDIT_STYLE="")
container = QWidget()
target = QLineEdit(container)
owner.advanced_table.setCellWidget(0, 1, container)
from PySide6.QtWidgets import QTableWidgetItem
owner.advanced_table.setItem(1, 0, QTableWidgetItem("next setting"))
exe = tmp_path / "Game.exe"
exe.touch()
target.setText(f"{exe},{exe}")
ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
assert owner.advanced_table.rowCount() == 4
item = owner.advanced_table.item(1, 0)
assert item is not None and item.text() == str(exe)
item = owner.advanced_table.item(2, 0)
assert item is not None and item.text() == str(exe)
ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
assert owner.advanced_table.rowCount() == 2
item = owner.advanced_table.item(1, 0)
assert item is not None and item.text() == "next setting"
owner.advanced_table.deleteLater()
def test_run_after_entry_paint_preserves_text_and_height() -> None:
from PySide6.QtWidgets import QTableWidgetItem
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
table = QTableWidget(1, 2)
table.viewport().setProperty("ppqt_settings_values", True)
table.setItem(0, 0, QTableWidgetItem("Game.exe"))
entry = QLineEdit("Game.exe")
table.setCellWidget(0, 0, entry)
table.resizeRowsToContents()
table.show()
QApplication.processEvents()
height = table.rowHeight(0)
for _ in range(2):
ExeSettingsDialog.eventFilter(
cast(ExeSettingsDialog, SimpleNamespace()),
table.viewport(), QEvent(QEvent.Type.Paint),
)
assert entry.text() == "Game.exe"
item = table.item(0, 0)
assert item is not None and item.text() == "Game.exe"
assert table.rowHeight(0) == height
assert entry.height() >= entry.sizeHint().height()
table.close()
table.deleteLater()
def test_run_after_removing_only_entry_clears_field(tmp_path: Path) -> None:
from PySide6.QtWidgets import QTableWidgetItem
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
owner = cast(Any, QWidget())
owner.advanced_table = QTableWidget(2, 3)
owner.theme = SimpleNamespace(ACTION_BUTTON_STYLE="", LINE_EDIT_STYLE="")
owner._toggle_run_after_entries = lambda target: ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
container = QWidget()
target = QLineEdit(container)
owner.advanced_table.setCellWidget(0, 1, container)
owner.advanced_table.setItem(1, 0, QTableWidgetItem("next setting"))
exe = tmp_path / "Game.exe"
exe.touch()
target.setText(str(exe))
owner._toggle_run_after_entries(target)
ExeSettingsDialog._remove_run_after_entry(cast(ExeSettingsDialog, owner), target, 0)
assert target.text() == ""
assert owner.advanced_table.rowCount() == 2
owner.advanced_table.deleteLater()
+1222 -49
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -116,3 +116,42 @@ 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()
@pytest.mark.parametrize("source", ["exe", "gog", "egs"])
def test_matched_steam_game_keeps_cover_when_app_details_fail(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: str,
) -> None:
import portprotonqt.steam_api.api as api
monkeypatch.setattr("portprotonqt.config.cache.CACHE_DIR", tmp_path / "cache")
executable = tmp_path / "Praest.exe"
executable.touch()
exec_line = str(executable) if source == "exe" else f"{source}://launch/graven"
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", exec_line, results.append)
assert len(results) == 1
assert results[0]["appid"] == 1371690
assert results[0]["steam_game"] == "true"
expected_cover = (
"https://steamcdn-a.akamaihd.net/steam/apps/1371690/library_600x900_2x.jpg"
if source == "exe" else ""
)
assert results[0]["cover"] == expected_cover
with (
patch("portprotonqt.steam_api.cache.get_exiftool_data", side_effect=AssertionError("EXE reread")),
patch.object(api, "get_steam_apps_and_index_async", side_effect=AssertionError("Catalog reloaded")),
patch.object(api, "search_app", side_effect=AssertionError("AppID searched again")),
):
api.get_steam_game_info_async("GRAVEN", exec_line, results.append)
assert len(results) == 2
assert results[1] == results[0]
+193 -1
View File
@@ -3,12 +3,15 @@
from collections.abc import Callable
from datetime import datetime, timedelta
from pathlib import Path
from threading import Event
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
import orjson
from PySide6.QtCore import QEventLoop, QThread, QTimer
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QMainWindow
from pytest import MonkeyPatch, mark
from shiboken6 import delete
@@ -20,6 +23,7 @@ from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin
from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin as GOGMixin
import portprotonqt.main_window as main_window_module
import portprotonqt.tabs.download_tab as download_tab_module
from portprotonqt.tabs.workers import MainWindowWorkersMixin
@mark.parametrize("source", ("gog", "egs"))
@@ -103,6 +107,83 @@ def test_store_dlc_selection_remains_available_during_download(
assert window.store_dlc_worker is worker
@mark.parametrize("source", ("gog", "egs"))
@mark.parametrize("failed", (False, True))
def test_dlc_worker_released_before_modal_and_retry(
monkeypatch: MonkeyPatch, source: str, failed: bool
) -> None:
application = download_tab_module.QApplication.instance() or download_tab_module.QApplication([])
loop = QEventLoop()
observations = []
class Window(GOGMixin, QMainWindow):
def _on_store_dlcs_loaded(self, source: str, game: dict, dlcs: list[dict]) -> None:
observations.append((self.store_dlc_worker, QThread.currentThread()))
dialog = download_tab_module.QDialog(self)
QTimer.singleShot(0, dialog.reject)
dialog.exec()
loop.quit()
window: Any = Window()
window.gog_api = cast(Any, object())
window.egs_api = cast(Any, object())
def run(worker: download_tab_module.StoreDLCWorker) -> None:
if failed:
worker.failed.emit("failed")
else:
worker.loaded.emit([])
def warning(*args: Any) -> None:
observations.append((window.store_dlc_worker, QThread.currentThread()))
loop.quit()
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
timeout = QTimer()
timeout.setSingleShot(True)
timeout.timeout.connect(loop.quit)
for _attempt in range(2):
window._select_store_dlcs(source, {"app_id": "123", "title": "Game"})
timeout.start(3000)
loop.exec()
timeout.stop()
assert window.store_dlc_worker is None
assert observations == [(None, application.thread())] * 2
window.deleteLater()
def test_dlc_worker_shutdown_suppresses_picker(monkeypatch: MonkeyPatch) -> None:
application = download_tab_module.QApplication.instance() or download_tab_module.QApplication([])
class Window(GOGMixin, QMainWindow, MainWindowWorkersMixin):
pass
window: Any = Window()
window.gog_api = cast(Any, object())
window._on_store_dlcs_loaded = MagicMock()
warning = MagicMock()
started = Event()
def run(worker: download_tab_module.StoreDLCWorker) -> None:
started.set()
while not worker.isInterruptionRequested():
Event().wait(0.001)
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
window._select_store_dlcs("gog", {"app_id": "123", "title": "Game"})
worker = window.store_dlc_worker
assert started.wait(3)
window._stopWorkerThread("store_dlc_worker", None)
assert not worker.isRunning()
application.processEvents()
assert window.store_dlc_worker is None
window._on_store_dlcs_loaded.assert_not_called()
warning.assert_not_called()
window.deleteLater()
def test_store_download_queues_reject_duplicates() -> None:
active = {"app_id": "active", "title": "Active"}
queued = {"app_id": "queued", "title": "Queued"}
@@ -920,15 +1001,19 @@ def test_installed_filter_excludes_uninstalled_gog_games(
get_launch_target=lambda app_id: f"/games/{app_id}/game.exe",
)
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
loaded_metadata = []
monkeypatch.setattr(
"portprotonqt.main_window.get_steam_game_info_async",
lambda _name, _uri, callback: callback({}),
lambda name, _uri, callback: (
loaded_metadata.append(name) or callback({})
),
)
results = []
MainWindow._load_gog_games_async(window, results.append)
assert [game[3] for game in results[0]] == ["installed"]
assert loaded_metadata == ["Installed"]
assert results[0][0][5] == "gog://launch/installed"
def test_installed_filter_disabled_includes_uninstalled_gog_games(
@@ -1052,6 +1137,38 @@ def test_egs_library_uses_steam_description_when_epic_has_title_only(
assert results[0][0][1] == "Witch It — игра в прятки по сети."
def test_installed_filter_skips_uninstalled_egs_metadata(
monkeypatch: MonkeyPatch,
) -> None:
library = [
{"app_id": "installed", "title": "Installed"},
{"app_id": "uninstalled", "title": "Uninstalled"},
]
installed_data = {"installed": {}}
api = SimpleNamespace(
load_library=lambda: library,
load_installed=lambda: installed_data,
is_game_installed=lambda app_id, data: app_id in data,
get_launch_target=lambda app_id, _data: f"/games/{app_id}.exe",
)
loaded_metadata = []
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
monkeypatch.setattr(
"portprotonqt.main_window.get_steam_game_info_async",
lambda name, _uri, callback: (
loaded_metadata.append(name) or callback({})
),
)
results = []
MainWindow._load_egs_games_async(
cast(MainWindow, SimpleNamespace(egs_api=api)), results.append
)
assert loaded_metadata == ["Installed"]
assert [game[3] for game in results[0]] == ["installed"]
def test_egs_maintenance_uses_legendary_commands() -> None:
start_operation = MagicMock()
window = cast(MainWindow, SimpleNamespace(_start_egs_operation=start_operation))
@@ -1350,3 +1467,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:
+8
View File
@@ -767,3 +767,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