Compare commits

...
166 Commits
Author SHA1 Message Date
Boria138 6e9f935222 feat: added Linux Native games support
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-13 17:41:53 +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
Boria138 7f75d5386d fix: enable game pause in silent tray
Code check / Check code (push) Successful in 3m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-15 14:55:34 +05:00
Boria138 64aedb4a12 build: align PKGBUILDs with AUR packaging
Code check / Check code (push) Successful in 2m33s
Build AppImage, Arch, Fedora and Debian Packages / Build AppImage (push) Successful in 15m40s
Build AppImage, Arch, Fedora and Debian Packages / Build Arch Package (push) Successful in 4m41s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (43) (push) Successful in 1m42s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (44) (push) Successful in 1m53s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (45) (push) Successful in 1m57s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (rawhide) (push) Successful in 1m53s
Build AppImage, Arch, Fedora and Debian Packages / Build Debian Package (push) Successful in 4m27s
Build AppImage, Arch, Fedora and Debian Packages / Create and Publish Release (push) Successful in 35s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-14 23:21:16 +05:00
Boria138 bc41211ab1 fix: restore minimal tray for silent EXE launches
Code check / Check code (push) Successful in 2m43s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-14 23:13:50 +05:00
Boria138 bf93eff67d bump to 1.5.0
Code check / Check code (push) Canceled after 3m25s
Build AppImage, Arch, Fedora and Debian Packages / Build AppImage (push) Canceled after 1m26s
Build AppImage, Arch, Fedora and Debian Packages / Build Arch Package (push) Canceled after 0s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (43) (push) Canceled after 0s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (44) (push) Canceled after 0s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (45) (push) Canceled after 0s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (rawhide) (push) Canceled after 0s
Build AppImage, Arch, Fedora and Debian Packages / Build Debian Package (push) Canceled after 0s
Build AppImage, Arch, Fedora and Debian Packages / Create and Publish Release (push) Canceled after 0s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-14 21:31:07 +05:00
Boria138 5d2df85d19 chore(ci): rate mirrors
Code check / Check code (push) Canceled after 2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-14 16:19:39 +00:00
Boria138 68ba818d74 chore(security): fix mailto link
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-14 20:07:24 +05:00
Boria138 68c89eccbc chore: added security policy
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-14 19:57:14 +05:00
Boria138 5109c50c99 fix: prevent library cards jumping during loading
Code check / Check code (push) Successful in 30m45s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-14 10:58:53 +05:00
Boria138 9f194fd33f perf: defer card creation during library scrolling
Code check / Check code (push) Successful in 12m28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-13 23:02:32 +05:00
Codeberg TranslateandRubin13 d7838eceb1 Translated using Weblate (German)
Currently translated at 100.0% (798 of 798 strings)

Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-10-12 19:09:48 +00:00
Boria138 94af16ceac feat: add keyboard-driven game settings search
Code check / Check code (push) Successful in 2m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-12 16:07:42 +05:00
Boria138 d2163b84b4 chore: disable WINE_FULLSCREEN_FSR by default
Code check / Check code (push) Successful in 2m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-12 15:56:03 +05:00
Renovate Bot a19f446b44 chore(deps): update https://git.linux-gaming.ru/actions/setup-node digest to 949feb2
Code check / Check code (push) Successful in 6m55s
2026-10-12 09:31:46 +00:00
Renovate Bot d1c812c5bd chore(deps): update archlinux:base-devel docker digest to 996c3a1
Code check / Check code (push) Successful in 8m12s
2026-10-12 09:31:38 +00:00
Boria138 e76abf430c chore: add themed icons to Wine/Proton manager buttons
Code check / Check code (push) Failing after 10m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 23:18:44 +05:00
Boria138 e9473ccaeb chore: sort Winetricks DLLs and fonts alphabetically
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 23:12:55 +05:00
Boria138 63997f8e1a fix: skip MangoHud config writes when disabled
Code check / Check code (push) Successful in 2m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 20:56:08 +05:00
Boria138 45235891c8 chore(theme): set minimum horizontal handle width
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 20:41:20 +05:00
Boria138 682363ded2 feat: add gamepad navigation to dialog action buttons
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 20:39:07 +05:00
Boria138 522e52f032 fix: size horizontal cards before layout insertion
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 20:29:13 +05:00
Boria138 c30be1214a fix: clear deleted full library tile reference
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 20:23:19 +05:00
Boria138 a99e752ec0 fix: correct horizontal layout menu mapping
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 20:20:43 +05:00
Boria138 91eddcb161 chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 01:05:02 +05:00
Boria138 7f68494a57 fix: preserve library scroll during late updates
Code check / Check code (push) Successful in 2m57s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-11 00:59:56 +05:00
Boria138 135d53df31 chore(changelog): clean
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 18:21:39 +05:00
Boria138 893ed8f057 chore: emphasize selected rows in game settings
Code check / Check code (push) Successful in 8m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 18:15:05 +05:00
Boria138 aaeb550fd6 fix: prevent duplicate cards after installation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 18:05:48 +05:00
Boria138 0210773642 chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 18:02:34 +05:00
Boria138 6cf0fc4650 Merge branch 'weblate' 2026-10-10 18:01:52 +05:00
Boria138 404d7c0fd0 feat: auto-hide cursor after fullscreen inactivity
Code check / Check code (push) Successful in 1m59s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 18:01:11 +05:00
Codeberg TranslateandRubin13 fd2b0fca61 Translated using Weblate (German)
Currently translated at 100.0% (797 of 797 strings)

Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-10-10 11:29:04 +00:00
Boria138 5fa734c2d1 chore: align Winetricks with application styling
Code check / Check code (push) Successful in 21m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 12:57:50 +05:00
Boria138 9b602181cc chore(locale): update
Code check / Check code (push) Successful in 6m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 12:35:44 +05:00
Boria138 7a2be0c289 chore: center wrapped settings button rows
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 12:35:14 +05:00
Boria138 7704308d63 chore: alternate list row backgrounds using theme colors
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 12:18:13 +05:00
Boria138 1f9882cec8 chore: set minimum scrollbar handle height to 30px
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 12:12:11 +05:00
Boria138 2f8d73e074 chore: arrange library controls vertically
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 12:07:54 +05:00
Boria138 2b36fe0a92 fix: prioritize horizontal wheel scrolling in library
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 12:01:56 +05:00
Boria138 cf1ed31f04 fix(theme): improve light library list contrast
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 11:52:38 +05:00
Boria138 066b3b6767 chore(settings): dim and shrink description text
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 11:51:00 +05:00
Boria138 ebb80a3602 chore: add spacing below compact detail buttons
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 11:45:49 +05:00
Boria138 1c37b4c961 chore: unify library and autoinstall table layout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 11:41:30 +05:00
Boria138 16060274d6 fix: match Rockstar launcher by installation path
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 11:30:14 +05:00
Boria138 197b5266ba feat: show autoinstall installation status
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 11:25:24 +05:00
Boria138 3787e9b2bf chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-10 11:17:18 +05:00
Boria138 4f5499b56d test: separate PortProton shell script tests
Code check / Check code (push) Successful in 2m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-09 20:02:34 +05:00
Boria138 e1b8bfde7f chore: allow same exe as secondary launch target
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-09 19:57:31 +05:00
Codeberg TranslateandBoria138 311039a543 Translated using Weblate (Russian)
Currently translated at 100.0% (797 of 797 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-10-09 11:06:18 +00:00
Boria138 fc73d80e43 chore(locale): update
Code check / Check code (push) Successful in 1m57s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-09 16:04:44 +05:00
Boria138 498a26c00e fix: retranslate gamepad hints after language changes
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-09 16:03:30 +05:00
Boria138 2234066479 feat: add option to disable Steam Proton scanning
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-09 15:57:06 +05:00
Boria138 d44f561c4c fix: preserve Unicode paths when launching second exe
Code check / Check code (push) Successful in 3m47s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-09 10:59:48 +05:00
Boria138 1d7602bb9e chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-08 22:00:59 +05:00
Boria138 facedf6c1a Merge branch 'weblate' 2026-10-08 21:57:32 +05:00
Boria138 78c5302a99 fix: detect RTX features after GPU initialization
Code check / Check code (push) Successful in 2m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-08 16:24:19 +00:00
Boria138 3fb29751ec feat: support bundled D7VK from Proton builds
Code check / Check code (push) Successful in 1m52s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-08 16:14:02 +00:00
Boria138 910c18ad37 fix: preload GameMode library instead of calling system gamemoderun
Code check / Check code (push) Successful in 1m51s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-08 16:13:26 +00:00
Boria138 a050c2ed35 chore: download Vulkan component versions on demand
Code check / Check code (push) Successful in 3m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-08 16:09:32 +00:00
Boria138 dc7e672ec8 fix: respect forced locale for Steam emulator configs
Code check / Check code (push) Failing after 1m46s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-08 16:08:11 +00:00
Codeberg TranslateandRubin13 ee093d727c Translated using Weblate (German)
Currently translated at 100.0% (796 of 796 strings)

Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-10-06 16:27:20 +00:00
Boria138 a4f4f7e60f docs: add README badges and translation links
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-06 16:46:57 +05:00
Boria138 d1ea3d28f7 chore: generalize app descriptions
Code check / Check code (push) Successful in 2m0s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-06 15:28:00 +05:00
Boria138 c2dca6eda9 chore(ci): added libpulse for tests fix
Code check / Check code (push) Successful in 2m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-05 21:02:20 +05:00
Codeberg TranslateandBoria138 f9c3e52275 Translated using Weblate (Russian)
Currently translated at 100.0% (796 of 796 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-10-05 12:41:05 +00:00
Codeberg TranslateandRubin13 7091d5ccbd Translated using Weblate (German)
Currently translated at 100.0% (795 of 795 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-05 12:41:05 +00:00
Boria138 b88567a3ae chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-05 17:39:24 +05:00
Boria138 40b3644f80 feat(themes): add fullscreen boot animations
Code check / Check code (push) Failing after 1m47s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-05 17:36:58 +05:00
Renovate Bot 636c535a33 fix(deps): update python dependencies
Code check / Check code (push) Successful in 2m14s
2026-10-05 05:10:16 +00:00
Boria138 e01076524c fix(settings): restore theme text colors
Code check / Check code (push) Successful in 2m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-04 17:15:09 +05:00
Renovate Bot ac84f3738b chore(deps): update archlinux:base-devel docker digest to 51dd3d2
Code check / Check code (push) Successful in 2m3s
2026-10-04 11:53:50 +00:00
Boria138 49f2d044b9 chore: bump Development Status to 5 - Production
Code check / Check code (push) Successful in 3m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-04 16:52:37 +05:00
Boria138 21603e32a4 fix(settings): correct combo tooltips and navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-04 16:45:17 +05:00
Boria138 4a35f05893 fix(library): prevent flicker and delayed card focus
Code check / Check code (push) Successful in 3m12s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-02 11:36:07 +05:00
Boria138 29362b3413 perf: reduce library card resize stalls
Code check / Check code (push) Successful in 1m46s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-02 10:22:42 +05:00
Codeberg TranslateandBoria138 166667f7cf Translated using Weblate (Russian)
Currently translated at 100.0% (795 of 795 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-10-02 03:32:23 +00:00
Boria138 6c560b9123 feat(proton): support separate WineLand builds
Code check / Check code (push) Successful in 2m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-02 01:09:40 +05:00
Boria138 595f58195e chore(locale): update
Code check / Check code (push) Successful in 4m4s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-01 17:44:59 +05:00
Boria138 e58d470960 feat(settings): add Wine download shortcut
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-01 17:43:33 +05:00
Boria138 760dd359b8 chore(settings): improve settings row readability
Code check / Check code (push) Successful in 2m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-01 14:17:07 +05:00
Gitea Actions 27f1247a1b chore: download stats 2026-10-01T00:00:52Z 2026-10-01 00:00:52 +00:00
Boria138 e25b52f3d5 fix: support game launches inside steam-run
Code check / Check code (push) Failing after 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-30 18:40:47 +05:00
Boria138 523446e827 fix: restore Guide + Select grid refresh
Code check / Check code (push) Successful in 1m47s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-30 11:06:21 +05:00
Boria138 a6610e8087 chore(i18n): clarify library layout labels
Code check / Check code (push) Successful in 1m47s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-28 10:46:02 +05:00
Codeberg Translateandadem 4dc09df895 Translated using Weblate (Russian)
Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (784 of 784 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-09-27 16:33:33 +00:00
Codeberg Translateandrogerio-mattos 20c8dbe350 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
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-09-27 16:33:33 +00:00
Codeberg TranslateandRubin13 839b4b7523 Translated using Weblate (German)
Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (German)

Currently translated at 100.0% (785 of 785 strings)

Translated using Weblate (German)

Currently translated at 100.0% (781 of 781 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-09-27 16:33:32 +00:00
Gitea Actions 4860c232ea chore: update steam apps list 2026-09-27T16:33:22Z 2026-09-27 16:33:22 +00:00
Renovate Bot 704c514ba4 chore(deps): update https://git.linux-gaming.ru/actions/anylinux-setup-action action to v5
Code check / Check code (push) Successful in 1m41s
2026-09-27 04:30:29 +00:00
Renovate Bot 68b77657d6 chore(deps): update archlinux:base-devel docker digest to 8745817
Code check / Check code (pull_request) Successful in 2m2s
Package Build - AppImage, Arch, Fedora, Debian / changes (pull_request) Successful in 14s
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 1m58s
2026-09-27 00:00:56 +00:00
150 changed files with 102802 additions and 5238 deletions

No files matched your search

+33 -4
View File
@@ -17,7 +17,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 1.4.1
VERSION: 1.5.0
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -31,6 +31,11 @@ jobs:
image: archlinux:base-devel
options: --privileged --device /dev/fuse
steps:
- name: Configure Arch mirror
run: |
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
@@ -69,6 +74,11 @@ jobs:
image: archlinux:base-devel
steps:
- name: Configure Arch mirror
run: |
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
run: |
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
@@ -128,8 +138,13 @@ jobs:
steps:
- name: Install build dependencies
run: |
dnf upgrade -y --exclude=openh264,mozilla-openh264
dnf install -y --disablerepo=fedora-cisco-openh264 \
# Select nearby mirrors and abandon slow downloads after 20 seconds.
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
--setopt=minrate=100k --setopt=timeout=20 \
upgrade -y --exclude=openh264,mozilla-openh264
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
--setopt=minrate=100k --setopt=timeout=20 \
install -y --disablerepo=fedora-cisco-openh264 \
git rpmdevtools meson ninja-build python3-devel \
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
gcc gettext 'pkgconfig(sdl3)'
@@ -172,8 +187,22 @@ jobs:
steps:
- name: Install build dependencies
run: |
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
/etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y build-essential debhelper dpkg-dev gettext git \
apt-get install -y --no-install-recommends nala
if timeout --kill-after=5s 120s nala fetch --auto --fetches=3 &&
test -s /etc/apt/sources.list.d/fetch.sources; then
# Keep updates and security; use only ranked mirrors for the base suite.
sed -i 's/^Suites: trixie trixie-updates$/Suites: trixie-updates/' \
/etc/apt/sources.list.d/debian.sources
else
echo "::warning::Mirror selection failed; keeping the bootstrap Debian sources"
fi
nala update
nala install -y build-essential debhelper dpkg-dev gettext git \
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
pkgconf python3
+15 -11
View File
@@ -22,21 +22,25 @@ jobs:
name: Check code
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
steps:
- name: Install system dependencies
- name: Configure Arch mirror
run: |
pacman -Syuu --noconfirm --disable-download-timeout
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
pacman -Scc --noconfirm
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Install CI dependencies
run: |
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"
+37 -8
View File
@@ -80,11 +80,16 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
options: --privileged --device /dev/fuse
steps:
- name: Configure Arch mirror
run: |
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7ff2bdc2fe97f9a4c6a4007f5f42fb4941f49fee # v4
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@b2d1ce4216f71162e00ae58fa11ee9aeb723d0d5 # v5
- name: Install CI dependencies
run: pacman -S --noconfirm --needed nodejs npm
@@ -135,8 +140,13 @@ jobs:
steps:
- name: Install build dependencies
run: |
dnf upgrade -y --exclude=openh264,mozilla-openh264
dnf install -y --disablerepo=fedora-cisco-openh264 \
# Select nearby mirrors and abandon slow downloads after 20 seconds.
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
--setopt=minrate=100k --setopt=timeout=20 \
upgrade -y --exclude=openh264,mozilla-openh264
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
--setopt=minrate=100k --setopt=timeout=20 \
install -y --disablerepo=fedora-cisco-openh264 \
git rpmdevtools meson ninja-build python3-devel \
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
gcc gettext 'pkgconfig(sdl3)'
@@ -185,12 +195,17 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
steps:
- name: Configure Arch mirror
run: |
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
run: |
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
pacman -Syu --noconfirm --needed git wget gnupg nodejs npm
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
yes | pacman -Scc
@@ -251,14 +266,28 @@ 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:
- name: Install build dependencies
run: |
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
/etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y build-essential debhelper dpkg-dev gettext git \
apt-get install -y --no-install-recommends nala
if timeout --kill-after=5s 120s nala fetch --auto --fetches=3 &&
test -s /etc/apt/sources.list.d/fetch.sources; then
# Keep updates and security; use only ranked mirrors for the base suite.
sed -i 's/^Suites: trixie trixie-updates$/Suites: trixie-updates/' \
/etc/apt/sources.list.d/debian.sources
else
echo "::warning::Mirror selection failed; keeping the bootstrap Debian sources"
fi
nala update
nala install -y build-essential debhelper dpkg-dev gettext git \
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
pkgconf python3
+4 -1
View File
@@ -9,11 +9,14 @@ 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
- name: Set up Node.js
uses: https://git.linux-gaming.ru/actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
uses: https://git.linux-gaming.ru/actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: 20
+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/)
+25 -8
View File
@@ -275,11 +275,13 @@ AI agents MAY:
- No blocking I/O in UI thread
- No synchronous HTTP in UI thread
- Cache API responses in `~/.cache/PortProtonQt`
- Cache API responses in `$XDG_DATA_HOME/PortProtonQt/cache` (default: `~/.local/share/PortProtonQt/cache`)
- Avoid repeated disk reads in loops
- Avoid O(n²) in game lists
- Lazy load images
- Limit threads to CPU cores
- Skip worker creation for valid cached downloads and URLs already marked failed; still complete callbacks exactly once through the owner's event loop
- Reuse cached download paths only while the file exists and matches the requested destination
### Clarification: "No Shared Mutable Global State" Does NOT Mean Removing Caches
@@ -288,10 +290,10 @@ Rule intent: avoid shared mutable globals that hold application state or cause h
This does not prohibit:
- Module-level cache constants (e.g., cache TTL values)
- Dedicated cache managers or classes with explicit lifecycle
- Disk caches under `~/.cache/PortProtonQt`
- Disk caches under `$XDG_DATA_HOME/PortProtonQt/cache` (default: `~/.local/share/PortProtonQt/cache`)
- `requests.Session()` objects if they are encapsulated and not mutated unpredictably
Do not delete or disable caching or downloaders to satisfy this rule. Caching is required by the Performance Rules ("Cache API responses in `~/.cache/PortProtonQt`" and "Avoid repeated disk reads in loops").
Do not delete or disable caching or downloaders to satisfy this rule. Caching is required by the Performance Rules ("Cache API responses in `$XDG_DATA_HOME/PortProtonQt/cache`" and "Avoid repeated disk reads in loops").
---
@@ -412,23 +414,29 @@ tests/
├── test_appimage_updater.py # AppImage auto-update worker, mirrors, changelog parsing
├── test_dark_theme.py # Dark theme detection (XFCE/MATE/Cinnamon/GNOME)
├── test_steam_cache.py # exiftool skip, cache eviction, delete_cached_app_files
├── test_downloader.py # Cache/error callbacks, worker ownership, and shutdown regressions
├── test_base_config.py # BaseConfig read/write, caching, versioning
├── test_cli.py # normalize_launch_path, URL/resolution parsing
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
├── test_input_manager.py # Gamepad input navigation and focus regressions
├── test_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
├── test_library_resize.py # Library resize batching, cover reuse, and flow layout regressions
├── test_library_loading.py # Incremental loading, source switching, first-paint focus, and cover callbacks
├── test_store_games.py # GOG and EGS library, launch, install, and stop regressions
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
├── test_portproton_scripts.py # Shell script launch, second exe, exit codes, user.conf
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
├── test_file_explorer.py # File explorer themed icon state regressions
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
├── test_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)
@@ -462,6 +470,15 @@ pre-commit run pytest
- Keep tests locale-independent: assert stable identifiers or numbers instead of translated strings; resolve values created through `_()` from the same application mapping using stable identifiers; use `monkeypatch` to provide deterministic text when testing translated search or matching
- Test regression scenarios from git fix commits
### Library Refresh Regressions
- Late metadata/download completion must preserve scroll position and focused card in an already populated library; first-card focus belongs to initial loading
- Cover both library layouts and refresh completion while the autoinstall tab is active
- For reported scrolling regressions, supplement unit tests with a full application cycle: isolated empty cache, forced ProtonDB connection failure, scroll and refresh both tabs, switch back, then close the application
- Record scroll values and visible cards around late updates; distinguish an intentional refresh reset from an unsolicited rollback after the user resumes scrolling
- Use temporary XDG config/data directories for GUI reproduction; never clear the user's real cache or alter system-wide network settings
- Report baseline reproduction and final-code results separately; passing callback tests alone does not prove the visible regression is fixed
### Key Regression Areas
| Module | Regression | Commit |
@@ -990,7 +1007,7 @@ PortProtonQt/
│ ├── tabs/ # Main window tabs and workers
│ │ ├── autoinstall_tab.py
│ │ ├── control_hints.py
│ │ ├── gog_tab.py
│ │ ├── download_tab.py
│ │ ├── library_tab.py
│ │ ├── settings_tab.py
│ │ ├── system_tab.py
@@ -1041,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 |
@@ -1067,8 +1086,6 @@ PortProtonQt/
- [Theme documentation](documentation/theme_guide)
- [Localization guide](documentation/localization_guide)
- [Metadata override guide](documentation/metadata_override)
- [TODO list](TODO.md)
- [Changelog](CHANGELOG.md)
---
+92 -3
View File
@@ -7,6 +7,76 @@
### 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/`
### 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.5.0] - 2026-10-14
### Added
- Добавлен поиск настроек игры вводом с клавиатуры без предварительного выбора поля поиска
- Добавлена навигация геймпадом к кнопкам под таблицами
- Добавлена настройка скрытия курсора в полноэкранном режиме, включённая по умолчанию
- Добавлена настройка отключения поиска установленных Proton в Steam для списков выбора версий и окна управления WINE/Proton
- Добавлена возможность добавления загрузочной WebM-заставки из тем при переходе в fullscreen, включая автоматический переход при подключении геймпада
- Добавлен переход к загрузке Wine/Proton из списка версий в настройках игры
- Добавлены выбор и установка DLC при установке игр GOG
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
@@ -23,9 +93,19 @@
- При открытии карточки с отсутствующим исполняемым файлом добавлен выбор: удалить карточку или указать другой файл
- Добавлен отдельный раздел настройки шрифтов MangoHud с масштабом, размером и выбором шрифта
- Добавлен графический редактор глобальных настроек игр из `user.conf`
- В режиме `PW_USE_SUPPLIED_DXVK_VKD3D` добавлена поддержка D7VK из сборки Proton
### Changed
- Настройка `WINE_FULLSCREEN_FSR` по умолчанию отключена
- Кнопки менеджера Wine/Proton приведены к общему стилю приложения и дополнены иконками
- Списки DLL и шрифтов в Winetricks теперь сортируются по алфавиту
- Оформление Winetricks приведено к общему стилю приложения
- В поле запуска второго `.exe` / враппера теперь можно указать тот же исполняемый файл, что и основной
- Описания приложения в README, метаданных и пакетах сделаны нейтральными, без перечисления источников игр и привязки к Windows
- Ускорено изменение размера карточек библиотеки
- Удалён GDK Proton из списка доступных сборок
- Улучшено отображение описаний параметров в настройках игры: они размещаются под названием, а текущая строка выделяется рамкой
- После завершения загрузки показывается системное уведомление, а строка результата открывает карточку игры мышью или кнопкой A геймпада
- Улучшено определение вылетов игр: учитывается код завершения команды Wine/Proton и время работы игровых процессов, включая дочерний движок, без учёта времени завершения скрипта запуска
- Каталоги пользовательских данных игр переведены на единый формат `источник-ID`
@@ -47,11 +127,18 @@
- `minazuki` добавлен в список конфликтующих с GameMode процессов
- Встроенный терминал после ошибки остаётся открытым и закрывается клавишей Enter или кнопкой A геймпада
- В список советов при вылете добавлено отключение Optiscaler
- Повышен статус разработки в `pyproject.toml` с Alpha до Production
- DXVK и VKD3D-Proton теперь загружаются только для выбранного режима Vulkan, а D7VK — только при его включении
### Fixed
- Исправлена запись `MANGOHUD_CONFIG` в `.ppdb` при выключенном MangoHud
- Исправлены ложный статус установки и открытие чужой карточки для Rockstar Games Launcher при совпадении имени `launcher.exe`
- Исправлен запуск второго `.exe` через `PW_RUN_AFTER_EXE` из пути с кириллицей
- Исправлены мерцание сетки и задержка выделения карточек при открытии библиотеки
- Исправлено обновление сетки комбинацией Guide + Select
- Исправлены создание и восстановление `.desktop`-файлов с переносами строк в названии игры
- Исправлено появление дубля карточки при добавлении игры
- Исправлено появление дубля карточки при добавлении и после установки игры
- Исправлено отсутствие новых `.exe` в поиске библиотеки до обновления сетки
- Исправлено обновление названия на детальной странице после редактирования ярлыка без повторного открытия страницы
- Исправлено отображение дополнений и наборов GOG как отдельных игр в библиотеке
@@ -59,9 +146,8 @@
- Исправлена синтаксическая ошибка в `functions_helper`, препятствовавшая запуску PortProton с Bash 5.1
- Исправлено управление интерфейсом при одновременном подключении нескольких геймпадов
- Исправлена коллизия пользовательских данных автоустановок с одинаковыми именами исходных исполняемых файлов
- Исправлен поиск скриптов и локалей через `XDG_DATA_DIRS` в NixOS
- Исправлен запуск PortProtonQt на NixOS через `steam-run`
- Исправлена проверка и установка недостающих i586-зависимостей в ALT Linux перед запуском игр
- Исправлено игнорирование проверки 32-битных пакетов при запуске игр GOG
- Исправлено отсутствие иконки у окна встроенного терминала
- Исправлена потеря финального вывода при ошибке встроенного терминала
- Исправлено открытие карточек GOG через URI при старте приложения
@@ -74,8 +160,11 @@
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
- Исправлено создание резервной копии префикса с `libarchive-c 5.1`
- Исправлено завершение приложения при активных фоновых операциях
- Исправлено автоматическое включение NVAPI, DLSS и трассировки лучей для видеокарт NVIDIA RTX
- Исправлено применение принудительно выбранного языка в конфигурациях эмуляторов Steam
### Contributors
- @dark_siders
- @rogerio-mattos
- @Rubin13
- @adem4ik
+16 -5
View File
@@ -3,10 +3,21 @@
---
<div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-12.8k-green?style=flat-square" alt="Total Downloads"></a>
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, Epic Games Store, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
<p align="center">An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.</p>
<p>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&amp;label=Release" alt="Releases"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-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>
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Translations-Weblate-2eccaa?style=flat-square&amp;logo=weblate" alt="Translate on Weblate"></a>
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg" alt="Packaging status on Repology"></a>
</p>
<p><a href="https://github.com/linux-gaming-ru/PortProtonQt/releases">Releases</a> · <a href="https://github.com/linux-gaming-ru/PortProtonQt/issues">Issue tracker</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Translations</a> · <a href="https://repology.org/project/portprotonqt/versions">Packages</a></p>
</div>
### Installation (devel)
@@ -57,7 +68,7 @@ pre-commit run --all-files
### Contributors
We thank everyone who has contributed to the development of PortProtonQt, including those who participate through commits as well as those who help in other ways (testing, ideas, translations, documentation, etc.). A full list of participants can be found in the [repository activity list](https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/activity/contributors). Additional participants are also listed in the [CHANGELOG.md](CHANGELOG.md) file. If you have contributed but are not listed, please contact the lead developers so we can acknowledge you!
We thank everyone who has contributed to the development of PortProtonQt, including those who participate through commits as well as those who help in other ways (testing, ideas, translations, documentation, etc.). A full list of participants can be found in the [repository activity list](https://github.com/linux-gaming-ru/PortProtonQt/graphs/contributors). Additional participants are also listed in the [CHANGELOG.md](CHANGELOG.md) file. If you have contributed but are not listed, please contact the lead developers so we can acknowledge you!
## Dependencies and Licenses
+16 -5
View File
@@ -1,12 +1,23 @@
📖 This documentation is also available in [English](README.md)
📘 This documentation is also available in [English](README.md)
---
<div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-12.8k-green?style=flat-square" alt="Всего загрузок"></a>
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam, Epic Games Store и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
<p align="center">Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.</p>
<p>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&amp;label=Релиз" alt="Релизы"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-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>
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Переводы-Weblate-2eccaa?style=flat-square&amp;logo=weblate" alt="Перевести на Weblate"></a>
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg?header=Репозитории" alt="Пакеты в Repology"></a>
</p>
<p><a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases">Релизы</a> · <a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/issues">Сообщить о проблеме</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Переводы</a> · <a href="https://repology.org/project/portprotonqt/versions">Пакеты</a></p>
</div>
### Установка (devel)
+42
View File
@@ -0,0 +1,42 @@
# Security Policy
## Reporting a vulnerability
Do not publish suspected vulnerability details in public issues, discussions,
or pull requests. Send reports to [support@linux-gaming.ru](mailto:support@linux-gaming.ru).
Please include enough information to reproduce and assess the issue:
- PortProtonQt version or commit and installation source.
- Linux distribution and relevant launcher or runtime details.
- Affected feature and clear reproduction steps.
- Expected behavior, observed behavior, and potential security impact.
- A minimal proof of concept or sanitized logs, if available.
- A suggested mitigation or fix, if known.
Do not include passwords, tokens, private keys, or personal data. Redact
usernames, home directory paths, and other sensitive details from logs where
practical. Test only with systems, accounts, and files you own or are
authorized to use.
## Scope
This policy covers security issues in PortProtonQt, including its handling of
local files and archives, launcher data, network requests, downloaded content,
and application configuration. Issues in third-party launchers, libraries,
compatibility tools, or distribution packages should also be reported to their
maintainers. Please report cases where PortProtonQt's handling creates or
increases the risk.
## Supported versions
Please check whether the issue affects the latest published release and
include the affected version in your report. Older releases may not receive
security fixes; users should update when a fix becomes available.
## Coordinated disclosure
Keep vulnerability details private while maintainers investigate and prepare a
fix or mitigation. Coordinate public disclosure with the maintainers, allowing
reasonable time for a fix to reach users. Response and remediation times depend
on the issue; no fixed response time is promised.
+15 -15
View File
@@ -1,16 +1,26 @@
pkgname=('portprotonqt')
pkgver=1.4.1
# Maintainer: Boris Yumankulov <boriabloger[at]protonmail[dot]com>
pkgname=portprotonqt
pkgver=1.5.0
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
pkgdesc="Game library manager and launcher for Linux"
arch=('x86_64')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'sdl3')
conflicts=("portprotonqt-steam-compat")
provides=('portprotonqt-steam-compat')
replaces=('portprotonqt-steam-compat')
optdepends=(
'networkmanager: System tab network management'
'bluez: System tab bluetooth management'
'upower: System tab bluetooth battery level'
'libpulse: System tab audio volume/output management'
'python-qrcode: Wi-Fi QR code generation'
'squashfs-tools: for legacy PortProton prefix backup support'
)
source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP')
@@ -19,17 +29,7 @@ build() {
meson compile -C build
}
package_portprotonqt() {
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
optdepends=(
'networkmanager: System tab network management'
'bluez: System tab bluetooth management'
'upower: System tab bluetooth battery level'
'libpulse: System tab audio volume/output management'
'python-qrcode: Wi-Fi QR code generation'
'squashfs-tools: for legacy PortProton prefix backup support'
)
package() {
DESTDIR="$pkgdir" meson install -C build --skip-subprojects
bash "$srcdir/PortProtonQt/dev-scripts/generate-completions.sh"
+14 -14
View File
@@ -1,16 +1,26 @@
pkgname=('portprotonqt-git')
# Maintainer: Boris Yumankulov <boriabloger[at]protonmail[dot]com>
pkgname=portprotonqt-git
pkgver=.
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
pkgdesc="Game library manager and launcher for Linux (development build)"
arch=('x86_64')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'sdl3')
conflicts=("portprotonqt-steam-compat")
provides=('portprotonqt-steam-compat')
replaces=('portprotonqt-steam-compat')
optdepends=(
'networkmanager: System tab network management'
'bluez: System tab bluetooth management'
'upower: System tab bluetooth battery level'
'libpulse: System tab audio volume/output management'
'python-qrcode: Wi-Fi QR code generation'
'squashfs-tools: for legacy PortProton prefix backup support'
)
source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git")
sha256sums=('SKIP')
@@ -24,17 +34,7 @@ build() {
meson compile -C build
}
package_portprotonqt-git() {
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
optdepends=(
'networkmanager: System tab network management'
'bluez: System tab bluetooth management'
'upower: System tab bluetooth battery level'
'libpulse: System tab audio volume/output management'
'python-qrcode: Wi-Fi QR code generation'
'squashfs-tools: for legacy PortProton prefix backup support'
)
package() {
DESTDIR="$pkgdir" meson install -C build --skip-subprojects
bash "$srcdir/PortProtonQt/dev-scripts/generate-completions.sh"
+3 -3
View File
@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 1.4.1
%global pypi_version 1.5.0
%global oname PortProtonQt
%global build_timestamp %(date +"%Y%m%d")
%global _python_no_extras_requires 1
@@ -9,7 +9,7 @@
Name: %{pypi_name}-git
Version: %{pypi_version}
Release: %{rel_build}
Summary: Modern GUI for managing and launching games from PortProton and Steam (development build)
Summary: Game library manager and launcher for Linux (development build)
License: GPL-3.0
URL: https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt
@@ -81,7 +81,7 @@ Obsoletes: %{pypi_name}-steam-compat < %{?epoch:%{epoch}:}%{version}-%{rele
Provides: %{pypi_name}-steam-compat = %{?epoch:%{epoch}:}%{version}-%{release}
%description
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
%{?python_disable_dependency_generator}
+3 -3
View File
@@ -1,12 +1,12 @@
%global pypi_name portprotonqt
%global pypi_version 1.4.1
%global pypi_version 1.5.0
%global oname PortProtonQt
%global _python_no_extras_requires 1
Name: %{pypi_name}
Version: %{pypi_version}
Release: 1%{?dist}
Summary: Modern GUI for managing and launching games from PortProton and Steam
Summary: Game library manager and launcher for Linux
License: GPL-3.0
URL: https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt
@@ -78,7 +78,7 @@ Obsoletes: %{pypi_name}-steam-compat < %{?epoch:%{epoch}:}%{version}-%{rele
Provides: %{pypi_name}-steam-compat = %{?epoch:%{epoch}:}%{version}-%{release}
%description
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
%{?python_disable_dependency_generator}
@@ -2,11 +2,11 @@
Name=PortProtonQt
Exec=portprotonqt %u
Type=Application
Comment=A modern GUI for PortProton project
Comment[ru]=Современный графический интерфейс для проекта PortProton
Comment[es]=Una interfaz gráfica moderna para el proyecto PortProton
Comment[pt]=Uma interface gráfica moderna para o projeto PortProton
Comment[de]=Eine moderne grafische Benutzeroberfläche für das PortProton-Projekt
Comment=Game library manager and launcher for Linux
Comment[ru]=Приложение для управления игровой библиотекой и запуска игр на Linux
Comment[es]=Gestor de biblioteca de juegos y lanzador para Linux
Comment[pt]=Gerenciador de biblioteca de jogos e inicializador para Linux
Comment[de]=Spielebibliotheksverwaltung und Spielestarter für Linux
Terminal=false
Icon=ru.linux_gaming.PortProtonQt
StartupWMClass=ru.linux_gaming.PortProtonQt
@@ -5,15 +5,15 @@
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<summary>Modern GUI for managing and launching games from PortProton, Steam, and GOG</summary>
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton, Steam и GOG</summary>
<summary>Game library manager and launcher for Linux</summary>
<summary xml:lang="ru">Приложение для управления игровой библиотекой и запуска игр на Linux</summary>
<description>
<p>
A modern and intuitive interface for managing and launching games from PortProton, Steam, and GOG. Combines libraries in one place and simplifies running Windows games on Linux.
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
</p>
<p xml:lang="ru">
Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.
</p>
</description>
@@ -24,9 +24,12 @@
</developer>
<releases>
<release version="1.4.1" date="2026-08-26">
<release version="1.5.0" date="2026-10-14">
<description></description>
</release>
<release version="1.4.1" date="2026-08-26">
<description/>
</release>
<release version="1.4.0" date="2026-08-23">
<description/>
</release>
@@ -20,7 +20,7 @@ python_module () {
local python3_module="$1"
shift
print_info "Running python3 module: $python3_module $@" >&2
python3 -m portprotonqt.scripts_utils.$python3_module "$@"
"${PORTPROTONQT_PYTHON:-python3}" -m portprotonqt.scripts_utils.$python3_module "$@"
}
pw_alt_i586_dependencies () {
@@ -177,7 +177,7 @@ pw_enable_idle_inhibit() {
return
fi
PW_INHIBIT_SLR="python3 -m portprotonqt.scripts_utils.idle_inhibit"
PW_INHIBIT_SLR="${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.idle_inhibit"
print_info "Screensaver will be inhibited using D-Bus"
}
@@ -1214,7 +1214,7 @@ pw_init_runtime () {
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
if [[ -z "$PW_TERM" ]] ; then
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" python3 -m portprotonqt.scripts_utils.easyterm -e"
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
fi
export pw_runtime="env"
return 0
@@ -1305,7 +1305,7 @@ ${PW_PV_OVERRIDES}/i386-linux-gnu/aliases:\
for PWRTMRW in "${PW_RT_MOUNT_RW[@]}" ; do
export PRESSURE_VESSEL_FILESYSTEMS_RW="${PRESSURE_VESSEL_FILESYSTEMS_RW:+$PRESSURE_VESSEL_FILESYSTEMS_RW:}${PWRTMRW}"
done
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" python3 -m portprotonqt.scripts_utils.easyterm -e"
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
print_info "RUNTIME is enabled"
}
@@ -1514,14 +1514,25 @@ kill_portwine () {
bwrap_pids=$(pgrep -f wrap | grep PortProton | head -n 1)
else
wine_pids=$(ls -l /proc/*/exe 2>/dev/null | grep -ie PortProton | grep -E 'wine(64)?-preloader|wineserver' | awk -F/ '{print $3}')
bwrap_pids="$(pgrep -a wrap | grep PortProton | head -n 1 | awk '{print $1}')"
bwrap_pids="$(pgrep -a wrap | grep PortProton | awk '{print $1}')"
fi
else
wine_pids=$(ls -l /proc/*/exe 2>/dev/null | grep -E 'wine(64)?-preloader|wineserver' | awk -F/ '{print $3}')
unset bwrap_pids
fi
# bwrap hosts steam-run's FHS container on NixOS.
# Killing an ancestor would terminate this launch with the container.
local pw_ancestor_pids=" " pw_ancestor_pid=$BASHPID
if [[ -n "${bwrap_pids:-}" ]] ; then
while [[ -n "$pw_ancestor_pid" && "$pw_ancestor_pid" -gt 1 ]] ; do
pw_ancestor_pids+="$pw_ancestor_pid "
pw_ancestor_pid=$(ps -o ppid= -p "$pw_ancestor_pid" 2>/dev/null | tr -d '[:space:]')
done
fi
for pw_kill_pids in ${bwrap_pids} ${wine_pids}; do
[[ "$pw_ancestor_pids" == *" $pw_kill_pids "* ]] && continue
if ps cax | grep "${pw_kill_pids}" ; then
kill -n 9 "${pw_kill_pids}" &>/dev/null
fi
@@ -1640,8 +1651,15 @@ pw_check_and_download_runtime () {
}
pw_check_and_download_dxvk_and_vkd3d () {
case "${PW_VULKAN_USE}" in
1) DXVK_VAR_VER="${DXVK_SAREK_VER}"; VKD3D_VAR_VER="${VKD3D_SAREK_VER}" ;;
2) DXVK_VAR_VER="${DXVK_OLD_VER}"; VKD3D_VAR_VER="${VKD3D_OLD_VER}" ;;
6) DXVK_VAR_VER="${DXVK_NEW_VER}"; VKD3D_VAR_VER="${VKD3D_NEW_VER}" ;;
*) DXVK_VAR_VER=""; VKD3D_VAR_VER="" ;;
esac
# Download DXVK
for DXVK_VAR_VER in "${DXVK_SAREK_VER}" "${DXVK_OLD_VER}" "${DXVK_NEW_VER}" ; do
if [[ -n "${DXVK_VAR_VER}" ]] ; then
if [[ ! -d "${PW_VULKAN_DIR}/dxvk-${DXVK_VAR_VER}" ]] ; then
print_info "Download and install DXVK v.${DXVK_VAR_VER}"
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/dxvk-${DXVK_VAR_VER}/dxvk-${DXVK_VAR_VER}.tar.xz" \
@@ -1668,10 +1686,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
fatal "Failed to download and unpack dxvk"
fi
fi
done
fi
# Download VKD3D
for VKD3D_VAR_VER in "${VKD3D_SAREK_VER}" "${VKD3D_OLD_VER}" "${VKD3D_NEW_VER}" ; do
if [[ -n "${VKD3D_VAR_VER}" ]] ; then
if [[ ! -d "${PW_VULKAN_DIR}/vkd3d-proton-${VKD3D_VAR_VER}" ]] ; then
print_info "Download and install VKD3D-PROTON v.${VKD3D_VAR_VER}"
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/vkd3d-proton-${VKD3D_VAR_VER}/vkd3d-proton-${VKD3D_VAR_VER}.tar.xz" \
@@ -1696,10 +1714,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
fatal "Failed to download and unpack VKD3D"
fi
fi
done
fi
# Download D7VK
if [[ ! -d "${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}" ]] ; then
if [[ "${PW_USE_D7VK}" == "1" && ! -d "${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}" ]] ; then
print_info "Download and install D7VK v.${D7VK_VAR_VER}"
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/d7vk-${D7VK_VAR_VER}/d7vk-${D7VK_VAR_VER}.tar.xz" \
"${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}.tar.xz" ; then
@@ -1843,11 +1861,6 @@ pw_init_db () {
[[ -n "${PW_DEFAULT_PREFIX_NAME:-}" ]] && export PW_PREFIX_NAME="${PW_DEFAULT_PREFIX_NAME}"
[[ -n "${PW_DEFAULT_VULKAN_USE:-}" ]] && export PW_VULKAN_USE="${PW_DEFAULT_VULKAN_USE}"
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
export PW_USE_NVAPI_AND_DLSS="1"
export PW_USE_RAY_TRACING="1"
fi
if [[ -f "${PW_EXE_FILE}" ]] ; then
PORTWINE_DB="$(basename "${PW_EXE_FILE%.[Ee][Xx][Ee]}")"
export PORTWINE_DB
@@ -2259,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
@@ -2606,21 +2624,16 @@ pw_enable_gamemode_basic () {
if check_flatpak ; then
export GAMEMODERUN=1
PW_GAMEMODERUN_SLR="gamemoderun"
elif command -v gamemoderun &>/dev/null ; then
export GAMEMODERUN=1
PW_GAMEMODERUN_SLR="gamemoderun"
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
gamemoded &>/dev/null &
sleep 0.1
fi
else
export GAMEMODERUN=1
if [[ -n "${PW_LD_PRELOAD}" ]]; then
export PW_LD_PRELOAD="${PW_LD_PRELOAD}:libgamemodeauto.so.0"
else
export PW_LD_PRELOAD="libgamemodeauto.so.0"
fi
if ! pw_is_process_active gamemoded ; then
local GAMEMODEAUTO_NAME="libgamemodeauto.so.0"
export PW_LD_PRELOAD="${GAMEMODEAUTO_NAME}${PW_LD_PRELOAD:+:$PW_LD_PRELOAD}"
if command -v gamemoderun &>/dev/null ; then
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
gamemoded &>/dev/null &
sleep 0.1
fi
elif ! pw_is_process_active gamemoded ; then
env LD_LIBRARY_PATH="${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32" \
"${PW_PLUGINS_PATH}/portable/bin/gamemoded" &>/dev/null &
sleep 0.1
@@ -2643,6 +2656,10 @@ start_portproton () {
pw_check_and_download_dxvk_and_vkd3d
pw_skip_get_info
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
[[ -z "${PW_USE_NVAPI_AND_DLSS:-}" ]] && export PW_USE_NVAPI_AND_DLSS="1"
[[ -z "${PW_USE_RAY_TRACING:-}" ]] && export PW_USE_RAY_TRACING="1"
fi
if [[ "${PW_LOCALE_SELECT}" != "disabled" ]] && [[ -n "${PW_LOCALE_SELECT}" ]] ; then
export LC_ALL="${PW_LOCALE_SELECT}"
if [[ "${PW_USE_RUNTIME}" == "1" ]] && [[ "${HOST_LC_ALL}" != "${LC_ALL}" ]] ; then
@@ -2912,7 +2929,9 @@ start_portproton () {
# export WINE_HIDE_AMD_GPU="1"
export DXVK_NVAPI_DRIVER_VERSION="99999"
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
if [[ $(check_vendor_gpu) != "nvidia" ]] ; then
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
fi
export PW_USE_NVAPI_AND_DLSS="1"
export PW_USE_RAY_TRACING="1"
;;
@@ -3479,10 +3498,17 @@ start_portproton () {
if [[ "${PW_USE_D7VK}" == "1" ]] ; then
PATH_TO_D7VK_FILES="${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}"
print_info "D7VK ${D7VK_VAR_VER} in use"
D7VK_FILE="${PATH_TO_D7VK_FILES}/x32/ddraw.dll"
if [[ "$PW_USE_SUPPLIED_DXVK_VKD3D" == "2" ]] \
&& [[ -f "${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll" ]] ; then
D7VK_FILE="${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll"
print_info "Bundled D7VK in use"
else
print_info "D7VK ${D7VK_VAR_VER} in use"
fi
print_info "Try create symlink D7VK files..."
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw_.dll"
try_force_link_file "${PATH_TO_D7VK_FILES}/x32/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
try_force_link_file "${D7VK_FILE}" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
var_winedlloverride_update "ddraw=n,b"
else
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
@@ -3773,10 +3799,19 @@ start_portproton () {
add_in_start_portproton
if [[ "$FULL_LN" == russian ]] ; then
local steam_emu_language=""
case "${PW_LOCALE_SELECT}" in
ru_*) steam_emu_language="russian" ;;
en_*) steam_emu_language="english" ;;
zh_*) steam_emu_language="schinese" ;;
ja_*) steam_emu_language="japanese" ;;
ko_*) steam_emu_language="koreana" ;;
disabled|"") steam_emu_language="$FULL_LN" ;;
esac
if [[ -n "$steam_emu_language" ]] ; then
for chk_lang_and_uname in "steam_emu.ini" "steam_api.ini" "steam_api64.ini" "SteamConfig.ini" ; do
if [[ -f "${PATH_TO_GAME}/${chk_lang_and_uname}" ]] ; then
sed -i "/^Language/c\Language=${FULL_LN}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
sed -i "/^Language/c\Language=${steam_emu_language}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
sed -i "/^UserName/c\UserName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
sed -i "/^PlayerName/c\PlayerName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
fi
@@ -3977,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
@@ -4158,15 +4212,27 @@ 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$ ]] \
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]] \
&& [[ "${run_after_valid}" == 1 ]] \
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
then
local wait_delay wait_delay_int run_after_bat
local run_after_dir run_after_name
local pw_exe_file_win
local pw_exe_file_win run_after_exe_win
local wine_drive_c
wait_delay="${PW_RUN_AFTER_DELAY:-3}"
wait_delay_int="${wait_delay%%.*}"
@@ -4185,17 +4251,49 @@ 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
run_after_exe_win="c:\\${PW_RUN_AFTER_EXE#"${wine_drive_c}/"}"
else
run_after_exe_win="z:${PW_RUN_AFTER_EXE//\//\\}"
fi
fi
run_after_exe_win="${run_after_exe_win//\//\\}"
cat > "${run_after_bat}" <<EOF
@echo off
chcp 65001 >nul
start "" "${pw_exe_file_win}" ${LAUNCH_PARAMETERS}
start "" "%PW_RUN_AFTER_MAIN_PATH%" ${LAUNCH_PARAMETERS}
timeout /t ${wait_delay_int} /nobreak
start "" /unix "${PW_RUN_AFTER_EXE}"
start "" "%PW_RUN_AFTER_SECOND_PATH%"
EOF
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
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
@@ -4391,7 +4489,9 @@ gui_edit_db () {
edit_db_from_gui "${PW_EDIT_DB_FINAL_LIST[@]}"
if [[ -z "$MANGOHUD_CONFIG" ]] ; then
if [[ "${PW_MANGOHUD}" == "1" ]] \
&& [[ "${PW_MANGOHUD_USER_CONF}" != "1" ]] \
&& [[ -z "$MANGOHUD_CONFIG" ]] ; then
MONITOR_HEIGHT="$(echo "$PW_SCREEN_RESOLUTION" | awk -F'x' '{print $2}')"
MH_FONT_SIZE="font_size=$(( MONITOR_HEIGHT / 45 ))"
@@ -4935,7 +5035,7 @@ pw_create_prefix_backup_cli () {
PW_PREFIX_NAME="$1"
PW_PREFIX_TO_BACKUP="$2"
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
backup "${PW_PREFIX_NAME}" "${PW_PREFIX_TO_BACKUP}"
return $?
}
@@ -4989,7 +5089,7 @@ pw_unpack_prefix () {
return $?
fi
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
restore "${PW_BACKUP_FILE}"
return $?
}
+2 -2
View File
@@ -222,8 +222,8 @@ fi
print_info "The first mirror in used: $MIRROR"
if check_gamescope_session
then PW_TERM="env LANG=C python3 -m portprotonqt.scripts_utils.easyterm --fullscreen -e"
else PW_TERM="env LANG=C python3 -m portprotonqt.scripts_utils.easyterm -e"
then PW_TERM="env LANG=C ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm --fullscreen -e"
else PW_TERM="env LANG=C ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
fi
pw_cleanup () {
+1 -1
View File
@@ -62,7 +62,7 @@ check_variables PW_USE_GALLIUM_NINE "0"
check_variables PW_USE_GALLIUM_ZINK "0"
check_variables PW_USE_WINED3D_VULKAN "0"
check_variables PW_USE_SUPPLIED_DXVK_VKD3D "1"
check_variables PW_WINE_FULLSCREEN_FSR "1"
check_variables PW_WINE_FULLSCREEN_FSR "0"
check_variables WINE_FULLSCREEN_FSR_MODE "0"
###DXVK_AND_VKD3D###
+9 -4
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"
},
{
@@ -1899,6 +1899,11 @@
"status": "Broken",
"slug": "tekken-7-for-esl"
},
{
"normalized_name": "rocket league",
"status": "Supported",
"slug": "rocket-league"
},
{
"normalized_name": "rocket league (faceit)",
"status": "Broken",
@@ -3341,7 +3346,7 @@
},
{
"normalized_name": "the crew motorfest",
"status": "Broken",
"status": "Supported",
"slug": "the-crew-motorfest"
},
{
@@ -3776,7 +3781,7 @@
},
{
"normalized_name": "exoborne",
"status": "Broken",
"status": "Denied",
"slug": "exoborne"
},
{
Binary file not shown.
+87474 -3226
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
+1207 -22
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
+6
View File
@@ -1,3 +1,9 @@
portprotonqt (1.5.0) unstable; urgency=medium
* New upstream release.
-- Ivan Mazhukin <vanyamajukin@outlook.com> Wed, 14 Oct 2026 21:30:22 +0500
portprotonqt (1.4.1) unstable; urgency=medium
* New upstream release.
+23 -7
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,11 +73,19 @@ _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]'
'--silent[Launch .exe file without GUI]'
'--log[Enable PortProton logging]'
'--silent[Launch Windows file or native game in tray]'
'--log[Enable game logging]'
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
'(-h --help)'{-h,--help}'[Show help message]'
@@ -94,18 +102,26 @@ 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"
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
complete -c portprotonqt -l log -d "Enable PortProton logging"
complete -c portprotonqt -l silent -d "Launch Windows file or native game in tray"
complete -c portprotonqt -l log -d "Enable game logging"
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
complete -c portprotonqt -s h -l help -d "Show help message"
+14
View File
@@ -958,6 +958,20 @@ When no `ICON_COLORS` entry matches an `AutoSizeButton` state, the button falls
---
## Boot animation (optional)
Place `bootanimation.webm` beside the theme's `styles.py`; missing videos are
inherited from parent themes. It plays with audio inside the main window only
in fullscreen, including the automatic transition when a gamepad is connected.
Minimized and windowed launches skip it, including windowed ppqtos.
The “Disable Boot Animation” checkbox in settings disables playback.
A key press, mouse click, or gamepad button skips playback. `bootAnimationTimeoutMs` defaults to
30000; `color_boot_animation` sets the background and letterbox color.
Video specifications: WebM container, VP9 video, optional Opus audio;
1920×1080 or 1280×800, 30 or 60 FPS. Recommended duration: 3–5 seconds;
the default playback timeout is 30 seconds. Aspect ratio is preserved.
## Sound Effects (optional)
Place UI sound effects in the theme's `sounds/` directory. File names must match an event name; do not add numeric suffixes such as `_001`:
+15
View File
@@ -960,6 +960,21 @@ ICON_COLORS = {
---
## Загрузочная анимация (опционально)
Положите `bootanimation.webm` рядом со `styles.py` темы. При отсутствии файла
используется видео родительской темы. Ролик со звуком показывается внутри
основного окна только в fullscreen, включая автоматический переход при
подключении геймпада. Оконный и свёрнутый запуск пропускают видео, в том числе
оконный ppqtos. Клавиша, щелчок мыши или кнопка геймпада пропускают ролик.
`bootAnimationTimeoutMs` по умолчанию равен 30000; `color_boot_animation`
задаёт цвет фона и свободных краёв.
Галочка «Disable Boot Animation» в настройках отключает воспроизведение.
Требования к видео: контейнер WebM, видеокодек VP9, звук Opus при необходимости;
1920×1080 или 1280×800, 30 или 60 FPS. Рекомендуемая длительность — 3–5 секунд;
по умолчанию воспроизведение ограничено 30 секундами. Пропорции сохраняются.
## Звуковые эффекты (опционально)
Поместите звуковые эффекты интерфейса в директорию `sounds/` темы. Имя файла должно совпадать с именем события; не добавляйте числовые суффиксы наподобие `_001`:
+1 -1
View File
@@ -1,5 +1,5 @@
project('portprotonqt', 'c',
version: '1.4.1',
version: '1.5.0',
meson_version: '>= 0.61.2',
license: 'GPL-3.0',
)
+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()
+162 -99
View File
@@ -2,27 +2,30 @@ import argparse
import sys
import os
import subprocess
import shlex
import threading
import urllib.error
import urllib.request
from urllib.parse import quote, unquote
from logging import Logger
import psutil
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
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,
display_config,
find_game_by_exe,
get_portproton_start_command,
get_portproton_location,
save_portdata_path_to_config,
@@ -31,6 +34,7 @@ from portprotonqt.config import (
window_config,
)
from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.native_games import NATIVE_URI_PREFIX, create_native_desktop_file
from portprotonqt.cli import (
parse_args,
is_portproton_url,
@@ -52,7 +56,7 @@ from portprotonqt.localization import _, get_steam_language
APP_VERSION = "@APP_VERSION@"
APP_COMMIT = "@APP_COMMIT@"
if APP_VERSION == "@APP" "_VERSION@":
APP_VERSION = "1.4.1"
APP_VERSION = "1.5.0"
try:
APP_COMMIT = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
@@ -63,6 +67,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():
@@ -79,70 +85,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 a minimal tray stop action."""
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()
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__)
@@ -200,6 +142,39 @@ 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":
from portprotonqt.appimage_integration import integrate_appimage
@@ -298,6 +273,17 @@ def main():
app.setApplicationVersion(__app_version__)
args = parsed_args
native_launch_uri = None
if args.file_or_url and args.file_or_url.startswith(NATIVE_URI_PREFIX):
native_launch_uri = args.file_or_url
elif args.file_or_url and not is_launch_file(args.file_or_url):
target = normalize_launch_path(args.file_or_url)
if os.path.isfile(target) and os.access(target, os.X_OK) and not is_autoinstall_file(target) and not is_prefix_backup_file(target):
entry = find_game_by_exe(target)
name = entry.get("Name", os.path.basename(target)) if entry is not None else os.path.basename(target)
result = create_native_desktop_file(target, name)
if result and os.path.isfile(result[1]):
native_launch_uri = NATIVE_URI_PREFIX + result[1]
setup_logger(args.debug_level)
logger = get_logger(__name__)
if consume_download_counter_skip():
@@ -329,6 +315,9 @@ def main():
elif is_restore_prefix_request(args):
restore_prefix_path = normalize_launch_path(args.file_or_url)
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
elif native_launch_uri:
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
ipc_message = f"{launch_action}:{quote(native_launch_uri, safe='')}"
elif args.file_or_url and is_launch_file(args.file_or_url):
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
@@ -356,7 +345,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 (
@@ -382,7 +371,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)
@@ -395,7 +387,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()
@@ -419,7 +415,7 @@ def main():
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
# Check if we have a portproton:// URL or launch file to handle
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri and not native_launch_uri:
if is_portproton_url(args.file_or_url):
# Parse the portproton:// URL to get the full download URL
download_url = parse_portproton_url(args.file_or_url)
@@ -504,22 +500,19 @@ def main():
launch_path = exe_path or autoinstall_path
silent_game_request = is_silent_launch and not args.log and bool(
exe_path or gog_launch_uri or egs_launch_uri
exe_path or gog_launch_uri or egs_launch_uri or native_launch_uri
)
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
window.silent_launch_mode = silent_game_request
if silent_game_request:
window._initialize_tray(__app_name__)
assert window.tray_manager is not None
if exe_path:
window.tray_manager.tray_icon.hide()
else:
window.tray_manager.set_minimal_mode()
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}"
exec_line = NATIVE_URI_PREFIX + app_id if source == "native" else f"{source}://launch/{app_id}"
if log_mode:
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
window._pending_log_exe = exec_line if source == "native" else getattr(window, f"{source}_api").get_launch_target(app_id)
def open_card(games: list[tuple]) -> None:
game = next((item for item in games if item[5] == exec_line), None)
@@ -533,17 +526,44 @@ def main():
"anticheat_status": game[9], "playtime_seconds": game[11],
"game_source": game[12], "anticheat_slug": game[13],
"ppdb_id": game[14], "ppdb_rating": game[15],
"protondb_appid": game[16],
"protondb_appid": game[16] if len(game) > 16 else "",
})
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 target.startswith(NATIVE_URI_PREFIX):
if silent and not log_mode:
window.toggleGame(target)
else:
open_store_game_card("native", target.removeprefix(NATIVE_URI_PREFIX), log_mode)
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
@@ -554,7 +574,11 @@ def main():
open_store_game_card(source, app_id, log_mode)
# Handle launch file if provided
if exe_path:
if native_launch_uri:
def handle_native_launch() -> None:
handle_game_request(native_launch_uri, is_silent_launch and not args.log, args.log)
QTimer.singleShot(0, handle_native_launch)
elif exe_path:
# Defer the call until after the window is shown
def handle_launch_exe() -> None:
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
@@ -619,24 +643,24 @@ def main():
if ":fullscreen" in msg:
logger.info("Switching to fullscreen via IPC")
display_config.set_fullscreen(True)
window.showFullScreen()
window.input_manager.handle_fullscreen_slot(True)
else:
if msg.startswith("show"):
logger.info("Switching to normal window via IPC")
display_config.set_fullscreen(False)
window.showNormal()
window.input_manager.handle_fullscreen_slot(False)
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())
if launch_path and is_launch_file(launch_path):
logger.info("Opening launch file via IPC: %s", launch_path)
handle_game_request(launch_path, False, log_mode)
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
handle_game_request(unquote(launch_path), False, True)
elif launch_path.startswith(NATIVE_URI_PREFIX) or (log_mode and launch_path.startswith(("gog://launch/", "egs://launch/"))):
handle_game_request(unquote(launch_path), False, log_mode)
else:
logger.warning("Invalid launch file via IPC: %s", launch_path)
elif msg.startswith("restore:"):
@@ -723,7 +747,7 @@ def main():
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
)
display_config.set_fullscreen(True)
window.showFullScreen()
window.input_manager.handle_fullscreen_slot(True)
elif launch_auto_fullscreen:
logger.info("Launching in fullscreen mode (gamepad)")
window.input_manager.handle_fullscreen_slot(True)
@@ -770,13 +794,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."""
@@ -863,14 +888,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 ---
@@ -885,7 +911,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()
+120
View File
@@ -0,0 +1,120 @@
"""Theme boot video rendered inside the application's startup page."""
import os
from typing import Any, TYPE_CHECKING
from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QImage, QPainter, QPaintEvent
from PySide6.QtWidgets import QApplication, QWidget
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import _find_theme_folder, _read_theme_parent_name
if TYPE_CHECKING:
from PySide6.QtMultimedia import QVideoFrame
logger = get_logger(__name__)
def find_boot_video(theme_name: str) -> str | None:
"""Find the theme's WebM, following its existing inheritance chain."""
visited = set()
while theme_name and theme_name not in visited:
visited.add(theme_name)
folder = _find_theme_folder(theme_name)
if folder is not None:
path = os.path.join(folder, "bootanimation.webm")
if os.path.isfile(path):
if os.path.dirname(os.path.realpath(path)) == os.path.realpath(folder):
return path
logger.warning("Boot video outside theme directory blocked: %s", path)
return None
theme_name = _read_theme_parent_name(theme_name) or ""
return None
class BootAnimation(QWidget):
finished = Signal()
def __init__(self, theme: Any, video_path: str, parent: QWidget) -> None:
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer, QVideoSink
super().__init__(parent)
self._background = QColor(theme.color_boot_animation)
self._video_path = video_path
self._frame = QImage()
self._stopped = False
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.sink = QVideoSink(self)
self.sink.videoFrameChanged.connect(self._receive_frame)
self.audio = QAudioOutput(self)
self.player = QMediaPlayer(self)
self.player.setAudioOutput(self.audio)
self.player.setVideoSink(self.sink)
self.player.mediaStatusChanged.connect(self._media_status_changed)
self.player.errorOccurred.connect(self._playback_error)
self.timeout = QTimer(self)
self.timeout.setSingleShot(True)
self.timeout.setInterval(theme.bootAnimationTimeoutMs)
self.timeout.timeout.connect(self.finish)
def start(self) -> None:
if self._stopped:
return
app = QApplication.instance()
if app is not None:
app.installEventFilter(self)
self.setFocus(Qt.FocusReason.OtherFocusReason)
self.timeout.start()
self.player.setSource(QUrl.fromLocalFile(self._video_path))
if not self._stopped:
self.player.play()
def _receive_frame(self, frame: "QVideoFrame") -> None:
if self._stopped or not frame.isValid():
return
image = frame.toImage()
if not image.isNull():
self._frame = image
self.update()
def paintEvent(self, event: QPaintEvent) -> None:
painter = QPainter(self)
painter.fillRect(self.rect(), self._background)
if not self._frame.isNull():
size = self._frame.size().scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
target = QRect(QPoint(), size)
target.moveCenter(self.rect().center())
painter.drawImage(target, self._frame)
def _media_status_changed(self, status: object) -> None:
from PySide6.QtMultimedia import QMediaPlayer
if status == QMediaPlayer.MediaStatus.EndOfMedia:
self.finish()
def _playback_error(self, error: object, message: str) -> None:
logger.warning("Boot video playback failed: %s", message)
self.finish()
def finish(self) -> None:
if not self._stopped:
self.stop()
self.finished.emit()
def stop(self) -> None:
if self._stopped:
return
self._stopped = True
self.timeout.stop()
self.player.stop()
self.sink.videoFrameChanged.disconnect(self._receive_frame)
self._frame = QImage()
app = QApplication.instance()
if app is not None:
app.removeEventFilter(self)
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self and event.type() in (QEvent.Type.KeyPress, QEvent.Type.MouseButtonPress):
self.finish()
return True
return super().eventFilter(watched, event)
+291 -3
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",
@@ -67,12 +89,12 @@ def parse_args():
parser.add_argument(
"--silent",
action="store_true",
help="Launch supported Windows file in tray without showing the main window"
help="Launch supported Windows file or native game in tray without showing the main window"
)
parser.add_argument(
"--log",
action="store_true",
help="Enable PortProton logging for the launched Windows file"
help="Enable logging for the launched Windows file or native game"
)
parser.add_argument(
"--restore-prefix",
@@ -89,7 +111,7 @@ def parse_args():
parser.add_argument(
'file_or_url',
nargs='?',
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg) or portproton:// URL"
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg), native executable, or portproton:// URL"
)
if os.environ.get("STEAM_COMPAT") == "1" and "--" in sys.argv[1:]:
separator_index = sys.argv.index("--")
@@ -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:
+18
View File
@@ -33,6 +33,24 @@ class DisplayConfig(BaseConfig):
validate_bool(auto, "auto_fullscreen_gamepad")
self._save_value("auto_fullscreen_gamepad", auto, "bool")
def get_hide_cursor(self) -> bool:
"""Return whether fullscreen cursor hiding is enabled."""
return self._get_bool("hide_cursor", True)
def set_hide_cursor(self, enabled: bool) -> None:
"""Enable or disable fullscreen cursor hiding."""
validate_bool(enabled, "hide_cursor")
self._save_value("hide_cursor", enabled, "bool")
def get_boot_animation_enabled(self) -> bool:
"""Return whether fullscreen boot videos are enabled."""
return self._get_bool("boot_animation_enabled", True)
def set_boot_animation_enabled(self, enabled: bool) -> None:
"""Enable or disable fullscreen boot videos."""
validate_bool(enabled, "boot_animation_enabled")
self._save_value("boot_animation_enabled", enabled, "bool")
def get_minimize_to_tray(self) -> bool:
"""Get minimize-to-tray setting."""
return self._get_bool("minimize_to_tray", False)
+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)
+18
View File
@@ -453,6 +453,15 @@ class UIConfig(BaseConfig):
validate_bool(enabled, "download_wine_to_steam")
self._save_download_value("download_wine_to_steam", enabled)
def get_disable_steam_proton_scanning(self) -> bool:
"""Get Steam Proton scanning setting."""
return self._get_bool("disable_steam_proton_scanning", False)
def set_disable_steam_proton_scanning(self, enabled: bool) -> None:
"""Set Steam Proton scanning setting."""
validate_bool(enabled, "disable_steam_proton_scanning")
self._save_value("disable_steam_proton_scanning", enabled, "bool")
def get_disable_runtime_download(self) -> bool:
"""Get PortProton runtime download setting."""
default = bool(os.getenv("FLATPAK_ID"))
@@ -510,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)
+314 -52
View File
@@ -2,13 +2,16 @@ import os
import glob
import shutil
import tempfile
import configparser
from pathlib import Path
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,
get_portproton_location,
extract_exec_target_path,
favorites_config,
favorites_folders_config,
@@ -29,6 +32,7 @@ from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.dialogs.base import DraggableDialog
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.native_games import NATIVE_URI_PREFIX, build_native_shortcut_command, quote_native_arguments
from portprotonqt.logger import get_logger
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
@@ -68,6 +72,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(
@@ -119,6 +131,8 @@ class ContextMenuManager:
Returns:
bool: True if the game is running, False otherwise.
"""
if game_card.game_source == "native":
return getattr(self.parent, "target_exe", None) == os.path.basename(game_card.exec_line.removeprefix("native://"))
if game_card.game_source == "steam":
return False
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
@@ -355,8 +369,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 +398,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 +463,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 +562,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 +580,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
@@ -626,6 +737,9 @@ class ContextMenuManager:
)
)
if game_card.game_source == "native":
self._add_native_actions(menu, game_card)
if game_card.game_source == "steam":
desktop_dir = QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.DesktopLocation
@@ -698,16 +812,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()
@@ -716,6 +838,96 @@ class ContextMenuManager:
menu.popup(game_card.mapToGlobal(pos))
def _add_native_actions(self, menu: QMenu, card: "GameCard") -> None:
source = Path(card.exec_line.removeprefix(NATIVE_URI_PREFIX))
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
managed = str(source.parent) == get_portproton_location() or source.parent == data / "PortProtonQt/native_games"
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
for directory, add_text, remove_text, icon in (
(desktop, _("Add to Desktop"), _("Remove from Desktop"), "desktop"),
(self._get_applications_dir() if managed else "", _("Add to Menu"), _("Remove from Menu"), "menu"),
):
if not directory:
continue
exists = (Path(directory) / source.name).is_file()
action = menu.addAction(self._get_safe_icon("delete" if exists else icon), remove_text if exists else add_text)
action.triggered.connect(lambda _checked=False, target=directory: self._toggle_native_shortcut(card.exec_line, target))
if get_steam_home() is not None:
is_in_steam = is_game_in_steam(card.name)
action = menu.addAction(self._get_safe_icon("delete" if is_in_steam else "menu_steam"),
_("Remove from Steam") if is_in_steam else _("Add to Steam"))
action.triggered.connect(lambda: self.remove_from_steam(card.name, card.exec_line, "native")
if is_in_steam else self.add_to_steam(card.name, card.exec_line, card.cover_path))
action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
action.triggered.connect(lambda: self._edit_native_shortcut(card))
if managed:
action = menu.addAction(self._get_safe_icon("delete"), _("Delete"))
action.triggered.connect(lambda: self.delete_game(card.name, card.exec_line))
action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
action.triggered.connect(lambda: self.parent.detail_page_manager._open_executable_folder(card.exec_line))
def _toggle_native_shortcut(self, exec_line: str, directory: str) -> None:
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
target = Path(directory) / source.name
try:
if target.is_file():
if target == source:
retained = Path(get_portproton_location() or source.parent) / source.name
retained.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, retained)
target.unlink()
else:
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.optionxform = lambda optionstr: optionstr
parser.read_string(source.read_text(encoding="utf-8"))
command = quote_native_arguments([exec_line])
parser["Desktop Entry"]["Exec"] = build_native_shortcut_command(command)
for action, mode in (("RunSilent", "--silent"), ("RunLog", "--log")):
section = f"Desktop Action {action}"
if parser.has_section(section):
parser[section]["Exec"] = build_native_shortcut_command(command, mode)
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("w", encoding="utf-8") as output:
parser.write(output, space_around_delimiters=False)
target.chmod(0o755)
if directory == self._get_applications_dir():
self._update_desktop_database()
self.parent.refreshGames()
except Exception as error:
logger.warning("Cannot update native shortcut: %s", error)
self.signals.show_warning_dialog.emit(_("Error"), str(error))
def _edit_native_shortcut(self, card: "GameCard") -> None:
source = card.exec_line.removeprefix(NATIVE_URI_PREFIX)
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
game_name=card.name, exe_path=source, cover_path=card.cover_path)
dialog.add_to_steam_checkbox.setChecked(is_game_in_steam(card.name))
if dialog.exec() != QDialog.DialogCode.Accepted:
return
name = dialog.nameEdit.text().strip()
if not name:
return
cover = dialog.coverEdit.text().strip()
if not os.path.isfile(cover) and dialog.last_cover_path:
cover = dialog.last_cover_path
try:
self._save_local_shortcut_name(source, name)
data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
directory = resolve_custom_data_dir(os.path.join(data, "PortProtonQt/custom_data"), source)
if os.path.isfile(cover) and Path(cover).suffix.lower() in COVER_IMAGE_EXTENSIONS:
target = Path(directory) / ("cover" + Path(cover).suffix.lower())
if os.path.abspath(cover) != str(target):
self._remove_old_cover_files(directory)
shutil.copyfile(cover, target)
if dialog.add_to_steam_checkbox.isChecked():
if not is_game_in_steam(card.name):
self.add_to_steam(name, card.exec_line, cover)
elif is_game_in_steam(card.name):
self.remove_from_steam(card.name, card.exec_line, "native")
self.parent.refreshGames()
except OSError as error:
logger.warning("Cannot edit native metadata: %s", error)
def _launch_game(self, game_card):
"""
Launch or stop a game based on its current state.
@@ -723,6 +935,9 @@ class ContextMenuManager:
Args:
game_card: The GameCard instance containing game data.
"""
if game_card.game_source == "native":
self.parent.toggleGame(game_card.exec_line, game_name=game_card.name)
return
if not self._check_portproton():
return
@@ -805,7 +1020,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
@@ -881,6 +1098,9 @@ class ContextMenuManager:
def _get_exec_line(self, game_name, exec_line):
"""Retrieve and validate exec_line from .desktop file if necessary."""
if exec_line and exec_line.startswith(NATIVE_URI_PREFIX):
entry = parse_desktop_entry(exec_line.removeprefix(NATIVE_URI_PREFIX))
return entry.get("Exec") if entry else None
if exec_line and exec_line.strip() != "full":
return exec_line
desktop_path = self._get_desktop_path(game_name)
@@ -1012,6 +1232,20 @@ class ContextMenuManager:
def _delete_game_without_confirm(self, game_name: str, exec_line: str) -> None:
card_exec_line = exec_line
if exec_line.startswith(NATIVE_URI_PREFIX):
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
paths = [str(source), str(data / "PortProtonQt/native_games" / source.name),
str(Path(self._get_applications_dir()) / source.name)]
if desktop:
paths.append(str(Path(desktop) / source.name))
self._remove_installed_shortcuts(game_name, paths)
self._remove_statistics_entry(str(source), game_name)
self.game_library_manager.remove_game_incremental(game_name, exec_line)
for key in ("all", "native", "favorites"):
getattr(self.parent, "_loaded_library_cache", {}).pop(key, None)
return
if not self._check_portproton():
return
desktop_path = self._get_desktop_path(game_name)
@@ -1054,14 +1288,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 +1548,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 +1663,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 +1724,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 +1737,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 +1764,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 +1783,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)
@@ -1613,26 +1873,30 @@ class ContextMenuManager:
"""
if not self._check_portproton():
return
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
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((NATIVE_URI_PREFIX, "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,27 +1918,25 @@ 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"),
_(message).format(game_name=game_name)
)
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = extract_exec_target_path(exec_line)
if not exe_path:
if not exec_line.startswith(NATIVE_URI_PREFIX) and not extract_exec_target_path(exec_line):
return
logger.debug("Removing game '%s' from Steam", game_name)
try:
+65 -9
View File
@@ -1,7 +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
@@ -17,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:
@@ -40,6 +54,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
result = [[0, 0, 0, 0] for _ in range(N)]
min_margin = 20
available_width = rect_width - 2 * min_margin
width_sums = list(accumulate((size[0] for size in nat_sizes), initial=0))
# Fast search for max items per row
max_items_per_row = 1
@@ -55,7 +70,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
while left <= right:
mid = (left + right) // 2
end_idx = min(i + mid, N)
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
sum_w = width_sums[end_idx] - width_sums[i]
needed_width = sum_w + spacing * (mid - 1)
if needed_width <= available_width:
@@ -65,7 +80,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
right = mid - 1
count = best_count
sum_width = sum(nat_sizes[j][0] for j in range(i, i + count))
sum_width = width_sums[i + count] - width_sums[i]
if count > max_items_per_row:
max_items_per_row = count
@@ -89,7 +104,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
while left <= right:
mid = (left + right) // 2
end_idx = min(i + mid, N)
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
sum_w = width_sums[end_idx] - width_sums[i]
needed_width = sum_w + spacing * (mid - 1)
if needed_width <= available_width:
@@ -151,9 +166,13 @@ class FlowLayout(QLayout):
self._cache_width = None
self._cache_visible_hash = None
self._cache_result = None
self._cache_visible_data = None
self._cache_minimum_size = None
def _get_visible_data(self):
"""Return list of visible items and their sizes"""
if self._cache_visible_data is not None:
return self._cache_visible_data
visible_items = []
visible_indices = []
visible_sizes = []
@@ -163,10 +182,13 @@ class FlowLayout(QLayout):
if widget and not widget.isHidden():
visible_items.append(item)
visible_indices.append(i)
s = item.sizeHint()
s = widget.minimumSize()
if s != widget.maximumSize():
s = item.sizeHint()
visible_sizes.append((s.width(), s.height()))
return visible_items, visible_indices, visible_sizes
self._cache_visible_data = (visible_items, visible_indices, visible_sizes)
return self._cache_visible_data
def _make_visible_hash(self, visible_sizes):
"""Create hash for change detection"""
@@ -176,6 +198,12 @@ class FlowLayout(QLayout):
self.itemList.append(item)
self._invalidate_cache()
def insertWidget(self, index: int, widget: QWidget) -> None:
self.addWidget(widget)
item = self.takeAt(self.count() - 1)
self.itemList.insert(index, item)
self.invalidate()
def takeAt(self, index: int) -> QLayoutItem:
if 0 <= index < len(self.itemList):
self._invalidate_cache()
@@ -186,6 +214,8 @@ class FlowLayout(QLayout):
self._cache_width = None
self._cache_visible_hash = None
self._cache_result = None
self._cache_visible_data = None
self._cache_minimum_size = None
def invalidate(self) -> None:
self._invalidate_cache()
@@ -247,12 +277,20 @@ class FlowLayout(QLayout):
return self.minimumSize()
def minimumSize(self):
if self._cache_minimum_size is not None:
return QSize(self._cache_minimum_size)
size = QSize()
for item in self.itemList:
size = size.expandedTo(item.minimumSize())
widget = item.widget()
if (widget is not None and not widget.isHidden()
and widget.minimumSize() == widget.maximumSize()):
size = size.expandedTo(widget.minimumSize())
else:
size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(),
margins.top() + margins.bottom())
self._cache_minimum_size = QSize(size)
return size
def doLayout(self, rect, testOnly):
@@ -291,10 +329,18 @@ class FlowLayout(QLayout):
if not testOnly:
rx, ry = rect.x(), rect.y()
row_bounds = {}
if self.alignment() & Qt.AlignmentFlag.AlignHCenter:
for x, y, w, _ in geom_array:
left = row_bounds.get(y, (x, x))[0]
row_bounds[y] = (left, x + w)
# Set geometry for visible items
for idx, item in enumerate(visible_items):
x, y, w, h = geom_array[idx]
if y in row_bounds:
left, right = row_bounds[y]
x += max(0, (rect.width() - (right - left)) // 2) - left
item.setGeometry(QRect(x + rx, y + ry, w, h))
# Hide invisible items
@@ -840,6 +886,8 @@ class NavLabel(QLabel):
super().mousePressEvent(event)
class AutoHideScrollArea(QScrollArea):
viewportResized = Signal()
def __init__(
self,
theme,
@@ -1011,7 +1059,13 @@ class AutoHideScrollArea(QScrollArea):
self._h_hide_timer.start(self.hide_delay_ms)
def wheelEvent(self, event: QWheelEvent) -> None:
if self._v_scrollbar.maximum() == 0 and self._h_scrollbar.maximum() > 0:
widget = self.widget()
horizontal_library = widget is not None and widget.property(
"library_layout_mode"
) in {"horizontal", "horizontal_top"}
if self._h_scrollbar.maximum() > 0 and (
horizontal_library or self._v_scrollbar.maximum() == 0
):
self._h_scrollbar.event(event)
return
super().wheelEvent(event)
@@ -1022,6 +1076,8 @@ class AutoHideScrollArea(QScrollArea):
if event.type() == QEvent.Type.Resize:
self._update_scroll_needed()
if obj == self.viewport():
self.viewportResized.emit()
elif obj == self._v_scrollbar:
if event.type() == QEvent.Type.Enter:
if self._scroll_needed:
+63 -8
View File
@@ -6,6 +6,9 @@ import signal
import subprocess
import threading
import time
import tempfile
import shutil
from typing import IO
from portprotonqt.logger import get_logger
from portprotonqt.config import get_portproton_location
@@ -18,7 +21,7 @@ logger = get_logger(__name__)
class DebugLogManager:
"""Manages debug log creation with game launch and Wine output capture."""
"""Manage debug logs for Wine and native game launches."""
def __init__(self):
self.process: subprocess.Popen | None = None
@@ -26,15 +29,19 @@ class DebugLogManager:
self.start_command: list[str] | None = None
self.wine_output: list[str] = []
self.runtime_env: dict[str, str] | None = None
self.native_output: IO[str] | None = None
self.native_directory: str | None = None
self.is_running = False
self.output_queue = queue.Queue()
self.output_thread = None
self._stop_event = threading.Event()
def start(self, exe_path: str, start_command: list[str]) -> bool:
"""Start game with PW_LOG=1 and capture output."""
"""Capture game output, enabling PW_LOG for PortProton launches."""
if self.is_running:
return False
if exe_path.startswith("native://"):
return self._start_native(exe_path)
self.exe_path = exe_path
self.start_command = start_command
@@ -86,6 +93,52 @@ class DebugLogManager:
logger.error("Failed to start debug session: %s", e)
return False
def _start_native(self, uri: str) -> bool:
from portprotonqt.native_games import get_native_launch
try:
command, directory = get_native_launch(uri.removeprefix("native://"))
self.native_output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace")
self.process = subprocess.Popen(
command, cwd=directory, stdout=self.native_output,
stderr=subprocess.STDOUT, start_new_session=True,
)
except Exception as error:
logger.error("Failed to start native debug session: %s", error)
if self.native_output is not None:
self.native_output.close()
self.native_output = None
return False
self.exe_path = uri.removeprefix("native://")
self.start_command = command
self.native_directory = directory
self.is_running = True
return True
def _save_native_log(self) -> str | None:
output = self.native_output
if output is None:
return None
from portprotonqt.native_games import load_native_settings, get_native_ppdb_path
try:
settings = load_native_settings(str(self.exe_path))
destination = get_native_ppdb_path(str(self.exe_path), settings).with_suffix(".log")
destination.parent.mkdir(parents=True, exist_ok=True)
output.seek(0)
with destination.open("w", encoding="utf-8") as log:
log.write(generate_system_info("native://" + str(self.exe_path), self.start_command))
log.write(f"\nCommand: {self.start_command!r}\nWorking directory: {self.native_directory}\n\n")
shutil.copyfileobj(output, log)
return str(destination)
except OSError as error:
logger.error("Failed to save native debug log: %s", error)
return None
finally:
output.close()
self.native_output = None
self.native_directory = None
def _read_output(self):
"""Read output from subprocess in a separate thread."""
if self.process and self.process.stdout:
@@ -98,19 +151,20 @@ class DebugLogManager:
logger.debug("Error reading output: %s", e)
def stop(self) -> str | None:
"""Stop game and save debug log with captured Wine output."""
self._capture_env_vars()
"""Stop the game and save its diagnostic log."""
if self.native_output is None:
self._capture_env_vars()
self._stop_event.set()
if self.is_running and self.process:
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGTERM)
try:
time.sleep(0.1)
if self.process.poll() is None:
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGKILL)
except ProcessLookupError:
pass
except Exception as e:
@@ -123,7 +177,7 @@ class DebugLogManager:
except queue.Empty:
pass
log_file = self._save_log()
log_file = self._save_native_log() if self.native_output is not None else self._save_log()
if self.process and self.process.stdout:
try:
@@ -146,7 +200,8 @@ class DebugLogManager:
self.is_running = False
return False
self._capture_env_vars()
if self.native_output is None:
self._capture_env_vars()
return True
def _capture_env_vars(self) -> None:
+35 -22
View File
@@ -290,7 +290,8 @@ def get_filesystem_info(exe_path: str | None, portproton_path: str) -> str:
game_dir = os.path.dirname(exe_path)
lines.append(f"Filesystem {game_dir} - {get_fs_type(game_dir)}")
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
if portproton_path:
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
tmp_dir = f"/tmp/PortProton_{os.environ.get('USER', 'user')}"
if os.path.exists(tmp_dir):
@@ -313,8 +314,17 @@ def generate_system_info(
get_d3d_extras_status,
)
portproton_path = get_portproton_location()
if not portproton_path:
from portprotonqt.native_games import load_native_settings, get_native_launch, get_native_ppdb_path
native = bool(exe_path and exe_path.startswith("native://"))
desktop_path = exe_path.removeprefix("native://") if native and exe_path else None
if desktop_path:
runtime_env = load_native_settings(desktop_path)
command, directory = get_native_launch(desktop_path)
exe_path = next((part for part in reversed(command)
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), directory)
portproton_path = get_portproton_location() or ""
if not portproton_path and not native:
return "Error: PortProton location not found"
lines = []
@@ -344,16 +354,17 @@ def generate_system_info(
lines.append(get_libc_version())
lines.append("-" * 61)
lines.append(get_vulkan_use_info(portproton_path, exe_path))
lines.append("-" * 61)
if not native:
lines.append(get_vulkan_use_info(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Version WINE in use:")
lines.append(get_wine_version(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Version WINE in use:")
lines.append(get_wine_version(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Program bit depth:")
lines.append(get_program_bit_depth(exe_path))
lines.append("-" * 61)
lines.append("Program bit depth:")
lines.append(get_program_bit_depth(exe_path))
lines.append("-" * 61)
lines.append("Date and time of start debug for PortProton:")
lines.append(datetime.now().strftime("%c %z"))
@@ -419,26 +430,28 @@ def generate_system_info(
lines.append(locale_avail)
lines.append("-" * 61)
lines.append(get_d3d_extras_status(portproton_path, exe_path))
lines.append("-" * 61)
prefix_name = get_prefix_name(exe_path)
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
if winetricks_content:
lines.append("winetricks.log:")
lines.append(winetricks_content)
if not native:
lines.append(get_d3d_extras_status(portproton_path, exe_path))
lines.append("-" * 61)
prefix_name = get_prefix_name(exe_path)
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
if winetricks_content:
lines.append("winetricks.log:")
lines.append(winetricks_content)
lines.append("-" * 61)
if exe_path:
ppdb_content = get_ppdb_content(exe_path, start_cmd)
ppdb_path = str(get_native_ppdb_path(desktop_path, runtime_env)).removesuffix(".ppdb") if desktop_path and runtime_env else exe_path
ppdb_content = get_file_content(ppdb_path + ".ppdb") if native else get_ppdb_content(exe_path, start_cmd)
if ppdb_content:
lines.append(f"Use {exe_path}.ppdb db file:")
lines.append(f"Use {ppdb_path}.ppdb db file:")
if ppdb_log_vars:
lines.append("\n".join(ppdb_log_vars))
lines.append(ppdb_content)
lines.append("-" * 61)
user_overrides = get_user_overrides(portproton_path)
user_overrides = "" if native else get_user_overrides(portproton_path)
if user_overrides:
lines.append(user_overrides)
lines.append("-" * 61)
+62 -9
View File
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
from PySide6.QtGui import QDesktopServices
from portprotonqt.native_games import get_native_launch, load_native_settings
from portprotonqt.detail_pages.widgets import (
create_scroll_area,
create_back_button,
@@ -93,6 +95,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)
@@ -652,7 +657,7 @@ class DetailPageManager:
)
buttons_layout.addWidget(open_folder_button)
elif (
str(game_source).lower() not in {"gog", "egs"}
str(game_source).lower() not in {"gog", "egs", "native"}
and not self._has_game_shortcut(game_name)
):
add_button = self._make_action_button(
@@ -667,7 +672,24 @@ class DetailPageManager:
buttons_layout.addWidget(add_button)
# Show settings button for PortProton games or Steam games using PortProtonQt
if str(game_source).lower() == "portproton":
if source == "native":
settings_button = self._make_action_button(
_("Settings"), self.main_window.theme_manager.get_icon("settings", as_path=True),
)
settings_button.clicked.connect(lambda: self.main_window.open_native_settings(exec_line))
buttons_layout.addWidget(settings_button)
log_button = self._make_action_button(_("Create Log"), self.main_window.theme_manager.get_icon("edit", as_path=True))
log_button.clicked.connect(lambda: self.toggleDebugLog(exec_line, log_button))
buttons_layout.addWidget(log_button)
self._debug_log_button = log_button
data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
if os.path.dirname(exec_line.removeprefix("native://")) in (
getattr(self.main_window, "portproton_location", None), os.path.join(data_home, "PortProtonQt/native_games")
):
folder_button = self._make_action_button(_("Open Folder"), self.main_window.theme_manager.get_icon("search", as_path=True))
folder_button.clicked.connect(lambda: self._open_executable_folder(exec_line))
buttons_layout.addWidget(folder_button)
elif str(game_source).lower() == "portproton":
self._add_portproton_buttons(buttons_layout, exec_line)
elif (
source in {"gog", "egs"}
@@ -722,6 +744,8 @@ class DetailPageManager:
def _get_current_exe(self, exec_line: str) -> str | None:
"""Extract current executable from exec line."""
if exec_line.startswith("native://"):
return os.path.basename(exec_line.removeprefix("native://"))
file_to_check = extract_exec_target_path(exec_line)
return os.path.basename(file_to_check) if file_to_check else None
@@ -819,9 +843,26 @@ class DetailPageManager:
def _open_executable_folder(self, exe_path: str | None) -> None:
"""Open the folder containing an executable."""
if not exe_path or not os.path.exists(exe_path):
if not exe_path:
return
folder_path = os.path.dirname(os.path.abspath(exe_path))
if exe_path.startswith("native://"):
try:
desktop_path = exe_path.removeprefix("native://")
settings = load_native_settings(desktop_path)
command, directory = get_native_launch(desktop_path)
executable = next((part for part in reversed(command)
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), None)
folder_path = os.path.dirname(executable) if executable else settings.get("Path", "")
if not folder_path or not os.path.isdir(folder_path):
raise ValueError("Game directory is not available")
except Exception as error:
logger.warning("Cannot resolve native game folder: %s", error)
QMessageBox.warning(self.main_window, _("Error"), str(error))
return
else:
if not os.path.exists(exe_path):
return
folder_path = os.path.dirname(os.path.abspath(exe_path))
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
def _has_game_shortcut(self, game_name: str) -> bool:
@@ -1023,6 +1064,9 @@ class DetailPageManager:
)
)
content_frame_layout.addLayout(page_data["buttons_layout"])
content_frame_layout.addSpacing(
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
)
def _get_compact_content_spacing(self) -> int:
return getattr(
@@ -1198,6 +1242,9 @@ class DetailPageManager:
)
)
content_frame_layout.addLayout(page_data["buttons_layout"])
content_frame_layout.addSpacing(
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
)
main_layout = self._get_main_layout(detail_page)
if main_layout:
@@ -1445,14 +1492,15 @@ class DetailPageManager:
"""Start debug logging session."""
if exe_path is None:
return
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
native = exe_path.startswith("native://")
resolved_exe_path = exe_path if native else self.main_window.resolve_launch_file_path(exe_path)
if resolved_exe_path is None:
return
if not self.main_window._check_alt_i586_dependencies_before_launch():
if not native and not self.main_window._check_alt_i586_dependencies_before_launch():
return
start_command = get_portproton_start_command()
if not start_command:
start_command = [] if native else get_portproton_start_command()
if start_command is None:
return
if self.debug_log_manager.start(resolved_exe_path, start_command):
@@ -1474,7 +1522,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()
@@ -1632,10 +1680,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()))
+8 -8
View File
@@ -745,14 +745,14 @@ def _extract_exe_name(line: str) -> str:
def _extract_exe_path(line: str) -> str:
match = re.search(r"['\"]([^'\"]+\.exe)['\"]", line.replace("\\", "/"), re.IGNORECASE)
if not match:
return ""
path = match.group(1)
drive_index = path.lower().find("drive_c/")
if drive_index < 0:
return ""
return path[drive_index:].lower()
paths = re.findall(r"['\"]([^'\"]+\.exe)['\"]", line.replace("\\", "/"), re.IGNORECASE)
for path in paths:
drive_index = path.lower().find("drive_c/")
if drive_index >= 0:
return path[drive_index:].lower()
if "/" in path and not path.startswith("$"):
return path.lower()
return ""
def _path_matches_autoinstall(exec_path: str, autoinstall_path: str) -> bool:
+499 -4
View File
@@ -1,25 +1,37 @@
"""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, QMessageBox
)
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.native_games import create_native_desktop_file, is_native_executable
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
@@ -28,6 +40,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 +138,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 +433,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()
@@ -233,6 +484,9 @@ class AddGameDialog(DraggableDialog):
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
self.add_to_menu_checkbox.setChecked(self._menu_shortcut_exists(current_name))
self.add_to_desktop_checkbox.setChecked(self._desktop_shortcut_exists(current_name))
if not edit_mode and is_native_executable(self.exeEdit.text().strip()):
self.add_to_menu_checkbox.setChecked(True)
self.add_to_desktop_checkbox.setChecked(True)
self.coverPreview = QLabel(self)
self.coverPreview.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -327,17 +581,60 @@ 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:
from portprotonqt.dialogs.file_explorer import FileExplorer
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)
def file_filter(path: str) -> bool:
return path.lower().endswith(LAUNCH_FILE_EXTENSIONS) or is_native_executable(path)
file_explorer = FileExplorer(self, file_filter=file_filter, 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 +648,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)
@@ -481,6 +967,13 @@ class AddGameDialog(DraggableDialog):
if not name:
return None, None
if is_native_executable(exe_path):
cover = self.last_cover_path or self.coverEdit.text().strip()
result = create_native_desktop_file(exe_path, name, cover)
if result is None:
QMessageBox.warning(self, _("Error"), _("Failed to create shortcut: {name}").format(name=name))
return result or (None, None)
if exe_path.lower().endswith(DISC_IMAGE_EXTENSIONS):
parent_widget = cast("MainWindow | None", self.parentWidget())
if not parent_widget or not hasattr(parent_widget, "resolve_launch_file_path"):
@@ -497,6 +990,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")),
]
+18 -4
View File
@@ -25,7 +25,7 @@ from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.localization import _
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.scripts_utils.mount_points import list_mounted_drive_paths
@@ -116,7 +116,7 @@ class FileExplorer(DraggableDialog):
self.signals.thumbnail_ready.emit(self.file_path, image)
else:
logger.warning("Failed to load image: %s", self.file_path)
elif self.file_path.lower().endswith((".exe",) + DISC_IMAGE_EXTENSIONS):
elif self.file_path.lower().endswith((".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
thumbnail_source = self.file_path
if self.file_path.lower().endswith(DISC_IMAGE_EXTENSIONS) and callable(self.resolve_launch_file_path):
resolved_path = self.resolve_launch_file_path(self.file_path)
@@ -125,6 +125,14 @@ class FileExplorer(DraggableDialog):
return
thumbnail_source = resolved_path
if thumbnail_source.lower().endswith(".appimage"):
icon_path = get_exe_icon_cache_path(thumbnail_source)
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
image = QImage()
if os.path.isfile(icon_path) or generate_thumbnail(thumbnail_source, icon_path):
image = QImage(icon_path)
self.signals.thumbnail_ready.emit(self.file_path, image)
return
if not thumbnail_source.lower().endswith(".exe"):
return
@@ -142,6 +150,8 @@ class FileExplorer(DraggableDialog):
os.unlink(tmp_name)
except Exception as e:
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
if self.file_path.lower().endswith(".appimage"):
self.signals.thumbnail_ready.emit(self.file_path, QImage())
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent)
@@ -214,7 +224,7 @@ class FileExplorer(DraggableDialog):
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
continue
mime_type = mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe",) + DISC_IMAGE_EXTENSIONS):
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
self.pending_thumbnails.add(file_path)
resolver = None
main_window = cast("MainWindow | None", self.main_window)
@@ -246,6 +256,8 @@ class FileExplorer(DraggableDialog):
def _set_thumbnail_icon(self, file_path: str, image: QImage) -> None:
icon = QIcon(QPixmap.fromImage(image))
self.thumbnail_cache[file_path] = icon
if image.isNull():
return
item = self.file_items.get(file_path)
if item:
item.setData(THEMED_ICON_ROLE, None)
@@ -607,7 +619,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)]
+34 -76
View File
@@ -2,7 +2,6 @@
import os
import platform
import re
import shutil
import tempfile
import urllib.parse
@@ -10,7 +9,7 @@ from PySide6.QtWidgets import (
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout,
QWidget, QCheckBox, QHeaderView, QMessageBox, QLabel, QTextEdit,
QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView,
QStackedWidget, QPushButton
QStackedWidget
)
from PySide6.QtCore import QEvent, QMimeData, QObject, Qt, QTimer, QUrl
from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QGuiApplication
@@ -18,6 +17,7 @@ from shiboken6 import isValid
from portprotonqt.config import get_portproton_start_command, ui_config
from portprotonqt.dialogs.base import DraggableDialog
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.localization import _
@@ -37,40 +37,6 @@ WINE_TABLE_ROW_BATCH_SIZE = 8
WINE_ARCHIVE_EXTENSIONS = ('.tar.xz', '.tar.gz')
def sort_wine_entries(entries: list[dict], source_name: str) -> list[dict]:
"""Sort Wine entries, alternating CachyOS and WineLand releases."""
if source_name.lower() != 'proton_cachyos':
return sorted(entries, key=version_sort_key)
release_groups = []
for is_wineland in (False, True):
family_entries = sorted(
(
entry for entry in entries
if ('proton-cachyos-wineland-' in entry.get('name', '').lower())
== is_wineland
),
key=version_sort_key,
)
groups = []
for entry in family_entries:
release_name = re.sub(
r'-(?:x86_64|arm64|aarch64)(?:_v\d+|-wow64)?$',
'', entry.get('name', '').lower(),
)
if not groups or groups[-1][0] != release_name:
groups.append((release_name, []))
groups[-1][1].append(entry)
release_groups.append(groups)
sorted_entries = []
for index in range(max(map(len, release_groups), default=0)):
for groups in release_groups:
if index < len(groups):
sorted_entries.extend(groups[index][1])
return sorted_entries
class ProtonManager(DraggableDialog):
"""Dialog for managing Proton/Wine versions."""
@@ -93,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()
@@ -103,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]:
@@ -274,7 +248,7 @@ class ProtonManager(DraggableDialog):
self.download_progress = QProgressBar()
self.download_progress.setMinimum(0)
self.download_progress.setMaximum(100)
self.cancel_btn = QPushButton(_('Cancel'))
self.cancel_btn = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
self.cancel_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_btn.clicked.connect(self.cancel_current_download)
progress_layout.addWidget(self.download_progress, 4)
@@ -290,18 +264,18 @@ class ProtonManager(DraggableDialog):
layout.addWidget(self.hints_widget)
button_layout = QHBoxLayout()
self.download_btn = QPushButton(_('Download Selected'))
self.download_btn = AutoSizeButton(_('Download Selected'), icon=theme_manager.get_icon('apply', as_path=True))
self.download_btn.clicked.connect(self.download_selected)
self.download_btn.setEnabled(False)
self.download_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.clear_btn = QPushButton(_('Clear All'))
self.clear_btn = AutoSizeButton(_('Clear All'), icon=theme_manager.get_icon('delete', as_path=True))
self.clear_btn.clicked.connect(self.clear_selection)
self.clear_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.open_folder_btn = QPushButton(_('Open Folder'))
self.open_folder_btn = AutoSizeButton(_('Open Folder'), icon=theme_manager.get_icon('folder', as_path=True))
self.open_folder_btn.clicked.connect(self._open_wine_folder)
self.open_folder_btn.setEnabled(bool(self.portproton_location))
self.open_folder_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_btn_dialog = QPushButton(_('Cancel'))
self.cancel_btn_dialog = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
self.cancel_btn_dialog.clicked.connect(self.reject)
self.cancel_btn_dialog.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button_layout.addWidget(self.download_btn)
@@ -312,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
@@ -352,7 +314,7 @@ class ProtonManager(DraggableDialog):
tabs = []
for source_name, entries in metadata.items():
filtered_entries = self.filter_entries_by_cpu_level(entries, source_name)
tabs.append((source_name, sort_wine_entries(filtered_entries, source_name)))
tabs.append((source_name, sorted(filtered_entries, key=version_sort_key)))
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
self.selected_assets.clear()
self.tab_widget.clear()
@@ -390,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()
@@ -425,7 +382,7 @@ class ProtonManager(DraggableDialog):
if entry_is_arm == is_arm:
filtered_entries.append(entry)
entries = filtered_entries
if source_name.lower() != 'proton_cachyos':
if source_name.lower() not in ('proton_cachyos', 'proton_wineland'):
return entries
if self.cpu_level >= 4:
return entries
@@ -482,7 +439,7 @@ class ProtonManager(DraggableDialog):
if url_filename:
entry['filename'] = url_filename
all_entries.append(entry)
all_entries = sort_wine_entries(all_entries, source_name)
all_entries.sort(key=version_sort_key)
table = self._create_empty_wine_tab(source_name, len(all_entries))
for row_index, entry in enumerate(all_entries):
self.add_asset_row_from_json(table, row_index, entry, source_name)
@@ -672,15 +629,16 @@ class ProtonManager(DraggableDialog):
installed_versions = []
seen_paths = set()
version_dirs = [dist_path]
steam_dir = get_steam_compatibilitytools_dir()
if steam_dir is not None:
version_dirs.append(str(steam_dir))
steam_home = get_steam_home()
if steam_home is not None:
for steam_lib in get_steam_libs(steam_home):
common_dir = steam_lib / "steamapps" / "common"
if common_dir.is_dir():
version_dirs.append(str(common_dir))
if not ui_config.get_disable_steam_proton_scanning():
steam_dir = get_steam_compatibilitytools_dir()
if steam_dir is not None:
version_dirs.append(str(steam_dir))
steam_home = get_steam_home()
if steam_home is not None:
for steam_lib in get_steam_libs(steam_home):
common_dir = steam_lib / "steamapps" / "common"
if common_dir.is_dir():
version_dirs.append(str(common_dir))
for version_dir in version_dirs:
for version_name in os.listdir(version_dir):
@@ -716,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()):
@@ -995,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
File diff suppressed because it is too large. Load diff
@@ -1291,6 +1291,9 @@ class MangoHudSettingsMixin:
if new_user_conf != orig_user_conf:
changes.append(f"PW_MANGOHUD_USER_CONF={new_user_conf}")
if not mangohud_enabled:
return changes
config_value = self._build_mangohud_config()
if config_value != self.mangohud_original_values.get('MANGOHUD_CONFIG', ''):
changes.append(f"MANGOHUD_CONFIG={config_value}")
+30 -11
View File
@@ -6,7 +6,7 @@ from typing import cast, TYPE_CHECKING
from PySide6.QtWidgets import (
QVBoxLayout, QHBoxLayout, QTextEdit, QTabWidget,
QTableWidget, QHeaderView, QTableWidgetItem, QAbstractItemView,
QStackedWidget, QWidget, QMessageBox
QStackedWidget, QWidget, QMessageBox, QCheckBox
)
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment
@@ -109,7 +109,7 @@ class WinetricksDialog(DraggableDialog):
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE + self.theme.TAB_STYLE)
table_base_style = self.theme.WINETRICKS_TABBLE_STYLE + self.theme.SCROLL_STYLE + self.theme.CHECKBOX_STYLE
table_base_style = self.theme.GETWINE_WINDOW_STYLE + self.theme.SCROLL_STYLE + self.theme.CHECKBOX_STYLE
self.dll_table = QTableWidget()
self.dll_table.setAlternatingRowColors(True)
@@ -120,7 +120,8 @@ class WinetricksDialog(DraggableDialog):
self.dll_table.setColumnCount(3)
self.dll_table.setHorizontalHeaderLabels([_("Set"), _("Libraries"), _("Information")])
self.dll_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.dll_table.horizontalHeader().resizeSection(0, 50)
self.dll_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
self.dll_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
self.dll_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.dll_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.dll_table.setStyleSheet(table_base_style)
@@ -152,7 +153,8 @@ class WinetricksDialog(DraggableDialog):
self.fonts_table.setColumnCount(3)
self.fonts_table.setHorizontalHeaderLabels([_("Set"), _("Fonts"), _("Information")])
self.fonts_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.fonts_table.horizontalHeader().resizeSection(0, 50)
self.fonts_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
self.fonts_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
self.fonts_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.fonts_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.fonts_table.setStyleSheet(table_base_style)
@@ -184,7 +186,8 @@ class WinetricksDialog(DraggableDialog):
self.settings_table.setColumnCount(3)
self.settings_table.setHorizontalHeaderLabels([_("Set"), _("Settings"), _("Information")])
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.settings_table.horizontalHeader().resizeSection(0, 50)
self.settings_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
self.settings_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.settings_table.setStyleSheet(table_base_style)
@@ -207,6 +210,13 @@ class WinetricksDialog(DraggableDialog):
self.settings_container.addWidget(self.settings_table)
self.tab_widget.addTab(self.settings_container, _("Settings"))
for table in (self.dll_table, self.fonts_table, self.settings_table):
table.currentCellChanged.connect(
lambda row, *_previous, table=table: table.setFocusProxy(
cast(QCheckBox, table.cellWidget(row, 0).findChild(QCheckBox))
) if table.cellWidget(row, 0) else None
)
self.containers = {
"dlls": self.dll_container,
"fonts": self.fonts_container,
@@ -359,14 +369,19 @@ class WinetricksDialog(DraggableDialog):
if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'):
continue
checked = Qt.CheckState.Checked if name in installed else Qt.CheckState.Unchecked
checked = name in installed
row = table.rowCount()
table.insertRow(row)
checkbox = QTableWidgetItem()
checkbox.setCheckState(checked)
table.setItem(row, 0, checkbox)
checkbox_widget = QWidget()
checkbox_layout = QHBoxLayout(checkbox_widget)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
checkbox = QCheckBox()
checkbox.setChecked(checked)
checkbox_layout.addWidget(checkbox)
table.setCellWidget(row, 0, checkbox_widget)
name_item = QTableWidgetItem(name)
table.setItem(row, 1, name_item)
@@ -374,13 +389,17 @@ class WinetricksDialog(DraggableDialog):
info_item = QTableWidgetItem(info)
table.setItem(row, 2, info_item)
if table is self.dll_table or table is self.fonts_table:
table.sortItems(1, Qt.SortOrder.AscendingOrder)
def install_selected(self, force=False):
"""Install selected components."""
selected = []
for table in [self.dll_table, self.fonts_table, self.settings_table]:
for row in range(table.rowCount()):
checkbox = table.item(row, 0)
if checkbox is not None and checkbox.checkState() == Qt.CheckState.Checked:
checkbox_widget = table.cellWidget(row, 0)
checkbox = checkbox_widget.findChild(QCheckBox) if checkbox_widget else None
if checkbox is not None and checkbox.isChecked():
name_item = table.item(row, 1)
if name_item is not None:
name = name_item.text()
+27 -7
View File
@@ -1,4 +1,4 @@
from PySide6.QtCore import QObject, Signal, QThread
from PySide6.QtCore import QObject, Signal, QThread, Qt, QCoreApplication
import threading
import os
import time
@@ -70,7 +70,7 @@ class Downloader(QObject):
self._locks = {}
self._active_threads: list[QThread] = []
self._global_lock = threading.Lock()
self.callback_requested.connect(self._run_callback)
self.callback_requested.connect(self._run_callback, Qt.ConnectionType.QueuedConnection)
def _run_callback(self, callback, result):
if callback:
@@ -240,11 +240,14 @@ class Downloader(QObject):
on_result(url, None)
emitted_urls.add(url)
continue
if url in self._cache:
cached_path = self._cache.get(url)
if (cached_path and os.path.exists(cached_path)
and os.path.abspath(cached_path) == os.path.abspath(path)):
if on_result and url not in emitted_urls:
on_result(url, self._cache[url])
on_result(url, cached_path)
emitted_urls.add(url)
continue
self._cache.pop(url, None)
filtered_urls.append(url)
filtered_paths.append(path)
@@ -261,7 +264,7 @@ class Downloader(QObject):
throttle_delay,
workers,
on_result=emit_result,
)
) if filtered_urls else {}
with self._global_lock:
for url, path in results.items():
@@ -270,7 +273,9 @@ class Downloader(QObject):
final_results = {}
with self._global_lock:
for url in urls:
if url in self._cache:
if url in self._last_error:
final_results[url] = None
elif url in self._cache:
final_results[url] = self._cache[url]
else:
final_results[url] = None
@@ -281,7 +286,18 @@ class Downloader(QObject):
return final_results
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread:
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread | None:
with self._global_lock:
previous_error = url in self._last_error
cached = not previous_error and os.path.exists(local_path)
if cached:
self._cache[url] = local_path
if previous_error or cached:
result = local_path if cached else None
self.download_completed.emit(url, result or "", cached)
self.callback_requested.emit(callback, result)
return None
class DownloadThread(QThread):
def __init__(self, downloader: 'Downloader', url: str, local_path: str, timeout: int, parallel: bool):
super().__init__()
@@ -308,7 +324,11 @@ class Downloader(QObject):
self.downloader.callback_requested.emit(callback, None)
thread = DownloadThread(self, url, local_path, timeout, parallel)
thread.moveToThread(self.thread())
thread.finished.connect(thread.deleteLater)
app = QCoreApplication.instance()
if app is not None:
app.aboutToQuit.connect(thread.wait)
# Remove from list after completion
def cleanup():
+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("/"))
+70 -31
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
@@ -277,7 +278,19 @@ class AnimatedCard(QFrame):
def paintEvent(self, event: QPaintEvent) -> None:
super().paintEvent(event)
self.animations.paint_border(QPainter(self))
painter = QPainter(self)
parent = self.parentWidget()
layout = parent.layout() if parent is not None else None
if getattr(self, "list_layout", False) and isinstance(layout, QVBoxLayout):
row_pitch = self.height() + layout.spacing()
row = (self.y() - layout.contentsMargins().top()) // max(1, row_pitch)
colors = (self.theme.color_bg, self.theme.color_surface_elevated)
painter.fillRect(self.rect(), QColor(colors[row % len(colors)]))
self.animations.paint_border(painter)
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_()
@@ -313,7 +326,7 @@ class AnimatedCard(QFrame):
super().focusOutEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.pos()):
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.position().toPoint()):
self.clicked.emit()
super().mouseReleaseEvent(event)
@@ -322,6 +335,7 @@ class AnimatedCard(QFrame):
class GameCard(AnimatedCard):
coverReady = Signal(object)
editShortcutRequested = Signal(str, str, str)
deleteGameRequested = Signal(str, str)
addToMenuRequested = Signal(str, str)
@@ -340,6 +354,7 @@ class GameCard(AnimatedCard):
card_width=250, parent=None, context_menu_manager=None
):
super().__init__(parent)
self.coverReady.connect(self.on_cover_loaded, Qt.ConnectionType.QueuedConnection)
self.name = name
self.description = description
self.cover_path = cover_path
@@ -365,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()
@@ -386,6 +402,7 @@ class GameCard(AnimatedCard):
self.steam_visible = str(game_source).lower() == "steam"
self.gog_visible = str(game_source).lower() == "gog"
self.egs_visible = str(game_source).lower() == "egs"
self.native_visible = str(game_source).lower() == "native"
self.portproton_visible = str(game_source).lower() == "portproton"
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
@@ -454,6 +471,7 @@ class GameCard(AnimatedCard):
self.favoriteLabel = ClickableLabel(self.coverWidget)
self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in set(favorites_config.get_games())
self._favorite_icon_state = self.is_favorite
self.update_favorite_icon()
self.favoriteLabel.raise_()
if self.list_layout:
@@ -498,6 +516,13 @@ class GameCard(AnimatedCard):
)
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
self.nativeLabel = SourceCorner(
icon=self.theme_manager.get_icon("badge_linux", as_path=True),
config=corner_config,
parent=self.coverWidget,
)
self.nativeLabel.setVisible(self.native_visible and not self.vertical_layout)
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
self.portprotonLabel = SourceCorner(
icon=portproton_icon,
@@ -573,7 +598,7 @@ class GameCard(AnimatedCard):
strict=True,
):
label = QLabel(text)
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setProperty(
"theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE"
)
@@ -644,6 +669,7 @@ class GameCard(AnimatedCard):
(self.steamLabel, "badge_steam"),
(self.gogLabel, "badge_gog"),
(self.egsLabel, "badge_egs"),
(self.nativeLabel, "badge_linux"),
(self.portprotonLabel, "badge_portproton"),
):
icon = self.theme_manager.get_icon(
@@ -727,7 +753,7 @@ class GameCard(AnimatedCard):
cover_path,
width,
height,
self.on_cover_loaded,
self.coverReady.emit,
app_name=str(self.appid or ""),
fallback_exe=fallback_exe,
fallback_icon_path=fallback_icon_path,
@@ -862,10 +888,11 @@ 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.native_visible, self.nativeLabel),
(self.portproton_visible, self.portprotonLabel),
]
for is_visible, ribbon in source_ribbons:
@@ -888,6 +915,7 @@ class GameCard(AnimatedCard):
self.portprotonLabel.raise_()
self.gogLabel.raise_()
self.egsLabel.raise_()
self.nativeLabel.raise_()
self.steamLabel.raise_()
except RuntimeError:
pass
@@ -1006,23 +1034,17 @@ class GameCard(AnimatedCard):
# Handle the case where the Qt object was deleted
pass
# Ensure parent layout is updated safely
try:
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
layout.activate()
layout.update()
parent.updateGeometry()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def update_card_size(self, new_width: int):
if self.base_card_width == new_width:
return
self.base_card_width = new_width
self._load_cover_image(self.cover_path or "")
cover_height = int(new_width * self.card_geometry_cfg.get("cover_aspect_ratio", 1.5))
if not self.animated_cover_path and (
self.base_pixmap is None or self.base_pixmap.isNull()
or self.base_pixmap.width() < new_width
or self.base_pixmap.height() < cover_height
):
self._load_cover_image(self.cover_path or "")
self.update_scale()
def update_badge_visibility(self, display_filter: str):
@@ -1035,21 +1057,19 @@ class GameCard(AnimatedCard):
self.steam_visible = str(self.game_source).lower() == "steam"
self.gog_visible = str(self.game_source).lower() == "gog"
self.egs_visible = str(self.game_source).lower() == "egs"
self.native_visible = str(self.game_source).lower() == "native"
self.portproton_visible = str(self.game_source).lower() == "portproton"
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
hidden_badges = self.badge_view_mode == "hidden"
show_source_ribbons = self.vertical_layout is not True
try:
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
self.portprotonLabel.setVisible(
self.portproton_visible and show_source_ribbons
)
self.steamLabel.setVisible(self.steam_visible)
self.gogLabel.setVisible(self.gog_visible)
self.egsLabel.setVisible(self.egs_visible)
self.nativeLabel.setVisible(self.native_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)
@@ -1211,6 +1231,9 @@ class GameCard(AnimatedCard):
# Handle the case where the Qt object was deleted
return
if self._favorite_icon_state == self.is_favorite:
return
self._favorite_icon_state = self.is_favorite
try:
parent = self.parent()
while parent:
@@ -1248,6 +1271,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
@@ -1277,6 +1306,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:
+150 -124
View File
@@ -9,7 +9,7 @@ from portprotonqt.search_utils import (
)
from PySide6.QtWidgets import QAbstractButton
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
from PySide6.QtCore import QRect, QRectF, Qt, QTimer
from PySide6.QtCore import QElapsedTimer, QRect, QRectF, Qt, QTimer
from PySide6.QtGui import QPaintEvent, QPainter, QPainterPath, QPixmap, QRegion
from portprotonqt.custom_widgets import FlowLayout, AutoHideScrollArea
from portprotonqt.config import favorites_config, game_config, ui_config
@@ -88,11 +88,17 @@ class GameLibraryManager:
self.full_library_open = False
self.sizeSlider: QSlider | None = None
self._update_timer: QTimer | None = None
self._card_resize_timer: QTimer | None = None
self._card_resize_queue: deque[tuple[str, str]] = deque()
self._card_resize_batch_size: int = 8
self._incremental_add_timer: QTimer | None = None
self._incremental_add_queue: deque[tuple[str, str]] = deque()
self._incremental_new_games_map: dict[tuple[str, str], tuple] = {}
self._incremental_search_text: str = ""
self._incremental_batch_size: int = 16
self._incremental_card_positions: dict[tuple[str, str], int] = {}
self._incremental_batch_size: int = 8
self._incremental_batch_budget_ms: int = 8
self._incremental_scroll_delay_ms: int = 250
self._focus_first_card_after_update = False
self._pending_update = False
self.pending_deletions = deque()
@@ -126,10 +132,6 @@ class GameLibraryManager:
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
layout.addWidget(searchWidget)
if self.layout_mode == "vertical":
self.libraryHeaderWidget = self._create_library_header()
layout.addWidget(self.libraryHeaderWidget)
# Scroll area for game grid
scrollArea = AutoHideScrollArea(theme=self.theme)
scrollArea.setProperty("theme_style_name", "TRANSPARENT_BACKGROUND_STYLE")
@@ -150,6 +152,7 @@ class GameLibraryManager:
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
elif self.layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
@@ -163,6 +166,9 @@ class GameLibraryManager:
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
if self.layout_mode == "vertical":
self.libraryHeaderWidget = self._create_library_header()
layout.addWidget(self.libraryHeaderWidget)
layout.addWidget(scrollArea)
# Slider for card size
@@ -191,13 +197,17 @@ class GameLibraryManager:
self._update_timer.setSingleShot(True)
self._update_timer.setInterval(100) # 100ms debounce
self._update_timer.timeout.connect(self._perform_update)
self._incremental_add_timer = QTimer()
self._card_resize_timer = QTimer(self.gamesListWidget)
self._card_resize_timer.setSingleShot(True)
self._card_resize_timer.timeout.connect(self._resize_next_card_batch)
self._incremental_add_timer = QTimer(self.gamesListWidget)
self._incremental_add_timer.setSingleShot(True)
self._incremental_add_timer.timeout.connect(self._process_incremental_add_batch)
# Connect scroll event for lazy loading
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
scrollArea.horizontalScrollBar().valueChanged.connect(self.load_visible_images)
scrollArea.viewportResized.connect(self.force_update_cards_library)
return self.gamesLibraryWidget
@@ -211,14 +221,19 @@ class GameLibraryManager:
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(*config["header_margins"])
header_layout.setSpacing(config["header_spacing"])
header_layout.addSpacing(config["header_cover_width"])
header_layout.addSpacing(config["header_cover_width"] + config["header_spacing"])
labels = (_("Game Title"), _("LAST LAUNCH"), _("TIME SPENT"), _("Library"))
for text, stretch in zip(labels, config["column_stretches"], strict=True):
label = QLabel(text)
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
label = QLabel(text.upper())
label.setAlignment(
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
if text == labels[0] else Qt.AlignmentFlag.AlignCenter
)
label.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
label.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
header_layout.addWidget(label, stretch)
if self.gamesScrollArea is not None:
header_layout.addSpacing(self.gamesScrollArea.verticalScrollBar().sizeHint().width())
return header
def rebuild_library_layout(self, layout_mode: str) -> None:
@@ -253,9 +268,15 @@ class GameLibraryManager:
vertical_policy = (
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
if horizontal
else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if layout_mode == "vertical"
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
self.gamesScrollArea.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
theme_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
@@ -308,9 +329,28 @@ class GameLibraryManager:
self.main_window._gamepad_tooltip_map[self.sizeSlider] = f"{self.card_width} px"
ui_config.set_card_width(self.card_width)
self.main_window.card_width = self.card_width
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.update_game_grid()
self._card_resize_queue = deque(self.game_card_cache)
self._resize_next_card_batch()
def _resize_next_card_batch(self) -> None:
if self.gamesListWidget is None or self.gamesListLayout is None:
self._card_resize_queue.clear()
return
self.gamesListLayout.setEnabled(False)
try:
for _ in range(min(self._card_resize_batch_size, len(self._card_resize_queue))):
card = self.game_card_cache.get(self._card_resize_queue.popleft())
if card is not None:
card.update_card_size(self.card_width)
finally:
self.gamesListLayout.invalidate()
self.gamesListLayout.setEnabled(True)
self.gamesListLayout.activate()
if self._card_resize_queue and self._card_resize_timer is not None:
self._card_resize_timer.start()
else:
self.force_update_cards_library()
self.load_visible_images()
def _set_card_width_from_slider(self):
"""Use max card width for fixed-size layouts."""
@@ -328,6 +368,8 @@ class GameLibraryManager:
"""Loads images for visible game cards."""
if self.gamesListWidget is None:
return
if self._incremental_add_queue and self._incremental_add_timer is not None:
self._incremental_add_timer.start(self._incremental_scroll_delay_ms)
visible_region = self.gamesListWidget.visibleRegion()
max_concurrent_loads = 5
loaded_count = 0
@@ -350,6 +392,7 @@ class GameLibraryManager:
card.set_animated_cover_paused(True)
def stop_background_activity(self) -> None:
self._cancel_incremental_add()
for card in self.game_card_cache.values():
card.stop_background_activity()
@@ -517,18 +560,42 @@ class GameLibraryManager:
def _perform_force_update(self):
"""Perform the actual force update on the layout."""
self._fit_horizontal_cards()
if self.gamesListLayout:
self.gamesListLayout.invalidate()
if self.gamesListWidget:
self.gamesListWidget.adjustSize()
self.gamesListWidget.updateGeometry()
def _fit_horizontal_cards(self, card: GameCard | None = None) -> None:
if self.layout_mode not in {"horizontal", "horizontal_top"}:
return
if self.gamesScrollArea is None or self.gamesListLayout is None:
return
margins = self.gamesListLayout.contentsMargins()
height = self.gamesScrollArea.viewport().height() - margins.top() - margins.bottom()
if height <= 0:
return
cards = (card,) if card is not None else self.game_card_cache.values()
for card in cards:
config = card.card_layout_cfg
scale = max(
config.get(name, self.theme.GAME_CARD_ANIMATION[name])
for name in ("default_scale", "hover_scale", "focus_scale")
)
available = height / scale - card.base_extra_margin
ratio = card.card_geometry_cfg.get("card_height_ratio", 1.8)
width = min(self.card_width, max(1, int(available / ratio)))
if width < self.theme.COMPACT_CARD["width_threshold"]:
width = min(width, max(1, int(available / self.theme.COMPACT_CARD["height_ratio"])))
card.update_card_size(width)
def _cancel_incremental_add(self) -> None:
if self._incremental_add_timer is not None and self._incremental_add_timer.isActive():
self._incremental_add_timer.stop()
self._incremental_add_queue.clear()
self._incremental_new_games_map = {}
self._incremental_search_text = ""
self._incremental_card_positions = {}
def _start_incremental_add(
self,
@@ -539,41 +606,43 @@ class GameLibraryManager:
if self.gamesListLayout is None:
return
self._cancel_incremental_add()
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
for card in self.game_card_cache.values():
if card.isVisible():
card.setVisible(False)
self._incremental_add_queue = deque(card_order)
self._incremental_card_positions = {key: index for index, key in enumerate(card_order)}
desired_keys = set(card_order)
self.gamesListLayout.setEnabled(False)
try:
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
for key, card in self.game_card_cache.items():
visible = key in desired_keys and (not search_text or search_text in key[0].lower())
card.setVisible(visible)
card.set_animated_cover_paused(not visible)
for key in card_order:
card = self.game_card_cache.get(key)
if card is not None:
self.gamesListLayout.addWidget(card)
finally:
self.gamesListLayout.setEnabled(True)
self._incremental_add_queue = deque(key for key in card_order if key not in self.game_card_cache)
self._incremental_new_games_map = new_games_map
self._incremental_search_text = search_text
if self._incremental_add_timer is not None:
self._incremental_add_timer.start(0)
self._process_incremental_add_batch()
def _process_incremental_add_batch(self) -> None:
if self.gamesListLayout is None or self.gamesListWidget is None:
self._cancel_incremental_add()
return
added_new_card = False
processed = 0
max_batch = min(self._incremental_batch_size, len(self._incremental_add_queue))
self.gamesListWidget.setUpdatesEnabled(False)
batch_timer = QElapsedTimer()
batch_timer.start()
self.gamesListLayout.setEnabled(False)
try:
while processed < max_batch:
for _ in range(min(self._incremental_batch_size, len(self._incremental_add_queue))):
game_key = self._incremental_add_queue.popleft()
card = self.game_card_cache.get(game_key)
if card is None:
if self.context_menu_manager is None:
processed += 1
continue
game_data = self._incremental_new_games_map.get(game_key)
if game_data is None:
processed += 1
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
added_new_card = True
game_data = self._incremental_new_games_map.get(game_key)
if game_data is None or self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
should_be_visible = (
not self._incremental_search_text or
self._incremental_search_text in str(game_key[0]).lower()
@@ -581,24 +650,29 @@ class GameLibraryManager:
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
card.set_animated_cover_paused(not should_be_visible)
self.gamesListLayout.addWidget(card)
processed += 1
self.gamesListLayout.insertWidget(self._incremental_card_positions[game_key], card)
if batch_timer.elapsed() >= self._incremental_batch_budget_ms:
break
finally:
self.gamesListWidget.setUpdatesEnabled(True)
self.gamesListLayout.update()
self.gamesListLayout.invalidate()
self.gamesListLayout.setEnabled(True)
if self.layout_mode in {"horizontal", "horizontal_top"}:
self.gamesListWidget.resize(
self.gamesListWidget.size().expandedTo(self.gamesListLayout.minimumSize())
)
self.gamesListLayout.activate()
self.gamesListWidget.updateGeometry()
self._focus_first_visible_card()
if self._incremental_add_queue:
if self._incremental_add_timer is not None:
self._incremental_add_timer.start(0)
self._incremental_add_timer.start(self._incremental_batch_budget_ms)
return
if added_new_card:
self.load_visible_images()
self.load_visible_images()
self._sync_full_library_tile()
self.force_update_cards_library()
self._cancel_incremental_add()
self._schedule_focus_first_card()
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
@@ -621,7 +695,6 @@ class GameLibraryManager:
sort_method = game_config.get_sort_method()
# Batch layout updates (extended scope)
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
@@ -657,10 +730,19 @@ class GameLibraryManager:
current_game_keys = {(game[0], game[5]) for game in sorted_games}
# Remove cards that no longer exist (batch)
retained_keys = {
(game[0], game[5])
for games in getattr(self.main_window, "_loaded_library_cache", {}).values()
for game in games
}
cards_to_remove = []
for card_key in list(self.game_card_cache.keys()):
if card_key not in current_game_keys:
cards_to_remove.append(card_key)
card = self.game_card_cache[card_key]
card.setVisible(False)
card.set_animated_cover_paused(True)
if card_key not in retained_keys:
cards_to_remove.append(card_key)
for card_key in cards_to_remove:
card = self.game_card_cache.pop(card_key)
@@ -673,15 +755,15 @@ class GameLibraryManager:
# Track current layout order (only if dirty/full update needed)
if self.dirty and self.gamesListLayout is not None:
current_layout_order = []
card_keys = {card: key for key, card in self.game_card_cache.items()}
for i in range(self.gamesListLayout.count()):
item = self.gamesListLayout.itemAt(i)
if item is not None:
widget = item.widget()
if widget:
for key, card in self.game_card_cache.items():
if card == widget:
current_layout_order.append(key)
break
key = card_keys.get(widget)
if key is not None:
current_layout_order.append(key)
else:
current_layout_order = None # Skip reorg if not dirty
@@ -737,7 +819,6 @@ class GameLibraryManager:
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
@@ -812,7 +893,7 @@ class GameLibraryManager:
QTimer.singleShot(0, self._focus_first_visible_card)
def _focus_first_visible_card(self) -> None:
if self.gamesListWidget is None:
if not self._focus_first_card_after_update or self.gamesListWidget is None:
return
if getattr(self.main_window.stackedWidget, "currentIndex", lambda: -1)() != 0:
return
@@ -829,74 +910,14 @@ class GameLibraryManager:
if self.gamesListLayout is None or self.gamesListWidget is None:
return
# Batch layout updates
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Create set of keys for current filtered games for fast lookup
filtered_keys = {(game[0], game[5]) for game in self.filtered_games} # (name, exec_line)
# Process existing cards: show cards that are in filtered results, hide others
cards_to_hide = []
for card_key, card in self.game_card_cache.items():
if card_key in filtered_keys:
# Card should be visible
if not card.isVisible():
card.setVisible(True)
card.set_animated_cover_paused(False)
else:
# Card should be hidden
if card.isVisible():
card.setVisible(False)
cards_to_hide.append(card_key)
card.set_animated_cover_paused(True)
# Now add any missing cards that are in filtered results but not in cache
cards_to_add = []
for game_data in self.filtered_games:
game_name = game_data[0]
exec_line = game_data[5]
game_key = (game_name, exec_line)
if game_key not in self.game_card_cache:
if self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(True) # New cards should be visible
card.set_animated_cover_paused(False)
cards_to_add.append((game_key, card))
# Add new cards to layout
for _game_key, card in cards_to_add:
self.gamesListLayout.addWidget(card)
# Remove cards that are no longer needed (if any)
# Note: we're not removing them completely as they might be needed later
# Instead, we just hide them and they'll be reused if needed
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.force_update_cards_library()
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()
# If search is empty, load images for visible ones
if not search_text:
self.load_visible_images()
else:
QTimer.singleShot(0, self.load_visible_images)
visible_keys = {(game[0], game[5]) for game in self.filtered_games}
card_order = [key for key in self.game_card_cache if key in visible_keys]
new_games_map = {
(game[0], game[5]): game for game in self.filtered_games
if (game[0], game[5]) not in self.game_card_cache
}
card_order.extend(new_games_map)
self._start_incremental_add(card_order, new_games_map, search_text)
def _create_game_card(self, game_data: tuple) -> GameCard:
"""Creates a new game card with all necessary connections."""
@@ -912,6 +933,8 @@ class GameLibraryManager:
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
self._fit_horizontal_cards(card)
if self.context_menu_manager:
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
@@ -957,6 +980,8 @@ class GameLibraryManager:
# Always schedule widget for deletion regardless of cache state
if isinstance(widget, GameCard):
widget.cleanup()
if widget is self.fullLibraryTile:
self.fullLibraryTile = None
widget.deleteLater()
# Also clear the cache completely if needed (in case layout wasn't in sync)
@@ -1000,7 +1025,8 @@ class GameLibraryManager:
def add_game_incremental(self, game_data: tuple):
"""Add a single game without full reload."""
self.games.append(game_data)
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
if self.filtered_games is not self.games:
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
self._build_search_indices(self.games)
self.dirty = True
self.update_game_grid()
+93 -2
View File
@@ -1,13 +1,21 @@
"""Unified icon extractor for NE and PE files."""
"""Unified icon extractor for NE, PE and type-2 AppImages."""
import os
import hashlib
import re
import shlex
import struct
import io
import subprocess
import tarfile
import posixpath
import glob
import pefile
from PySide6.QtCore import QByteArray, QBuffer, QIODevice
from PySide6.QtGui import QImage, QImageWriter
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__)
@@ -15,6 +23,82 @@ logger = get_logger(__name__)
RT_ICON = 3
RT_GROUP_ICON = 14
THUMBNAIL_SIZE = 128
APPIMAGE_EXTRACT_TIMEOUT = 10
ELF_SECTION_LAYOUTS = {1: (32, "I", 46), 2: (40, "Q", 58)}
def _read_dwarfs_icon(path: str, offset: int) -> bytes:
"""Read an icon and resolve archive-local links without extracting to disk."""
filename = ".DirIcon"
seen: set[str] = set()
while filename not in seen:
seen.add(filename)
result = subprocess.run(
["dwarfsextract", "-i", os.path.abspath(path), "-O", str(offset),
"-f", "pax", "-o", "-", "--pattern", ":" + glob.escape(filename),
"--skip-devices", "--skip-specials", "--log-level", "error"],
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
)
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
member = next((item for item in archive if item.name.removeprefix("./") == filename), None)
if member is None:
return b""
if member.issym() or member.islnk():
target = member.linkname
base = posixpath.dirname(filename) if member.issym() else ""
filename = posixpath.normpath(target.lstrip("/") if target.startswith("/")
else posixpath.join(base, target))
if filename == ".." or filename.startswith("../"):
return b""
continue
if not member.isfile():
return b""
stream = archive.extractfile(member)
return stream.read() if stream is not None else b""
return b""
def _get_appimage_icon(path: str) -> io.BytesIO | None:
"""Read .DirIcon from a type-2 AppImage without executing its runtime."""
with open(path, "rb") as source:
header = source.read(64)
if len(header) < 64 or header[:4] != b"\x7fELF" or header[8:11] != b"AI\x02":
return None
layout = ELF_SECTION_LAYOUTS.get(header[4])
if layout is None or header[5] not in (1, 2):
return None
endian = "<" if header[5] == 1 else ">"
section_offset, offset_format, size_offset = layout
offset = struct.unpack_from(endian + offset_format, header, section_offset)[0]
entry_size, entry_count = struct.unpack_from(endian + "HH", header, size_offset)
offset += entry_size * entry_count
source.seek(offset)
magic = source.read(6)
if not magic.startswith(b"hsqs") and magic != b"DWARFS":
return None
try:
if magic == b"DWARFS":
data = _read_dwarfs_icon(path, offset)
else:
result = subprocess.run(
["unsquashfs", "-cat", "-o", str(offset), os.path.abspath(path), ".DirIcon"],
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
)
data = result.stdout
except OSError as error:
logger.warning("Cannot read AppImage icon from %s: %s", path, error)
return None
except subprocess.SubprocessError as error:
logger.warning("Cannot extract AppImage icon from %s: %s", path, error)
return None
image = QImage.fromData(data)
if image.isNull():
return None
buffer = QBuffer()
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
if not QImageWriter(buffer, QByteArray(b"PNG")).write(image):
return None
return io.BytesIO(buffer.data().data())
def _resolve_batch_icon_target(inputfile: str) -> str | None:
@@ -49,7 +133,12 @@ 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))
if exe_path.lower().endswith(".appimage"):
icon_name += "-" + hashlib.sha256(os.fsencode(os.path.abspath(exe_path))).hexdigest()
return os.path.join(
CACHE_DIR,
"images",
@@ -60,7 +149,7 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
"""
Generates a thumbnail for an .exe file.
Generates a thumbnail for a Windows executable or type-2 AppImage.
inputfile: the input file path
outfile: output filename
@@ -113,6 +202,8 @@ class IconExtractor:
"""
try:
with open(self.file_path, "rb") as f:
if f.read(4) == b"\x7fELF":
return _get_appimage_icon(self.file_path)
if not self._parse_file(f):
return None
+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"
+30 -9
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:
@@ -529,12 +544,18 @@ class InputManager(
window = self._parent
if not isinstance(window, QWidget):
return
if enable and not self._is_fullscreen:
if enable and not window.isFullScreen():
if not window.isFullScreen():
window_config.set_geometry(window.width(), window.height())
window.showFullScreen()
self._is_fullscreen = True
elif not enable and self._is_fullscreen:
start_animation = getattr(window, "start_boot_animation", None)
if callable(start_animation):
start_animation()
elif not enable and window.isFullScreen():
animation = getattr(window, "boot_animation", None)
if animation is not None:
animation.finish()
window.showNormal()
width, height = window_config.get_geometry()
if width > 0 and height > 0:
+26 -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,
@@ -34,6 +36,11 @@ logger = get_logger(__name__)
class ButtonInputMixin(InputMixin):
@Slot(int, int)
def handle_button_slot(self, button_code: int, value: int) -> None:
animation = getattr(self._parent, "boot_animation", None)
if animation is not None:
if value:
animation.finish()
return
if self._route_surface_button(button_code, value):
return
self._handle_default_button(button_code, value)
@@ -128,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()
@@ -155,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
@@ -171,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
@@ -191,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
@@ -200,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)
@@ -241,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
+57 -3
View File
@@ -11,6 +11,7 @@ from PySide6.QtWidgets import (
QListView,
QListWidget,
QMenu,
QPushButton,
QStackedWidget,
QTableWidget,
QWidget,
@@ -35,6 +36,48 @@ class InputSurface:
class DialogInputModesMixin(InputMixin):
def _get_dialog_action_buttons(self, dialog: QWidget) -> list[QPushButton]:
"""Return available dialog buttons outside tab contents."""
buttons = [
button for button in dialog.findChildren(
QPushButton, options=Qt.FindChildOption.FindDirectChildrenOnly,
)
if button.isVisible() and button.isEnabled()
]
return sorted(buttons, key=lambda button: (button.y(), button.x()))
def _handle_table_action_navigation(self, dialog: QWidget, code: int, value: int) -> bool:
"""Move between the current table and dialog action buttons."""
direction = self._normalize_dpad_direction(code, value)
if direction is None or QApplication.activePopupWidget() is not None:
return False
code, value = direction
buttons = self._get_dialog_action_buttons(dialog)
focused = QApplication.focusWidget()
table = next((table for table in dialog.findChildren(QTableWidget) if table.isVisible()), None)
if isinstance(focused, QPushButton) and focused in buttons:
self._reset_surface_navigation()
if code == PAD_DPAD_X:
index = buttons.index(focused) + value
if 0 <= index < len(buttons):
buttons[index].setFocus(Qt.FocusReason.OtherFocusReason)
elif value < 0:
if table is not None:
table.setFocus(Qt.FocusReason.OtherFocusReason)
elif dialog is getattr(self, 'settings_dialog', None):
self._focus_first_row_in_current_settings_table()
return True
if table is None or code != PAD_DPAD_Y or value < 0 or not buttons:
return False
next_row = table.currentRow() + 1
while next_row < table.rowCount() and table.isRowHidden(next_row):
next_row += 1
if next_row < table.rowCount():
return False
self._reset_surface_navigation()
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
return True
# WINETRICKS MODE
def enable_winetricks_mode(self, winetricks_dialog):
"""Setup gamepad handling for WinetricksDialog"""
@@ -70,9 +113,13 @@ class DialogInputModesMixin(InputMixin):
# Winetricks-specific button handling
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if isinstance(focused, QTableWidget):
if button_code in BUTTONS['confirm']: # A: Confirm
if isinstance(focused, QCheckBox) and focused.isEnabled():
focused.toggle()
elif isinstance(focused, QTableWidget):
self.handle_table_confirm(focused)
elif isinstance(focused, QPushButton) and focused.isEnabled():
focused.click()
return
elif button_code in BUTTONS['add_game']: # X: Install
@@ -110,6 +157,8 @@ class DialogInputModesMixin(InputMixin):
if self.winetricks_dialog is None:
return
try:
if self._handle_table_action_navigation(self.winetricks_dialog, code, value):
return
if value == 0: # Release
self.dpad_timer.stop()
self.current_dpad_code = None
@@ -498,7 +547,10 @@ class DialogInputModesMixin(InputMixin):
# ProtonManager-specific button handling
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if button_code in BUTTONS['confirm']: # A: Confirm
if isinstance(focused, QPushButton) and focused.isEnabled():
focused.click()
return
if isinstance(focused, QTableWidget):
current_row = focused.currentRow()
if current_row >= 0:
@@ -555,6 +607,8 @@ class DialogInputModesMixin(InputMixin):
return
try:
if self._handle_table_action_navigation(self.proton_manager_dialog, code, value):
return
if value == 0: # Release
self.dpad_timer.stop()
self.current_dpad_code = None
+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
+12 -13
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
@@ -448,9 +446,9 @@ class KeyboardInputMixin(InputMixin):
for attr_name in (
"gamesSortCombo",
"gamesDisplayCombo",
"onlyInstalledCheckBox",
"gamesBadgeViewCombo",
"gamesLayoutCombo",
"onlyInstalledCheckBox",
):
widget = getattr(self._parent, attr_name, None)
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
@@ -516,13 +514,14 @@ class KeyboardInputMixin(InputMixin):
filter_widgets = self._get_library_filter_widgets() if current_index == 0 else []
if focused in filter_widgets:
if code == PAD_DPAD_X:
widget_index = filter_widgets.index(cast(QWidget, focused))
next_index = widget_index + (1 if value > 0 else -1)
if 0 <= next_index < len(filter_widgets):
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
if code != PAD_DPAD_Y:
return False
widget_index = filter_widgets.index(cast(QWidget, focused))
next_index = widget_index + (1 if value > 0 else -1)
if 0 <= next_index < len(filter_widgets):
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
if value < 0:
controls_button = getattr(self._parent, "libraryControlsButton", None)
if isinstance(controls_button, QWidget):
+14 -3
View File
@@ -100,7 +100,7 @@ class GamepadRuntimeMixin(InputMixin):
if not self._initial_gamepad_check:
SoundManager().play("gamepad_connect")
if display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
if not self._initial_gamepad_check and display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
self.toggle_fullscreen.emit(True)
elif self.gamepad:
@@ -217,7 +217,12 @@ class GamepadRuntimeMixin(InputMixin):
def _poll_button_events(self, gamepad: SDLGamepad, current_time: float) -> None:
"""Emit button changes using SDL's standardized controller mapping."""
for button_index, button_code in SDL_CONTROLLER_BUTTON_TO_PAD.items():
# Deliver Guide before Select when both change in the same SDL snapshot.
buttons = sorted(
SDL_CONTROLLER_BUTTON_TO_PAD.items(),
key=lambda item: item[1] not in BUTTONS['guide'],
)
for button_index, button_code in buttons:
value = gamepad.get_button(button_index)
self._handle_button_value(button_index, button_code, value, current_time)
@@ -225,8 +230,13 @@ class GamepadRuntimeMixin(InputMixin):
if self._button_states.get(button_index) == value:
return
self._button_states[button_index] = value
if getattr(self._parent, "boot_animation", None) is not None:
self.button_event.emit(button_code, value)
return
if button_code in BUTTONS['guide']:
self.guide_held = value == 1
if self.guide_held:
self.pending_menu_fullscreen_time = 0.0
if button_code in BUTTONS['menu']:
self.select_held = value == 1
if button_code == PAD_BUTTON_START:
@@ -259,7 +269,8 @@ class GamepadRuntimeMixin(InputMixin):
return (
value == 1 and button_code in BUTTONS['menu'] and
not emulation_combo and
not self._is_gamescope_session and not self.in_guide_combination_attempt and
not self._is_gamescope_session and not self.guide_held and
not self.in_guide_combination_attempt and
self._parent.isActiveWindow()
)
+53 -17
View File
@@ -128,6 +128,7 @@ class SettingsInputMixin(InputMixin):
open_combo.lineEdit().setFocus()
else:
open_combo.setFocus()
open_combo.activated.emit(open_combo.currentIndex())
return
# Standard interaction
@@ -183,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:
@@ -269,6 +267,10 @@ class SettingsInputMixin(InputMixin):
kb.move_focus_up()
return
if self._handle_table_action_navigation(self.settings_dialog, code, value):
return
action_buttons = self._get_dialog_action_buttons(self.settings_dialog)
if self._is_current_settings_tool_tab():
focused = QApplication.focusWidget()
open_combo = self._get_open_settings_combo()
@@ -314,6 +316,8 @@ class SettingsInputMixin(InputMixin):
self.current_dpad_value = normalized_value
sections = self._get_mangohud_nav_sections()
if action_buttons:
sections.append(action_buttons)
if not sections:
return
@@ -335,15 +339,23 @@ 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()
new_idx = max(0, idx - 1) if value < 0 else min(cell_widget.count() - 1, idx + 1)
if new_idx != idx:
cell_widget.setCurrentIndex(new_idx)
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()
new_idx = next((
row for row in range(idx + step, end, step)
if model.flags(model.index(row, 0)) & Qt.ItemFlag.ItemIsEnabled
), 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
@@ -464,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:
@@ -480,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
@@ -496,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 ""
@@ -1207,6 +1249,9 @@ msgstr ""
msgid "disabled"
msgstr ""
msgid "Download Wine/Proton..."
msgstr ""
msgid "Enter prefix name"
msgstr ""
@@ -2114,9 +2159,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
msgid "Download process failed"
msgstr ""
msgid "Quick Launch"
msgstr ""
@@ -2144,7 +2186,7 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgid "Show only installed games"
msgstr ""
msgid "Detailed"
@@ -2165,7 +2207,7 @@ msgstr ""
msgid "List"
msgstr ""
msgid "Table"
msgid "Vertical"
msgstr ""
msgid "Horizontal Blocks"
@@ -2174,13 +2216,13 @@ msgstr ""
msgid "Horizontal Cards"
msgstr ""
msgid "Horizontal Top Blocks"
msgid "Horizontal endless Blocks"
msgstr ""
msgid "Horizontal Top Cards"
msgid "Horizontal endless Cards"
msgstr ""
msgid "Grid Type:"
msgid "Layout"
msgstr ""
#, python-brace-format
@@ -2189,6 +2231,9 @@ msgid ""
"will remove the .desktop files and custom data."
msgstr ""
msgid "Failed to install theme"
msgstr ""
msgid "PortProton Settings"
msgstr ""
@@ -2219,9 +2264,15 @@ msgstr ""
msgid "Launch Application in Fullscreen"
msgstr ""
msgid "Hide Cursor in Fullscreen"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""
msgid "Disable Boot Animation"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
@@ -2246,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 ""
@@ -2255,6 +2309,9 @@ msgstr ""
msgid "Add to Steam compatibility tools"
msgstr ""
msgid "Disable Steam Proton scanning"
msgstr ""
msgid "Download WINE/Proton to Steam"
msgstr ""
@@ -2463,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-09-11 12:20+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"
@@ -18,17 +18,27 @@ 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.8.1\n"
"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!"
@@ -1218,15 +1272,14 @@ msgstr "Der Download oder das Entpacken wurde abgebrochen."
msgid "System WINE"
msgstr "System WINE"
#, fuzzy
msgid "Global Game Settings"
msgstr "Einstellungen speichern"
msgstr "Globale Spieleinstellungen"
msgid "Exe Settings"
msgstr "Einstellungen für die exe-Datei"
msgid "Edit user.conf"
msgstr ""
msgstr "user.conf bearbeiten"
msgid "Search settings…"
msgstr "Einstellungen suchen…"
@@ -1252,9 +1305,8 @@ msgstr "PPDB bearbeiten"
msgid "Clear PPDB"
msgstr "PPDB zurücksetzen"
#, fuzzy
msgid "Failed to load global game settings."
msgstr "Laden der Themes fehlgeschlagen"
msgstr "Das Laden der globalen Spieleinstellungen ist fehlgeschlagen."
msgid "Executable path is not available."
msgstr "Der Pfad zur ausführbaren Datei ist nicht verfügbar."
@@ -1265,12 +1317,8 @@ msgstr "PPDB-Datei ist nicht vorhanden unter: "
msgid "Failed to open PPDB file:\n"
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
#, fuzzy
msgid "Failed to open file:\n"
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
msgid "Information"
msgstr "Information"
msgstr "Datei konnte nicht geöffnet werden:\n"
msgid "Confirm PPDB Clear"
msgstr "PPDB-Zurücksetzung bestätigen"
@@ -1292,6 +1340,9 @@ msgstr "Standard"
msgid "disabled"
msgstr "deaktiviert"
msgid "Download Wine/Proton..."
msgstr "Lade Wine/Proton herunter..."
msgid "Enter prefix name"
msgstr "Prefix-Name eingeben"
@@ -1579,17 +1630,14 @@ msgstr "Hintergrund-Deckkraft"
msgid "Round corners (px)"
msgstr "Abgerundete Ecken (px)"
#, fuzzy
msgid "Font scale"
msgstr "Schriftarten"
msgstr "Schriftgröße"
#, fuzzy
msgid "Font size"
msgstr "Schriftarten"
msgstr "Schriftgröße"
#, fuzzy
msgid "Font"
msgstr "Schriftarten"
msgstr "Schrift"
msgid "Top-left"
msgstr "Oben links"
@@ -2218,9 +2266,6 @@ msgstr "Fehlgeschlagen"
msgid "Repaired"
msgstr "Repariert"
msgid "Download process failed"
msgstr "Download-Vorgang ist fehlgeschlagen"
msgid "Quick Launch"
msgstr "Schnellstart"
@@ -2248,8 +2293,8 @@ msgstr "Alle"
msgid "Display Filter:"
msgstr "Anzeigefilter:"
msgid "Only Installed"
msgstr "Nur installierte"
msgid "Show only installed games"
msgstr "Nur installierte Spiele anzeigen"
msgid "Detailed"
msgstr "Detailliert"
@@ -2260,39 +2305,32 @@ msgstr "Versteckt"
msgid "Badge View Type:"
msgstr "Anzeigetyp von Titelbildern:"
#, fuzzy
msgid "Theme"
msgstr "Themen"
msgstr "Thema"
msgid "Grid"
msgstr ""
msgstr "Gitter"
#, fuzzy
msgid "List"
msgstr "Hell"
msgstr "Liste"
#, fuzzy
msgid "Table"
msgstr "Stable"
msgid "Vertical"
msgstr "Vertikal"
#, fuzzy
msgid "Horizontal Blocks"
msgstr "Horizontales HUD-Layout"
msgstr "Horizontale Blöcke"
#, fuzzy
msgid "Horizontal Cards"
msgstr "Horizontales HUD-Layout"
msgstr "Horizontale Karten"
#, fuzzy
msgid "Horizontal Top Blocks"
msgstr "Horizontales HUD-Layout"
msgid "Horizontal endless Blocks"
msgstr "Endlose horizontale Blöcke"
msgid "Horizontal Top Cards"
msgstr ""
msgid "Horizontal endless Cards"
msgstr "Endlose horizontale Karten"
#, fuzzy
msgid "Grid Type:"
msgstr "Gamepad-Typ:"
msgid "Layout"
msgstr "Layout"
#, python-brace-format
msgid ""
@@ -2302,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"
@@ -2332,9 +2373,15 @@ msgstr "Gamepad-Typ:"
msgid "Launch Application in Fullscreen"
msgstr "Anwendung im Vollbildmodus starten"
msgid "Hide Cursor in Fullscreen"
msgstr "Cursor im Vollbildmodus ausblenden"
msgid "Auto Fullscreen on Gamepad connected"
msgstr "Automatischer Vollbildmodus bei angeschlossenem Gamepad"
msgid "Disable Boot Animation"
msgstr "Startanimation deaktivieren"
msgid "Minimize to tray on close"
msgstr "Beim Schließen in die Taskleiste minimieren"
@@ -2359,8 +2406,11 @@ 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 ""
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
msgid "Disable runtime download"
msgstr "Runtime-Download deaktivieren"
@@ -2368,6 +2418,9 @@ msgstr "Runtime-Download deaktivieren"
msgid "Add to Steam compatibility tools"
msgstr "Zu den Steam-Kompatibilitätswerkzeugen hinzufügen"
msgid "Disable Steam Proton scanning"
msgstr "Scan nach Steam-Proton deaktivieren"
msgid "Download WINE/Proton to Steam"
msgstr "Lade WINE/Proton zu Steam herunter"
@@ -2580,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"
@@ -1310,6 +1358,10 @@ msgstr ""
msgid "disabled"
msgstr ""
#, fuzzy
msgid "Download Wine/Proton..."
msgstr "Versión de WINE/Proton"
msgid "Enter prefix name"
msgstr ""
@@ -2222,10 +2274,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
#, fuzzy
msgid "Download process failed"
msgstr "Descargar seleccionados"
msgid "Quick Launch"
msgstr ""
@@ -2254,8 +2302,8 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgstr "Solo instalados"
msgid "Show only installed games"
msgstr "Mostrar solo los juegos instalados"
msgid "Detailed"
msgstr ""
@@ -2277,8 +2325,8 @@ msgid "List"
msgstr ""
#, fuzzy
msgid "Table"
msgstr "Estable"
msgid "Vertical"
msgstr "parcial"
msgid "Horizontal Blocks"
msgstr ""
@@ -2286,13 +2334,13 @@ msgstr ""
msgid "Horizontal Cards"
msgstr ""
msgid "Horizontal Top Blocks"
msgid "Horizontal endless Blocks"
msgstr ""
msgid "Horizontal Top Cards"
msgid "Horizontal endless Cards"
msgstr ""
msgid "Grid Type:"
msgid "Layout"
msgstr ""
#, fuzzy, python-brace-format
@@ -2303,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 ""
@@ -2333,9 +2384,15 @@ msgstr ""
msgid "Launch Application in Fullscreen"
msgstr ""
msgid "Hide Cursor in Fullscreen"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""
msgid "Disable Boot Animation"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
@@ -2360,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 ""
@@ -2369,6 +2429,9 @@ msgstr ""
msgid "Add to Steam compatibility tools"
msgstr ""
msgid "Disable Steam Proton scanning"
msgstr ""
msgid "Download WINE/Proton to Steam"
msgstr ""
@@ -2579,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 ""
+101 -50
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-09-26 12:00+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 ""
@@ -1203,6 +1245,9 @@ msgstr ""
msgid "disabled"
msgstr ""
msgid "Download Wine/Proton..."
msgstr ""
msgid "Enter prefix name"
msgstr ""
@@ -2110,9 +2155,6 @@ msgstr ""
msgid "Repaired"
msgstr ""
msgid "Download process failed"
msgstr ""
msgid "Quick Launch"
msgstr ""
@@ -2140,7 +2182,7 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgid "Show only installed games"
msgstr ""
msgid "Detailed"
@@ -2161,7 +2203,7 @@ msgstr ""
msgid "List"
msgstr ""
msgid "Table"
msgid "Vertical"
msgstr ""
msgid "Horizontal Blocks"
@@ -2170,13 +2212,13 @@ msgstr ""
msgid "Horizontal Cards"
msgstr ""
msgid "Horizontal Top Blocks"
msgid "Horizontal endless Blocks"
msgstr ""
msgid "Horizontal Top Cards"
msgid "Horizontal endless Cards"
msgstr ""
msgid "Grid Type:"
msgid "Layout"
msgstr ""
#, python-brace-format
@@ -2185,6 +2227,9 @@ msgid ""
"will remove the .desktop files and custom data."
msgstr ""
msgid "Failed to install theme"
msgstr ""
msgid "PortProton Settings"
msgstr ""
@@ -2215,9 +2260,15 @@ msgstr ""
msgid "Launch Application in Fullscreen"
msgstr ""
msgid "Hide Cursor in Fullscreen"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""
msgid "Disable Boot Animation"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
@@ -2242,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 ""
@@ -2251,6 +2305,9 @@ msgstr ""
msgid "Add to Steam compatibility tools"
msgstr ""
msgid "Disable Steam Proton scanning"
msgstr ""
msgid "Download WINE/Proton to Steam"
msgstr ""
@@ -2460,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-09 00:26+0000\n"
"PO-Revision-Date: 2026-10-17 23:27+0000\n"
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
"portprotonqt/portprotonqt/pt_BR/>\n"
@@ -18,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.8.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,17 @@ 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 ""
msgid "Yes"
msgstr "Sim"
msgid "No"
msgstr "Não"
msgid "Update complete"
msgstr "Atualização concluída"
@@ -114,12 +122,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 +149,16 @@ msgstr "A pasta já existe: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Falha ao renomear o arquivo: {error}"
#, fuzzy
msgid "Select multiple"
msgstr "Selecionar Tudo"
msgid "Delete from PortProton"
msgstr "Excluir do PortProton"
msgid "Stop Game"
msgstr "Parar o Jogo"
msgid "Launch Game"
msgstr "Iniciar Jogo"
@@ -258,17 +267,16 @@ msgstr "Área de Trabalho"
msgid "Failed to save custom data: {error}"
msgstr "Falha ao salvar os dados personalizados: {error}"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Executable not found"
msgstr "Executável não encontrado: {0}"
msgstr "Executável não encontrado"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Executável não encontrado: {0}"
#, fuzzy
msgid "Select another executable"
msgstr "Excluir atalhos sem executável"
msgstr "Selecione outro executável"
msgid "Game name and executable path are required"
msgstr "O nome do jogo e o caminho do executável são obrigatórios"
@@ -280,14 +288,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}"
@@ -307,10 +307,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}"
@@ -323,10 +319,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}"
@@ -483,6 +475,16 @@ msgstr "Outra operação da Epic está em andamento"
msgid "Wine prefix not found"
msgstr "Prefixo do Wine não encontrado"
msgid ""
"Cyrillic characters were found in the following paths. Wine may work "
"unreliably. Move the game or PortProton to a path without Cyrillic "
"characters:\n"
"\n"
msgstr ""
msgid "Don't remind again"
msgstr ""
msgid "Game not found."
msgstr "Jogo não encontrado."
@@ -840,9 +842,8 @@ msgstr "min."
msgid "sec."
msgstr "s."
#, fuzzy
msgid "Pause Game"
msgstr "Iniciar Jogo"
msgstr "Pausar jogo"
msgid "Exit"
msgstr "Sair"
@@ -856,13 +857,11 @@ msgstr "Favoritos"
msgid "Recent Games"
msgstr "Jogos Recentes"
#, fuzzy
msgid "Resume Game"
msgstr "Jogos Recentes"
msgstr "Retomar jogo"
#, fuzzy
msgid "Failed to pause game"
msgstr "Falha ao parar o jogo"
msgstr "Falha ao pausar o jogo"
msgid "No recent games"
msgstr "Nenhum jogo recente"
@@ -963,6 +962,22 @@ msgstr "Atualizando AppImage..."
msgid "Launching {0}"
msgstr "Iniciando {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Falha ao criar a pasta: {error}"
msgid "Name:"
msgstr "Nome:"
@@ -978,6 +993,10 @@ msgstr "Capa Personalizada:"
msgid "Enter local path or URL for cover image"
msgstr "Informe o caminho local ou a URL da imagem de capa"
#, fuzzy
msgid "Custom Icon:"
msgstr "Capa Personalizada:"
msgid "Add shortcut to:"
msgstr "Adicionar atalho em:"
@@ -990,6 +1009,33 @@ msgstr "Aplicar"
msgid "Invalid image"
msgstr "Imagem inválida"
#, fuzzy
msgid "Select folder"
msgstr "Excluir Pasta"
#, fuzzy
msgid "Processing games…"
msgstr "Jogos Recentes"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr "Informação"
msgid "No games found in the selected folder."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "Jogos Recentes"
msgid "Close"
msgstr "Fechar"
msgid "Failed to download cover"
msgstr "Falha ao baixar a capa"
@@ -1007,8 +1053,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"
@@ -1031,9 +1083,6 @@ msgstr "Aba Anterior"
msgid "Next Tab"
msgstr "Próxima Aba"
msgid "Save"
msgstr "Salvar"
msgid "Search"
msgstr "Pesquisar"
@@ -1181,9 +1230,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!"
@@ -1222,15 +1268,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…"
@@ -1256,9 +1301,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."
@@ -1269,12 +1313,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"
@@ -1296,6 +1336,9 @@ msgstr "Padrão"
msgid "disabled"
msgstr "desativado"
msgid "Download Wine/Proton..."
msgstr "Baixar Wine/Proton..."
msgid "Enter prefix name"
msgstr "Digite o nome do prefixo"
@@ -1581,17 +1624,14 @@ msgstr "Opacidade do fundo"
msgid "Round corners (px)"
msgstr "Cantos arredondados (px)"
#, fuzzy
msgid "Font scale"
msgstr "Fontes"
msgstr "Escala da fonte"
#, fuzzy
msgid "Font size"
msgstr "Fontes"
msgstr "Tamanho da fonte"
#, fuzzy
msgid "Font"
msgstr "Fontes"
msgstr "Fonte"
msgid "Top-left"
msgstr "Superior esquerdo"
@@ -2138,7 +2178,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"
@@ -2220,9 +2260,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"
@@ -2250,8 +2287,8 @@ msgstr "Todos"
msgid "Display Filter:"
msgstr "Filtro de Exibição:"
msgid "Only Installed"
msgstr "Apenas instalados"
msgid "Show only installed games"
msgstr "Mostrar apenas jogos instalados"
msgid "Detailed"
msgstr "Detalhado"
@@ -2262,39 +2299,32 @@ 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"
#, fuzzy
msgid "Table"
msgstr "Estável"
msgid "Vertical"
msgstr "Vertical"
#, fuzzy
msgid "Horizontal Blocks"
msgstr "Layout horizontal do HUD"
msgstr "Blocos horizontais"
#, fuzzy
msgid "Horizontal Cards"
msgstr "Layout horizontal do HUD"
msgstr "Cartões horizontais"
#, fuzzy
msgid "Horizontal Top Blocks"
msgstr "Layout horizontal do HUD"
msgid "Horizontal endless Blocks"
msgstr "Blocos horizontais infinitos"
msgid "Horizontal Top Cards"
msgstr ""
msgid "Horizontal endless Cards"
msgstr "Cartões horizontais infinitos"
#, fuzzy
msgid "Grid Type:"
msgstr "Tipo de Controle:"
msgid "Layout"
msgstr "Layout"
#, python-brace-format
msgid ""
@@ -2304,6 +2334,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"
@@ -2334,9 +2367,15 @@ msgstr "Tipo de Controle:"
msgid "Launch Application in Fullscreen"
msgstr "Iniciar Aplicativo em Tela Cheia"
msgid "Hide Cursor in Fullscreen"
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 "Desativar animação de inicialização"
msgid "Minimize to tray on close"
msgstr "Minimizar para a bandeja ao fechar"
@@ -2361,15 +2400,21 @@ msgstr "Ativar sons da interface"
msgid "Show compatibility report after game crash"
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
msgid "Disable ALT Linux i586 dependency check"
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr "Desativar verificação de dependências do ALT Linux i586"
msgid "Disable runtime download"
msgstr "Desativar download de runtime"
msgid "Add to Steam compatibility tools"
msgstr "Adicionar às ferramentas de compatibilidade da Steam"
msgid "Disable Steam Proton scanning"
msgstr "Desativar a busca por instalações do Steam Proton"
msgid "Download WINE/Proton to Steam"
msgstr "Baixar WINE/Proton para a Steam"
@@ -2580,15 +2625,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-09-20 18:28+0000\n"
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
"PO-Revision-Date: 2026-10-24 09: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,10 @@ msgstr "Пользовательская обложка:"
msgid "Enter local path or URL for cover image"
msgstr "Введите локальный путь или URL обложки"
#, fuzzy
msgid "Custom Icon:"
msgstr "Пользовательская обложка:"
msgid "Add shortcut to:"
msgstr "Добавить ярлык:"
@@ -985,6 +1012,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 +1055,14 @@ msgstr ""
"Программа остановилась. Информация о совместимости была собрана "
"автоматически."
msgid "Close"
msgstr "Закрыть"
msgid "Downloading…"
msgstr "Скачивание…"
msgid "Download process failed"
msgstr "Ошибка процесса загрузки"
msgid "Downloading Complete"
msgstr "Скачивание завершено"
msgid "Open"
msgstr "Открыть"
@@ -1026,9 +1085,6 @@ msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
msgid "Save"
msgstr "Сохранить"
msgid "Search"
msgstr "Поиск"
@@ -1176,9 +1232,6 @@ msgstr "Не удалось удалить WINE/Proton в {path}: {error}"
msgid "Successfully removed {} WINE/Proton."
msgstr "Успешно удалено {} WINE/Proton."
msgid "Downloading Complete"
msgstr "Скачивание завершено"
msgid "All selected WINE/Proton have been downloaded!"
msgstr "Все выбранные WINE/Proton успешно загружены!"
@@ -1217,15 +1270,14 @@ msgstr "Скачивание или распаковка отменены."
msgid "System WINE"
msgstr "Системный WINE"
#, fuzzy
msgid "Global Game Settings"
msgstr "Сохранить настройки"
msgstr "Глобальные настройки игры"
msgid "Exe Settings"
msgstr "Настройки EXE"
msgid "Edit user.conf"
msgstr ""
msgstr "Изменить user.conf"
msgid "Search settings…"
msgstr "Поиск настроек…"
@@ -1251,9 +1303,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 "Путь к исполняемому файлу недоступен."
@@ -1264,12 +1315,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"
@@ -1289,6 +1336,9 @@ msgstr "Стандартный"
msgid "disabled"
msgstr "отключено"
msgid "Download Wine/Proton..."
msgstr "Скачать Wine/Proton..."
msgid "Enter prefix name"
msgstr "Введите имя префикса"
@@ -1547,7 +1597,7 @@ msgid "Failed to save custom preset."
msgstr "Ошибка сохранения пользовательских настроек."
msgid "Overlay position"
msgstr "Позиция MangoHud"
msgstr "Положение MangoHud"
msgid "Device battery"
msgstr "Батарея устройства"
@@ -1562,7 +1612,7 @@ msgid "FCAT screen edge"
msgstr "Край области FCAT"
msgid "Table columns"
msgstr "Кол-во колонок"
msgstr "Столбцов таблицы"
msgid "Network (tx/rx kb/s)"
msgstr "Сеть (tx/rx kb/s)"
@@ -1931,9 +1981,8 @@ msgid ""
"Select one or more FPS presets. The values are saved into FPS_LIMIT and "
"MangoHud will also show the active FPS limit."
msgstr ""
"Выберите один или несколько предустановленных профилей FPS. Значения "
"будут сохранены в FPS_LIMIT и MangoHud также отобразит активное "
"ограничение FPS."
"Выберите один или несколько предустановленных профилей FPS. Значения будут "
"сохранены в FPS_LIMIT, а также MangoHud отобразит активное ограничение FPS."
msgid "Additional comma-separated MangoHud options not covered by the GUI."
msgstr "Дополнительные параметры MangoHud (через запятую), отсутствующие в GUI."
@@ -2207,9 +2256,6 @@ msgstr "Ошибка"
msgid "Repaired"
msgstr "Исправлено"
msgid "Download process failed"
msgstr "Ошибка процесса загрузки"
msgid "Quick Launch"
msgstr "Быстрый запуск"
@@ -2237,8 +2283,8 @@ msgstr "Все"
msgid "Display Filter:"
msgstr "Фильтр:"
msgid "Only Installed"
msgstr "Только установленные"
msgid "Show only installed games"
msgstr "Показывать только установленные игры"
msgid "Detailed"
msgstr "Подробный"
@@ -2249,39 +2295,32 @@ msgstr "Скрытый"
msgid "Badge View Type:"
msgstr "Тип отображения бейджей:"
#, fuzzy
msgid "Theme"
msgstr "Темы"
msgstr "Тема"
msgid "Grid"
msgstr ""
msgstr "Сетка"
#, fuzzy
msgid "List"
msgstr "Светлая"
msgstr "Список"
#, fuzzy
msgid "Table"
msgstr "Стабильный"
msgid "Vertical"
msgstr "Вертикальная"
#, fuzzy
msgid "Horizontal Blocks"
msgstr "Горизонтальное расположение HUD"
msgstr "Горизонтальные блоки"
#, fuzzy
msgid "Horizontal Cards"
msgstr "Горизонтальное расположение HUD"
msgstr "Горизонтальные карточки"
#, fuzzy
msgid "Horizontal Top Blocks"
msgstr "Горизонтальное расположение HUD"
msgid "Horizontal endless Blocks"
msgstr "Бесконечные горизонтальные блоки"
msgid "Horizontal Top Cards"
msgstr ""
msgid "Horizontal endless Cards"
msgstr "Бесконечные горизонтальные карточки"
#, fuzzy
msgid "Grid Type:"
msgstr "Тип геймпада:"
msgid "Layout"
msgstr "Режим отображения"
#, python-brace-format
msgid ""
@@ -2291,6 +2330,9 @@ msgstr ""
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
"приведёт к удалению файлов .desktop и пользовательских данных."
msgid "Failed to install theme"
msgstr "Не удалось установить тему"
msgid "PortProton Settings"
msgstr "Настройки PortProtonQt"
@@ -2321,9 +2363,15 @@ msgstr "Тип геймпада:"
msgid "Launch Application in Fullscreen"
msgstr "Запускать приложение в полноэкранном режиме"
msgid "Hide Cursor in Fullscreen"
msgstr "Скрывать курсор в полноэкранном режиме"
msgid "Auto Fullscreen on Gamepad connected"
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
msgid "Disable Boot Animation"
msgstr "Отключить анимацию загрузки"
msgid "Minimize to tray on close"
msgstr "Сворачивать в область уведомления при закрытии"
@@ -2348,8 +2396,11 @@ msgstr "Включить звуки интерфейса"
msgid "Show compatibility report after game crash"
msgstr "Показывать отчёт о совместимости после сбоя игры"
msgid "Disable Cyrillic path warning"
msgstr "Отключить предупреждение о кириллице в пути"
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgstr "Отключить проверку зависимостей ALT Linux i586"
msgid "Disable runtime download"
msgstr "Отключить скачивание контейнера"
@@ -2357,6 +2408,9 @@ msgstr "Отключить скачивание контейнера"
msgid "Add to Steam compatibility tools"
msgstr "Добавить в инструменты совместимости Steam"
msgid "Disable Steam Proton scanning"
msgstr "Отключить сканирование Steam Proton"
msgid "Download WINE/Proton to Steam"
msgstr "Скачивать Proton в Steam"
@@ -2565,15 +2619,9 @@ msgstr "Не удалось загрузить темы"
msgid "Failed to download theme"
msgstr "Не удалось скачать тему"
msgid "Downloading…"
msgstr "Скачивание…"
msgid "Downloading… {0}%"
msgstr "Скачивание… {0}%"
msgid "Failed to install theme"
msgstr "Не удалось установить тему"
msgid "Select Theme:"
msgstr "Выбрать тему:"
@@ -21,14 +21,22 @@ msgstr ""
"X-Generator: Weblate 2026.6.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 ""
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,9 +143,16 @@ msgstr "文件已存在:{file_name}"
msgid "Failed to rename file: {error}"
msgstr "重命名文件失败:{error}"
#, fuzzy
msgid "Select multiple"
msgstr "全选"
msgid "Delete from PortProton"
msgstr "从 PortProton 中删除"
msgid "Stop Game"
msgstr "停止游戏"
msgid "Launch Game"
msgstr "启动游戏"
@@ -273,14 +282,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 +299,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 +309,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}"
@@ -476,6 +469,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 "未找到游戏。"
@@ -925,6 +928,22 @@ msgstr "正在更新 AppImage..."
msgid "Launching {0}"
msgstr "正在启动 {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "创建文件夹失败:{error}"
msgid "Name:"
msgstr "名称:"
@@ -940,6 +959,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 +975,34 @@ msgstr "应用"
msgid "Invalid image"
msgstr "图像无效"
#, fuzzy
msgid "Select folder"
msgstr "删除文件夹"
#, fuzzy
msgid "Processing games…"
msgstr "最近游戏"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
msgid "Information"
msgstr "信息"
msgid "No games found in the selected folder."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "最近游戏"
#, fuzzy
msgid "Close"
msgstr "鼠标"
msgid "Failed to download cover"
msgstr "下载封面失败"
@@ -967,9 +1018,15 @@ msgstr ""
msgid "The program stopped. Compatibility details were collected automatically."
msgstr ""
msgid "Downloading…"
msgstr "正在下载…"
#, fuzzy
msgid "Close"
msgstr "鼠标"
msgid "Download process failed"
msgstr "下载进行中"
msgid "Downloading Complete"
msgstr "下载完成"
msgid "Open"
msgstr "打开"
@@ -992,9 +1049,6 @@ msgstr "上一标签页"
msgid "Next Tab"
msgstr "下一标签页"
msgid "Save"
msgstr "保存"
msgid "Search"
msgstr "搜索"
@@ -1142,9 +1196,6 @@ msgstr "移除位于 {path} 的 WINE/Proton 失败:{error}"
msgid "Successfully removed {} WINE/Proton."
msgstr "已成功移除 {} 个 WINE/Proton。"
msgid "Downloading Complete"
msgstr "下载完成"
msgid "All selected WINE/Proton have been downloaded!"
msgstr "所有选定的 WINE/Proton 已下载完成!"
@@ -1234,9 +1285,6 @@ msgstr "打开 PPDB 文件失败:\n"
msgid "Failed to open file:\n"
msgstr "打开 PPDB 文件失败:\n"
msgid "Information"
msgstr "信息"
msgid "Confirm PPDB Clear"
msgstr "确认清除 PPDB"
@@ -1255,6 +1303,10 @@ msgstr "默认"
msgid "disabled"
msgstr "已禁用"
#, fuzzy
msgid "Download Wine/Proton..."
msgstr "将 WINE/Proton 下载到 Steam"
msgid "Enter prefix name"
msgstr "输入前缀名称"
@@ -2183,10 +2235,6 @@ msgstr "详细"
msgid "Repaired"
msgstr "已配对"
#, fuzzy
msgid "Download process failed"
msgstr "下载进行中"
msgid "Quick Launch"
msgstr "快速启动"
@@ -2214,9 +2262,8 @@ msgstr "全部"
msgid "Display Filter:"
msgstr "显示筛选:"
#, fuzzy
msgid "Only Installed"
msgstr "已安装"
msgid "Show only installed games"
msgstr "仅显示已安装的游戏"
msgid "Detailed"
msgstr "详细"
@@ -2239,8 +2286,8 @@ msgid "List"
msgstr "浅色"
#, fuzzy
msgid "Table"
msgstr "稳定"
msgid "Vertical"
msgstr "部分"
#, fuzzy
msgid "Horizontal Blocks"
@@ -2251,15 +2298,16 @@ msgid "Horizontal Cards"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Horizontal Top Blocks"
msgid "Horizontal endless Blocks"
msgstr "水平 HUD 布局"
msgid "Horizontal Top Cards"
msgstr ""
#, fuzzy
msgid "Horizontal endless Cards"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Grid Type:"
msgstr "游戏手柄类型:"
msgid "Layout"
msgstr "注销"
#, python-brace-format
msgid ""
@@ -2269,6 +2317,9 @@ msgstr ""
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
"据。"
msgid "Failed to install theme"
msgstr "安装主题失败"
msgid "PortProton Settings"
msgstr "PortProton 设置"
@@ -2300,9 +2351,16 @@ msgstr "游戏手柄类型:"
msgid "Launch Application in Fullscreen"
msgstr "全屏启动应用程序"
#, fuzzy
msgid "Hide Cursor in Fullscreen"
msgstr "强制窗口全屏"
msgid "Auto Fullscreen on Gamepad connected"
msgstr "连接手柄时自动全屏"
msgid "Disable Boot Animation"
msgstr ""
msgid "Minimize to tray on close"
msgstr "关闭时最小化到系统托盘"
@@ -2327,6 +2385,9 @@ msgstr "启用界面音效"
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable Cyrillic path warning"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
@@ -2336,6 +2397,9 @@ msgstr "禁用运行时下载"
msgid "Add to Steam compatibility tools"
msgstr "添加到 Steam 兼容工具"
msgid "Disable Steam Proton scanning"
msgstr ""
msgid "Download WINE/Proton to Steam"
msgstr "将 WINE/Proton 下载到 Steam"
@@ -2546,15 +2610,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 "选择主题:"
+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.getdefaultlocale()[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:
+363 -67
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
@@ -27,6 +29,7 @@ from portprotonqt.detail_pages import DetailPageManager
from portprotonqt.portproton_api import PortProtonAPI, remove_empty_custom_data_dirs
from portprotonqt.gog_api import GOGAPI
from portprotonqt.egs_api import EGSAPI
from portprotonqt.native_games import NativeLibraryWorker, NATIVE_URI_PREFIX, get_native_launch, read_native_game
from portprotonqt.debug_utils import get_prefix_name
from portprotonqt.input_manager import InputManager, MainWindowProtocol
from portprotonqt.context_menu_manager import ContextMenuManager
@@ -90,9 +93,11 @@ from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
STORE_LAUNCH_GRACE_SECONDS = 10
GAME_PROCESS_CHECK_INTERVAL_MS = 500
if TYPE_CHECKING:
from portprotonqt.appimage_updater import AppImageUpdateWorker
from portprotonqt.boot_animation import BootAnimation
logger = get_logger(__name__)
DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
@@ -114,7 +119,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:
@@ -134,19 +139,49 @@ class MainWindow(
def changeEvent(self, event: QEvent) -> None:
super().changeEvent(event)
if event.type() in (QEvent.Type.ActivationChange, QEvent.Type.WindowStateChange):
self._update_cursor_visibility()
self._update_animated_covers_activity()
def hideEvent(self, event: QHideEvent) -> None:
self._update_cursor_visibility()
self._set_animated_covers_suspended(True)
super().hideEvent(event)
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self._update_cursor_visibility()
self._update_animated_covers_activity()
def _on_application_state_changed(self, _state: Qt.ApplicationState) -> None:
self._update_cursor_visibility()
self._update_animated_covers_activity()
def _update_cursor_visibility(self) -> None:
if not hasattr(self, "_cursor_timer"):
return
hide = (
self.isFullScreen() and self.isVisible() and not self.isMinimized()
and display_config.get_hide_cursor()
and QApplication.applicationState() == Qt.ApplicationState.ApplicationActive
)
if hide and not self._cursor_hidden and not self._cursor_timer.isActive():
self._cursor_timer.start(self.theme.cursorHideTimeoutMs)
elif not hide:
self._cursor_timer.stop()
if self._cursor_hidden:
QApplication.restoreOverrideCursor()
self._cursor_hidden = False
def _hide_cursor(self) -> None:
if (
self.isFullScreen() and self.isVisible() and not self.isMinimized()
and display_config.get_hide_cursor()
and QApplication.applicationState() == Qt.ApplicationState.ApplicationActive
and not self._cursor_hidden
):
QApplication.setOverrideCursor(Qt.CursorShape.BlankCursor)
self._cursor_hidden = True
def _update_animated_covers_activity(self) -> None:
should_suspend = (
not self.isVisible() or
@@ -218,6 +253,15 @@ class MainWindow(
self.gamepad_tooltip_timer.start(max(2500, min(12000, 1500 + len(text) * 30)))
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
if isinstance(obj, QWidget) and hasattr(self, "_cursor_timer"):
if event.type() == QEvent.Type.Polish:
obj.setMouseTracking(True)
if event.type() in (QEvent.Type.MouseMove, QEvent.Type.MouseButtonPress):
if self.isFullScreen() and self.isVisible() and not self.isMinimized() and display_config.get_hide_cursor():
if self._cursor_hidden:
QApplication.restoreOverrideCursor()
self._cursor_hidden = False
self._cursor_timer.start(self.theme.cursorHideTimeoutMs)
if isinstance(obj, QWidget) and obj in getattr(self, "_gamepad_tooltip_map", {}):
if event.type() in (QEvent.Type.Enter, QEvent.Type.FocusIn):
self._show_gamepad_tooltip(True, self._gamepad_tooltip_map[obj], obj)
@@ -307,6 +351,15 @@ class MainWindow(
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
self.setAcceptDrops(True)
self._init_gamepad_tooltip()
self._cursor_hidden = False
self._cursor_timer = QTimer(self)
self._cursor_timer.setSingleShot(True)
self._cursor_timer.timeout.connect(self._hide_cursor)
app = QApplication.instance()
if app is not None:
app.installEventFilter(self)
for widget in self.findChildren(QWidget):
widget.setMouseTracking(True)
self.current_exec_line = None
self.currentDetailPage = None
self.current_play_button = None
@@ -367,7 +420,11 @@ class MainWindow(
# Central widget and main layout
centralWidget = QWidget()
self.setCentralWidget(centralWidget)
self.startup_stack = QStackedWidget(self)
self.startup_stack.addWidget(centralWidget)
self.setCentralWidget(self.startup_stack)
self.boot_animation: BootAnimation | None = None
self._boot_pending_load = False
mainLayout = QVBoxLayout(centralWidget)
mainLayout.setSpacing(0)
mainLayout.setContentsMargins(0, 0, 0, 0)
@@ -504,31 +561,59 @@ class MainWindow(
)
self.system_theme_watcher.start()
auto_fullscreen_gamepad = (
display_config.get_auto_fullscreen_gamepad()
and self.input_manager.gamepad is not None
)
if display_config.get_fullscreen() or auto_fullscreen_gamepad:
self.showFullScreen()
elif self._pending_resolution:
if self._pending_resolution:
# Apply resolution from command line
self.resize(self._pending_resolution[0], self._pending_resolution[1])
self.showNormal()
else:
width, height = window_config.get_geometry()
if width > 0 and height > 0:
self.resize(width, height)
else:
self.showNormal()
# Process events to ensure UI is responsive before starting heavy operations
QApplication.processEvents()
self.updateControlHints("force")
# Delay game loading until after the UI is fully displayed to prevent blocking
# Use a longer delay to ensure window is fully rendered and responsive
# Use a custom event processing approach to make sure UI stays responsive
QTimer.singleShot(500, self.loadGames) # Reduced delay but ensure UI gets event processing
self.startup_load_timer = QTimer(self)
self.startup_load_timer.setSingleShot(True)
self.startup_load_timer.setInterval(self.theme.startupLibraryLoadDelayMs)
self.startup_load_timer.timeout.connect(self.loadGames)
self.startup_load_timer.start()
def start_boot_animation(self) -> None:
"""Show the boot page when entering fullscreen."""
if not self.isFullScreen() or self.boot_animation is not None:
return
if not display_config.get_boot_animation_enabled():
return
from portprotonqt.boot_animation import BootAnimation, find_boot_video
path = find_boot_video(self.current_theme_name)
if path is None:
return
try:
animation = BootAnimation(self.theme, path, self.startup_stack)
except ImportError as error:
logger.warning("Qt Multimedia unavailable for boot video: %s", error)
return
self.boot_animation = animation
self._boot_pending_load = self.startup_load_timer.isActive()
self.startup_load_timer.stop()
self.startup_stack.addWidget(animation)
self.startup_stack.setCurrentWidget(animation)
animation.finished.connect(self._finish_boot_animation)
app = QApplication.instance()
if app is not None:
app.aboutToQuit.connect(animation.stop)
QTimer.singleShot(0, animation.start)
def _finish_boot_animation(self) -> None:
animation = self.boot_animation
if animation is None:
return
self.boot_animation = None
self.startup_stack.setCurrentIndex(0)
self.startup_stack.removeWidget(animation)
animation.deleteLater()
if self._boot_pending_load:
self._boot_pending_load = False
self.startup_load_timer.start()
def on_slider_released(self) -> None:
"""Delegate to game library manager."""
@@ -893,17 +978,13 @@ class MainWindow(
@Slot(list)
def on_games_loaded(self, games: list[tuple]):
self._loading_games = False
self.games = games
if not game_config.get_only_installed():
display_filter = game_config.get_display_filter()
current_filter = (game_config.get_display_filter(), game_config.get_only_installed())
loaded_filter = getattr(self, "_loading_library_filter", current_filter)
if not loaded_filter[1]:
display_filter = loaded_filter[0]
library_cache = getattr(self, "_loaded_library_cache", {})
library_cache[display_filter] = games
self._loaded_library_cache = library_cache
focus_first_card = not getattr(self, "_preserve_library_focus_after_load", False)
self._preserve_library_focus_after_load = False
self.game_library_manager.set_games(games, focus_first_card=focus_first_card)
self._known_portproton_desktops = self._get_portproton_desktop_files()
# Clear the refresh in progress flag
if hasattr(self, '_refresh_in_progress'):
self._refresh_in_progress = False
@@ -913,6 +994,17 @@ class MainWindow(
self.refreshButton.setEnabled(True)
self._gamepad_tooltip_map[self.refreshButton] = _("Refresh Grid")
if loaded_filter != current_filter:
self._on_only_installed_changed(current_filter[1])
return
self.games = games
focus_first_card = not self.game_library_manager.games and not getattr(
self, "_preserve_library_focus_after_load", False
)
self._preserve_library_focus_after_load = False
self.game_library_manager.set_games(games, focus_first_card=focus_first_card)
self._known_portproton_desktops = self._get_portproton_desktop_files()
def loadGames(self, force_load: bool = False):
if self._loading_games:
return
@@ -926,6 +1018,7 @@ class MainWindow(
self._loading_games = True
display_filter = game_config.get_display_filter()
self._loading_library_filter = (display_filter, game_config.get_only_installed())
favorites = favorites_config.get_games()
self.pending_games = []
self.games = []
@@ -945,7 +1038,7 @@ class MainWindow(
self.games_loaded.emit(games)
# Load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
completed = dict.fromkeys(results, False)
def check_completion():
@@ -975,14 +1068,24 @@ class MainWindow(
completed['egs'] = True
check_completion()
def native_callback(games: list[tuple]) -> None:
results['native'] = games
completed['native'] = True
check_completion()
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)
self._load_native_games_async(native_callback)
else:
# For 'all' filter - load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
completed = dict.fromkeys(results, False)
# Load selected sources in parallel.
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
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()
@@ -1024,15 +1127,106 @@ 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)
def native_callback(games: list[tuple]) -> None:
results['native'] = games
completed['native'] = True
check_completion()
callbacks = {
"portproton": portproton_callback, "steam": steam_callback,
"gog": gog_callback, "egs": egs_callback, "native": native_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()
def _load_native_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
self._native_games_callback = callback
self.native_library_worker = NativeLibraryWorker()
self.native_library_worker.finished.connect(self._on_native_games_loaded)
self.native_library_worker.start()
def open_native_settings(self, exec_line: str) -> None:
desktop_path = exec_line.removeprefix(NATIVE_URI_PREFIX)
try:
dialog = ExeSettingsDialog(self, self.theme, desktop_path, game_source="native")
except OSError as error:
logger.warning("Cannot open native settings for %s: %s", desktop_path, error)
QMessageBox.warning(self, _("Error"), str(error))
return
dialog.exec()
dialog.deleteLater()
def _launch_native_game(self, exec_line: str, button: QPushButton | None = None) -> None:
desktop_path = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
if self.game_processes:
if getattr(self, "native_game_running", False) and self.game_start_exe == str(desktop_path):
self.stop_running_game(button)
else:
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
return
try:
if not desktop_path.is_absolute() or desktop_path.suffix != ".desktop":
raise ValueError("Invalid native desktop path")
command, directory = get_native_launch(str(desktop_path))
process = subprocess.Popen(command, cwd=directory, start_new_session=True, text=True)
except Exception as error:
logger.warning("Failed to launch native game %s: %s", desktop_path, error)
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(error))
return
self.native_game_running = True
self.game_stopped_by_user = False
self.game_processes = [process]
self.current_running_button = button
self.target_exe = desktop_path.name
self.game_start_exe = str(desktop_path)
self.game_start_exact_path = True
self.game_start_time = datetime.now()
self.game_launch_monotonic = time.monotonic()
self.game_launch_started = True
self.wine_download_seen = False
save_last_launch(desktop_path.stem, self.game_start_time)
self._update_last_launch_after_start(str(desktop_path), self.game_start_time)
self.launch_output_queue = Queue()
self.input_manager.suspend_gamepad_polling()
self._set_running_button_stop()
SoundManager().play("game_launch")
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(GAME_PROCESS_CHECK_INTERVAL_MS)
@Slot()
def _on_native_games_loaded(self) -> None:
worker = self.native_library_worker
if worker is None:
return
self.native_library_worker = None
games = worker.games
worker.deleteLater()
if not games:
self._native_games_callback([])
return
completed = []
def on_info(info: dict, game: tuple) -> None:
updated = list(game)
updated[1] = info.get("description", "") or game[1]
updated[2] = game[2] or info.get("cover", "")
updated[3] = info.get("appid", "")
updated[4] = info.get("controller_support", "")
completed.append(tuple(updated))
if len(completed) == len(games):
self._native_games_callback(completed)
for game in games:
if ui_config.get_economy_mode():
on_info(get_cached_steam_game_info(game[0], game[5]), game)
else:
get_steam_game_info_async(game[0], game[5], lambda info, game=game: on_info(info, game))
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
api = getattr(self, "gog_api", None) or GOGAPI()
installed = api.load_installed()
@@ -1040,6 +1234,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)
@@ -1103,6 +1302,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
@@ -1116,9 +1332,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", ""))
@@ -1293,6 +1519,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):
@@ -1331,7 +1560,7 @@ class MainWindow(
callback: Callable[[tuple | None], None],
):
entry = parse_desktop_entry(file_path)
if not entry:
if not entry or read_native_game(Path(file_path)) is not None:
callback(None)
return
desktop_name = entry.get("Name", _("Unknown Application"))
@@ -1673,11 +1902,13 @@ class MainWindow(
self.detail_page_manager.openGameDetailPage(game_data)
pending_log_exe = self._pending_log_exe
detail_exe = extract_exec_target_path(game_data.get("exec_line", ""))
if exec_line.startswith(("gog://launch/", "egs://launch/")):
if exec_line.startswith(NATIVE_URI_PREFIX):
detail_exe = exec_line
elif exec_line.startswith(("gog://launch/", "egs://launch/")):
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
detail_exe = getattr(self, f"{source}_api").get_launch_target(app_id)
if pending_log_exe and detail_exe:
if os.path.abspath(detail_exe) == pending_log_exe:
if detail_exe == pending_log_exe or os.path.abspath(detail_exe) == pending_log_exe:
self._pending_log_exe = None
log_button = self.detail_page_manager._debug_log_button
if log_button:
@@ -1994,7 +2225,7 @@ class MainWindow(
def checkTargetExe(self):
"""Update launch state from the marker and PortProton process."""
"""Update launch state and reset controls after the game exits."""
dependency_active = self._drain_launch_output_progress()
child_running = self._has_running_game_process()
@@ -2119,6 +2350,7 @@ class MainWindow(
self.game_processes = [] if launcher_only else [
proc for proc in self.game_processes if proc.poll() is None
]
self.native_game_running = False
if not getattr(self, "_animated_covers_suspended", False):
self.input_manager.resume_gamepad_polling()
self._refresh_portproton_shortcuts()
@@ -2213,6 +2445,8 @@ class MainWindow(
def _get_game_executable(self, exec_line: str) -> str:
"""Resolve a library launch entry to its tracked executable."""
if exec_line.startswith(NATIVE_URI_PREFIX):
return exec_line.removeprefix(NATIVE_URI_PREFIX)
if exec_line.startswith("gog://launch/"):
app_id = exec_line.rsplit("/", 1)[-1]
return str(self.gog_api.get_launch_target(app_id) or "")
@@ -2222,6 +2456,19 @@ class MainWindow(
return extract_exec_target_path(exec_line) or ""
def _has_running_game_process(self) -> bool:
if getattr(self, "native_game_running", False):
if any(process.poll() is None for process in self.game_processes):
return True
groups = {process.pid for process in self.game_processes}
for process in psutil.process_iter(attrs=["pid", "status"]):
try:
if process.info["status"] != psutil.STATUS_ZOMBIE and os.getpgid(process.pid) in groups:
return True
except ProcessLookupError:
continue
except PermissionError:
continue
return False
if self._observe_game_processes():
return True
process_alive = any(proc.poll() is None for proc in self.game_processes)
@@ -2343,6 +2590,8 @@ class MainWindow(
self._start_launch_output_reader(game_process)
def _analyze_short_launch(self) -> None:
if getattr(self, "native_game_running", False):
return
if not ui_config.get_crash_reports_enabled():
logger.info("Compatibility report skipped: crash reports disabled")
return
@@ -2404,20 +2653,22 @@ 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:
for proc in self.game_processes:
if proc.poll() is not None:
if proc.poll() is not None and not getattr(self, "native_game_running", False):
continue
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.kill()
os.killpg(proc.pid if getattr(self, "native_game_running", False) else os.getpgid(proc.pid), signal.SIGTERM)
if not getattr(self, "native_game_running", False):
proc.kill()
except ProcessLookupError:
continue
except OSError as e:
@@ -2436,11 +2687,14 @@ class MainWindow(
return True
def stop_running_game(self, button=None) -> bool:
"""Stop current game via PortProton CLI stop command."""
"""Stop the current game's owned processes and launcher."""
if button is not None:
self.current_running_button = button
self.game_stopped_by_user = True
self.egs_launch_cancelled = True
if getattr(self, "native_game_running", False):
self._terminate_game_processes()
return True
if getattr(self, "launcher_process_only", False):
if self._run_portproton_stop_command():
return True
@@ -2472,8 +2726,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"
@@ -2590,8 +2844,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()
@@ -2675,22 +2929,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")
)
@@ -2781,6 +3036,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:
@@ -2808,6 +3094,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,
@@ -2869,6 +3156,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)
@@ -2912,6 +3202,10 @@ class MainWindow(
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
def toggleGame(self, exec_line, button=None, game_name=None):
if exec_line.startswith(NATIVE_URI_PREFIX):
self._launch_native_game(exec_line, button)
return
# Handle Steam games
if exec_line.startswith("steam://"):
self._launch_steam_game(exec_line)
@@ -2974,6 +3268,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:
+3
View File
@@ -4,6 +4,7 @@ install_data(
'appimage_integration.py',
'appimage_updater.py',
'cli.py',
'boot_animation.py',
'context_menu_manager.py',
'custom_widgets.py',
'dialogs.py',
@@ -21,6 +22,7 @@ install_data(
'localization.py',
'logger.py',
'main_window.py',
'native_games.py',
'port_data_path_selector.py',
'portproton_api.py',
'preloader.py',
@@ -185,6 +187,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',
+511
View File
@@ -0,0 +1,511 @@
"""Discover installed native games from desktop application entries."""
import configparser
import platform
import os
import re
import shlex
import shutil
import tempfile
from pathlib import Path
from datetime import datetime
from PySide6.QtCore import QThread
from portprotonqt.config import LAUNCH_FILE_EXTENSIONS, WINDOWS_LAUNCH_EXTENSIONS, get_portproton_location, get_portproton_scripts_path, get_custom_data_dir_name
from portprotonqt.config.portproton import get_custom_icon_path, extract_exec_target_path, resolve_custom_data_dir, parse_desktop_entry
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.icon_extractor import get_exe_icon_cache_path
from portprotonqt.time_utils import (
get_statistics_path, parse_playtime_file, get_last_launch_path, _parse_last_launch_line,
format_last_launch, format_playtime,
)
logger = get_logger(__name__)
NATIVE_URI_PREFIX = "native://"
NATIVE_SETTINGS_SECTION = "X-PortProtonQt"
NATIVE_TOGGLE_KEYS = frozenset({"PW_USE_GAMEMODE", "PW_USE_RUNTIME", "PW_REDUCE_PULSE_LATENCY",
"PW_USE_GALLIUM_ZINK", "PW_USE_OBS_VKCAPTURE", "PW_USE_INHIBIT_SLEEP"})
NATIVE_ADVANCED_KEYS = frozenset({"LAUNCH_PARAMETERS", "PW_MESA_GL_VERSION_OVERRIDE", "PW_LOCALE_SELECT",
"PW_MESA_VK_WSI_PRESENT_MODE", "PW_WINE_CPU_TOPOLOGY"})
def is_native_executable(path: str) -> bool:
"""Recognize native launch files while preserving Windows and disc formats."""
return (
Path(path).suffix.lower() not in LAUNCH_FILE_EXTENSIONS
and os.path.isfile(path)
and os.access(path, os.X_OK)
)
def quote_native_arguments(arguments: list[str]) -> str:
"""Quote arguments using desktop entry escaping, without a shell."""
escaped = []
for argument in arguments:
value = argument.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
escaped.append('"' + value.replace("\\", "\\\\").replace("%", "%%") + '"')
return " ".join(escaped)
def build_native_shortcut_command(command: str, mode: str = "--silent") -> str:
"""Use the installed application launcher for native desktop shortcuts."""
flatpak_id = os.getenv("FLATPAK_ID")
appimage = os.getenv("APPIMAGE", "").strip()
launcher = ["portprotonqt"]
if flatpak_id:
launcher = ["flatpak", "run", flatpak_id]
elif appimage and os.path.isfile(appimage):
launcher = [appimage]
if mode:
launcher.append(mode)
return quote_native_arguments(launcher) + " " + command
def get_native_source_command(command: str) -> str:
"""Unwrap a PortProtonQt desktop command before applying native settings."""
parts = shlex.split(command.replace("\\\\", "\\"))
if "--silent" in parts and (parts[0] in {"portprotonqt", "flatpak", os.getenv("APPIMAGE", "").strip()} or parts[0].lower().endswith(".appimage")):
target = extract_exec_target_path(parts)
if target:
target = target.replace("\\$", "$").replace("\\`", "`").replace("%%", "%")
return quote_native_arguments([target])
return command
def get_native_tool_paths() -> dict[str, str]:
"""Locate the existing PortProton runtime and shader bundle without loading Wine."""
scripts = get_portproton_scripts_path()
location = get_portproton_location()
if not scripts or not location:
return {}
values = {}
try:
for line in (Path(scripts) / "var").read_text(encoding="utf-8").splitlines():
key, separator, value = line.removeprefix("export ").partition("=")
if separator and key in {"PW_LIBS_VER", "PW_PLUGINS_VER"}:
values[key] = value.strip().strip('"\'')
except OSError as error:
logger.warning("Cannot read native runtime versions: %s", error)
return {}
root = Path(location) / "data/tmp"
return {"runtime": str(root / ("libs" + values.get("PW_LIBS_VER", ""))),
"plugins": str(root / ("plugins" + values.get("PW_PLUGINS_VER", ""))),
"PW_PLUGINS_VER": values.get("PW_PLUGINS_VER", "")}
def read_native_variables(path: Path) -> dict[str, str]:
"""Read literal var/PPDB assignments without executing shell commands."""
values = {}
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip().startswith(("export ", "check_variables ")):
continue
parts = shlex.split(line, comments=True)
if not parts:
continue
if parts[0] == "check_variables" and len(parts) == 3:
values[parts[1]] = parts[2]
elif parts[0] == "export" and len(parts) == 2 and "=" in parts[1]:
key, value = parts[1].split("=", 1)
values[key] = value.replace("\\$", "$").replace("\\`", "`")
return values
def get_native_ppdb_path(desktop_path: str, settings: dict[str, str]) -> Path:
"""Keep native settings and logs in a named per-game user data directory."""
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
name = settings.get("Name", Path(desktop_path).stem).replace("/", "_").strip(".") or "native"
return data_home / "PortProtonQt/native_games" / name / (name + ".ppdb")
def get_native_defaults() -> dict[str, str]:
"""Read supported Linux defaults from the existing PortProton var file."""
values = dict.fromkeys(NATIVE_TOGGLE_KEYS | NATIVE_ADVANCED_KEYS, "0")
values.update(dict.fromkeys(NATIVE_ADVANCED_KEYS, "disabled"))
values.update({"LAUNCH_PARAMETERS": "", "PW_MANGOHUD": "0", "PW_MANGOHUD_USER_CONF": "0",
"PW_VKBASALT": "0", "PW_VKBASALT_USER_CONF": "0", "PW_GAMESCOPE": "0",
"MANGOHUD_CONFIG": "", "FPS_LIMIT": ""})
scripts = get_portproton_scripts_path()
if scripts and (Path(scripts) / "var").is_file():
defaults = read_native_variables(Path(scripts) / "var")
values.update({key: value for key, value in defaults.items()
if (key in values and key != "PW_USE_RUNTIME") or key.startswith(("PW_VKBASALT_", "PW_GAMESCOPE_"))})
values["MANGOHUD_CONFIG"] = ",".join(item for item in defaults.get("DEFAULT_MANGOHUD_CONFIG", "").split(",")
if item not in {"wine", "winesync"})
return values
def load_native_settings(desktop_path: str) -> dict[str, str]:
"""Load var defaults, existing desktop settings, and the game PPDB."""
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.optionxform = lambda optionstr: optionstr
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
settings = get_native_defaults()
if parser.has_section(NATIVE_SETTINGS_SECTION):
settings.update(parser[NATIVE_SETTINGS_SECTION])
settings["Path"] = parser["Desktop Entry"].get("Path", "").replace("\\\\", "\\")
database = Path(desktop_path + ".ppdb")
if database.is_file():
content = database.read_text(encoding="utf-8")
if content.lstrip().startswith("["):
parser.read_string(content)
settings.update(parser[NATIVE_SETTINGS_SECTION])
else:
settings.update(read_native_variables(database))
settings.setdefault("Name", parser["Desktop Entry"].get("Name", Path(desktop_path).stem))
settings.setdefault("Exec", parser["Desktop Entry"]["Exec"])
settings["Exec"] = get_native_source_command(settings["Exec"])
parts = shlex.split(settings["Exec"].replace("\\\\", "\\"))
executable = next((part.replace("%%", "%") for part in reversed(parts)
if os.path.isabs(part.replace("%%", "%")) and Path(part.replace("%%", "%")).is_file()), None)
if executable is None and parts:
executable = shutil.which(parts[0])
legacy_paths = [Path((executable or desktop_path) + ".ppdb")]
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
legacy_paths.append(data_home / "PortProtonQt/native_games" / (get_custom_data_dir_name(desktop_path) + ".ppdb"))
for legacy in legacy_paths:
if legacy.is_file():
settings.update(read_native_variables(legacy))
database = get_native_ppdb_path(desktop_path, settings)
if database.is_file():
settings.update(read_native_variables(database))
settings.setdefault("PW_PLUGINS_VER", get_native_tool_paths().get("PW_PLUGINS_VER", ""))
return settings
def build_native_runtime(settings: dict[str, str]) -> tuple[list[str], list[str]]:
"""Use PortProton's runtime and portable Linux tools without loading Wine."""
paths = get_native_tool_paths()
runtime = Path(paths.get("runtime", ""))
wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime"
if not wrapper.is_file() or not (runtime / "runtime/files").is_dir():
raise ValueError("PortProton container runtime is not installed")
environment = [f"PRESSURE_VESSEL_RUNTIME_BASE={runtime}", "PRESSURE_VESSEL_RUNTIME=runtime",
f"PRESSURE_VESSEL_VARIABLE_DIR={runtime / 'var'}", "PRESSURE_VESSEL_COPY_RUNTIME=1",
"PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1"]
if platform.freedesktop_os_release().get("ID") == "altlinux":
drivers = [str(path.resolve()) for path in (Path("/etc/libnvidiacurrent"), Path("/etc/libnvidia32current"))
if path.is_dir()]
if drivers:
environment.append("PRESSURE_VESSEL_APP_LD_LIBRARY_PATH=" + ":".join(filter(None, [os.getenv("LD_LIBRARY_PATH", ""), *drivers])))
environment.append("LIBGL_DRIVERS_PATH=/usr/lib/X11/modules/dri:/usr/lib64/X11/modules/dri")
command = [str(wrapper), "--share-home", "--remove-game-overlay", "--share-pid", "--", "env"]
portable = Path(paths.get("plugins", "")) / "portable"
command += [f"PATH={portable / 'bin'}:{os.environ.get('PATH', '')}"]
if settings.get("PW_MANGOHUD") == "1" or settings.get("PW_VKBASALT") == "1":
layers = portable / "share/vulkan/implicit_layer.d"
libraries = ":".join(str(portable / "lib" / arch) for arch in ("lib64", "lib32"))
command += [f"LD_LIBRARY_PATH={libraries}:/overrides/lib/x86_64-linux-gnu:/overrides/lib/i386-linux-gnu",
f"VK_ADD_LAYER_PATH={layers}", f"VK_ADD_IMPLICIT_LAYER_PATH={layers}"]
environment.remove("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1")
environment.append("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=0")
if settings.get("PW_MANGOHUD") == "1":
if not (portable / "lib/lib64/libMangoHud_shim.so").is_file():
raise ValueError("PortProton portable MangoHud is not installed")
preload = ":".join(filter(None, [os.getenv("LD_PRELOAD", ""), "libMangoHud_shim.so"]))
command += ["MANGOHUD=1", f"LD_PRELOAD={preload}"]
return command, environment
def build_native_launch_command(command: str, settings: dict[str, str]) -> str:
"""Compose Linux launch wrappers and environment for a desktop entry."""
prefix = []
environment = []
for key, variable in (("PW_REDUCE_PULSE_LATENCY", "PULSE_LATENCY_MSEC=60"),
("PW_USE_GALLIUM_ZINK", "MESA_LOADER_DRIVER_OVERRIDE=zink"),
("PW_USE_OBS_VKCAPTURE", "OBS_VKCAPTURE=1")):
if settings.get(key) == "1":
environment.append(variable)
for key, variable in (("PW_LOCALE_SELECT", "LC_ALL"), ("PW_MESA_GL_VERSION_OVERRIDE", "MESA_GL_VERSION_OVERRIDE"),
("PW_MESA_VK_WSI_PRESENT_MODE", "MESA_VK_WSI_PRESENT_MODE")):
if settings.get(key) not in (None, "", "disabled"):
environment.append(f"{variable}={settings[key]}")
if settings.get("PW_USE_INHIBIT_SLEEP") == "1":
prefix += ["systemd-inhibit", "--what=idle:sleep", "--why=Native game", "--"]
if settings.get("PW_GAMESCOPE") == "1":
prefix += ["gamescope", *shlex.split(settings.get("PW_GAMESCOPE_ARGS_NEW", "")), "--"]
if settings.get("PW_USE_RUNTIME") == "1":
runtime_command, runtime_env = build_native_runtime(settings)
prefix += runtime_command
environment += runtime_env
cores = settings.get("PW_WINE_CPU_TOPOLOGY", "disabled")
if cores not in ("", "disabled"):
available = sorted(os.sched_getaffinity(0))
count = int(cores)
if not 1 <= count <= len(available):
raise ValueError("Invalid CPU cores limit")
prefix += ["taskset", "-c", ",".join(str(cpu) for cpu in available[:count])]
if settings.get("PW_USE_GAMEMODE") == "1":
prefix.append("gamemoderun")
if settings.get("PW_MANGOHUD") == "1":
if settings.get("PW_USE_RUNTIME") != "1":
prefix += ["mangohud", "--dlsym"]
if settings.get("PW_MANGOHUD_USER_CONF") != "1":
config = settings.get("MANGOHUD_CONFIG", "")
if settings.get("FPS_LIMIT"):
config += f",fps_limit={settings['FPS_LIMIT']}"
environment.append(f"MANGOHUD_CONFIG={config.strip(',')}")
if settings.get("PW_VKBASALT") == "1":
environment.append("ENABLE_VKBASALT=1")
if settings.get("VKBASALT_CONFIG_FILE"):
environment.append(f"VKBASALT_CONFIG_FILE={settings['VKBASALT_CONFIG_FILE']}")
if environment:
prefix = ["env", *environment, *prefix]
suffix = quote_native_arguments(shlex.split(settings.get("LAUNCH_PARAMETERS", "")))
return " ".join(part for part in (quote_native_arguments(prefix), command, suffix) if part)
def write_native_vkbasalt_config(desktop_path: str, settings: dict[str, str]) -> None:
"""Keep native vkBasalt configuration isolated from global settings."""
if settings.get("PW_VKBASALT") != "1" or settings.get("PW_VKBASALT_USER_CONF") == "1":
settings.pop("VKBASALT_CONFIG_FILE", None)
return
config = get_native_ppdb_path(desktop_path, settings).with_suffix(".vkbasalt.conf")
plugins = Path(get_native_tool_paths().get("plugins", "")) / "reshade"
lines = [f"effects = {settings.get('PW_VKBASALT_EFFECTS', 'cas')}",
f"casSharpness = {settings.get('PW_VKBASALT_FFX_CAS', '0.50')}",
f"toggleKey = {settings.get('PW_VKBASALT_TOOGLE_KEY', 'Home')}",
f"reshadeTexturePath = {plugins / 'textures'}", f"reshadeIncludePath = {plugins}"]
for shader in settings.get("PW_VKBASALT_EFFECTS", "").split(":"):
if shader and shader != "cas":
if Path(shader).name != shader:
raise ValueError("Invalid vkBasalt shader name")
shader_path = plugins / "shaders" / (shader + ".fx")
if not shader_path.is_file():
raise ValueError(f"vkBasalt shader is not installed: {shader}")
lines.append(f"{shader} = {shader_path}")
config.write_text("\n".join(lines) + "\n", encoding="utf-8")
settings["VKBASALT_CONFIG_FILE"] = str(config)
def save_native_settings(desktop_path: str, changes: dict[str, str]) -> None:
"""Save native settings only to PPDB, leaving installed launchers unchanged."""
settings = load_native_settings(desktop_path)
settings.update(changes)
settings["Exec"] = get_native_source_command(settings["Exec"])
command, directory = settings["Exec"], settings.get("Path", "")
if not shlex.split(command.replace("\\\\", "\\")) or any(char in "".join(settings.values()) for char in "\n\r\x00"):
raise ValueError("Invalid native launch command or directory")
if directory and (not os.path.isabs(directory) or not os.path.isdir(directory)):
raise ValueError("Working directory must be an existing absolute path")
database = get_native_ppdb_path(desktop_path, settings)
database.parent.mkdir(parents=True, exist_ok=True)
write_native_vkbasalt_config(desktop_path, settings)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=database.parent, delete=False) as output:
temporary = Path(output.name)
for key, value in sorted(settings.items()):
if key in {"Name", "Icon", "Exec", "Path"}:
continue
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
output.write(f'export {key}="{escaped}"\n')
os.replace(temporary, database)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def create_native_desktop_file(executable: str, name: str, icon: str = "") -> tuple[str, str] | None:
"""Build a native launcher through PortProtonQt with the game's directory."""
path = Path(executable).expanduser().absolute()
if not path.is_file() or not os.access(path, os.X_OK) or path.suffix.lower() in WINDOWS_LAUNCH_EXTENSIONS:
logger.warning("Not an executable native game: %s", path)
return None
if not name.strip() or any(char in str(path) + icon for char in "\n\r\x00"):
logger.warning("Invalid native game name or path")
return None
name = " ".join(name.splitlines()).strip()
# Desktop Exec uses double quoting and doubles literal percent signs.
command = build_native_shortcut_command(quote_native_arguments([str(path)]))
directory = str(path.parent).replace("\\", "\\\\")
icon = get_custom_icon_path(str(path)) or icon
if not icon and path.suffix.lower() == ".appimage":
icon = get_exe_icon_cache_path(str(path))
icon = icon.replace("\\", "\\\\") if os.path.isfile(icon) else "applications-games"
comment = _('Launch "{name}" with PortProton').format(name=name)
content = (f"[Desktop Entry]\nType=Application\nName={name}\nComment={comment}\nExec={command}\n"
f"Path={directory}\nIcon={icon}\nTerminal=false\nCategories=Game;\nStartupNotify=true\nActions=RunSilent;RunLog;\n")
for action, label, mode in (("RunSilent", _("Run in silent mode"), "--silent"),
("RunLog", _("Run in logging mode"), "--log")):
action_command = build_native_shortcut_command(quote_native_arguments([str(path)]), mode)
content += f"\n[Desktop Action {action}]\nName={label}\nExec={action_command}\nIcon={icon}\n"
location = get_portproton_location()
if not location:
logger.warning("PortProton data directory is unavailable")
return None
filename = name.replace("/", "_") + ".desktop"
destination = Path(location) / filename
try:
destination.parent.mkdir(parents=True, exist_ok=True)
except OSError as error:
logger.warning("Cannot create native library directory: %s", error)
return None
return content, str(destination)
def read_native_game(path: Path) -> tuple | None:
"""Read a game entry without modifying the installed desktop file."""
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
parser.read_string(path.read_text(encoding="utf-8"))
if not parser.has_section("Desktop Entry"):
return None
entry = parser["Desktop Entry"]
if entry.get("Type") != "Application" or "Game" not in entry.get("Categories", "").split(";"):
return None
if any(entry.get(key, "false").lower() == "true" for key in ("Hidden", "NoDisplay")):
return None
if any(mime.startswith("x-scheme-handler/") for mime in entry.get("MimeType", "").split(";")):
return None
name, command = entry.get("Name", ""), entry.get("Exec", "")
parts = shlex.split(command.replace("\\\\", "\\"))
if not name or not parts:
return None
if any(part.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS)
or part.lower().startswith(("steam://", "gog://", "egs://")) for part in parts):
return None
if entry.get("TryExec") and not shutil.which(entry["TryExec"]):
return None
original = parser[NATIVE_SETTINGS_SECTION].get("Exec", command) if parser.has_section(NATIVE_SETTINGS_SECTION) else command
executable = extract_exec_target_path(shlex.split(original.replace("\\\\", "\\"))) or ""
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
root = str(data_home / "PortProtonQt/custom_data")
custom = Path(resolve_custom_data_dir(root, str(path)))
if not custom.is_dir() and executable:
custom = Path(resolve_custom_data_dir(root, executable))
description = entry.get("Comment", "")
metadata = custom / "metadata.txt"
if metadata.is_file():
translations = read_metadata_translations(str(metadata), get_metadata_language())
name = translations.get("name") or name
description = translations.get("description") or description
cover = next((str(path) for path in custom.glob("cover.*") if path.is_file()), "")
if not cover:
from portprotonqt.steam_api.api import get_cached_sgdb_cover
cover = get_cached_sgdb_cover(name)
if not cover and executable.lower().endswith(".appimage"):
cached_icon = get_exe_icon_cache_path(executable)
if os.path.isfile(cached_icon):
cover = cached_icon
return (name, description, cover,
"", "", NATIVE_URI_PREFIX + str(path), _("Never"), "", "", "", 0, 0,
"native", "", "", "")
except configparser.Error as error:
logger.warning("Invalid native desktop entry %s: %s", path, error)
except OSError as error:
logger.warning("Cannot read native desktop entry %s: %s", path, error)
except UnicodeError as error:
logger.warning("Invalid native desktop encoding %s: %s", path, error)
except ValueError as error:
logger.warning("Invalid native desktop command %s: %s", path, error)
return None
def get_native_launch(desktop_path: str) -> tuple[list[str], str]:
"""Resolve desktop field codes into a direct Linux process command."""
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
entry = parser["Desktop Entry"]
settings = load_native_settings(desktop_path)
entry["Exec"] = settings["Exec"]
if get_native_ppdb_path(desktop_path, settings).is_file():
entry["Exec"] = build_native_launch_command(settings["Exec"], settings)
entry["Path"] = settings.get("Path", "").replace("\\", "\\\\")
escapes = {"\\": "\\", "s": " ", "n": "\n", "t": "\t", "r": "\r"}
command = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Exec", ""))
arguments = []
for part in shlex.split(command):
part = part.replace("\\$", "$").replace("\\`", "`")
if part == "%i":
if entry.get("Icon"):
arguments.extend(["--icon", entry["Icon"]])
continue
value = re.sub(r"%%|%[fFuUdDnNvmick]", lambda match:
{"%%": "%", "%c": entry.get("Name", ""), "%k": desktop_path}.get(match[0], ""), part)
if value:
arguments.append(value)
if not arguments:
raise ValueError("Empty native launch command")
directory = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Path", ""))
if directory and not os.path.isdir(directory):
raise ValueError(f"Working directory does not exist: {directory}")
executable = next((part for part in reversed(arguments)
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), arguments[0])
fallback = Path(executable).parent if os.path.isabs(executable) else Path.home()
return arguments, directory or str(fallback)
def load_native_game_times(games: list[tuple]) -> list[tuple]:
"""Read shared statistics once for the native library batch."""
statistics, launches = {}, {}
try:
path = get_statistics_path()
if os.path.isfile(path):
statistics = parse_playtime_file(path)
path = get_last_launch_path()
if os.path.isfile(path):
launches = dict(parsed for line in Path(path).read_text(encoding="utf-8").splitlines()
if (parsed := _parse_last_launch_line(line)))
except OSError as error:
logger.warning("Cannot read native game statistics: %s", error)
updated_games = []
for game in games:
path = Path(game[5].removeprefix(NATIVE_URI_PREFIX))
updated = list(game)
updated[11] = statistics.get(str(path).replace(" ", "#@_@#"), 0)
updated[7] = format_playtime(updated[11])
if path.stem in launches:
try:
launched = datetime.fromisoformat(launches[path.stem])
updated[6], updated[10] = format_last_launch(launched), launched.timestamp()
except ValueError as error:
logger.warning("Invalid native last launch time for %s: %s", path, error)
updated_games.append(tuple(updated))
return updated_games
class NativeLibraryWorker(QThread):
"""Scan application directories outside the UI thread."""
def __init__(self) -> None:
super().__init__()
self.games: list[tuple] = []
def run(self) -> None:
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(os.pathsep)
roots = [data_home, *(Path(directory) for directory in data_dirs if directory),
data_home / "flatpak/exports/share", Path("/var/lib/flatpak/exports/share")]
directories = [root / "applications" for root in roots]
directories.insert(0, data_home / "PortProtonQt/native_games")
location = get_portproton_location()
if location:
directories.insert(0, Path(location))
seen: set[str] = set()
commands: set[tuple[str, str, str]] = set()
for applications in directories:
try:
for path in sorted(applications.glob("*.desktop") if location and applications == Path(location)
else applications.rglob("*.desktop")):
if self.isInterruptionRequested():
return
desktop_id = str(path.relative_to(applications)).replace(os.sep, "-")
if desktop_id in seen:
continue
seen.add(desktop_id)
game = read_native_game(path)
if not game:
continue
entry = parse_desktop_entry(str(path)) or {}
command = get_native_source_command(entry.get("Exec", ""))
identity = (command, entry.get("Path", ""), entry.get("Name", ""))
if identity in commands:
continue
commands.add(identity)
self.games.append(game)
except OSError as error:
logger.warning("Cannot scan native games in %s: %s", applications, error)
self.games = load_native_game_times(self.games)
+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())
+19 -9
View File
@@ -5,6 +5,7 @@ import sys
from pathlib import Path
from PySide6.QtCore import QCoreApplication, QUrl, qInstallMessageHandler
from PySide6.QtWidgets import QApplication
from portprotonqt.config import ui_config
from portprotonqt.logger import get_logger
@@ -94,13 +95,7 @@ class SoundManager:
self._initialized = True
self._enabled = ui_config.get_sounds_enabled()
self._sounds_dirs: list[str] = []
try:
if QCoreApplication.instance() is None or not _audio_service_available():
raise RuntimeError("audio service is unavailable")
self._slots = [_SoundSlot() for _ in range(4)]
except Exception as e:
logger.warning("UI sounds are unavailable: %s", e)
self._slots = []
self._slots: list[_SoundSlot] | None = None
self._slot_index = 0
self._url_cache: dict[str, QUrl] = {}
@@ -108,7 +103,7 @@ class SoundManager:
if dirs != self._sounds_dirs:
self._sounds_dirs = dirs
self._url_cache.clear()
for slot in self._slots:
for slot in self._slots or []:
slot.reset()
def reload_config(self) -> None:
@@ -136,8 +131,23 @@ class SoundManager:
def play(self, event: str) -> None:
if not self._enabled:
return
if any(
getattr(window, "silent_launch_mode", False) and not window.isVisible()
for window in QApplication.topLevelWidgets()
):
return
url = self._get_url(event)
if url is None or not self._slots:
if url is None:
return
if self._slots is None:
try:
if QCoreApplication.instance() is None or not _audio_service_available():
raise RuntimeError("audio service is unavailable")
self._slots = [_SoundSlot() for _ in range(4)]
except Exception as e:
logger.warning("UI sounds are unavailable: %s", e)
self._slots = []
if not self._slots:
return
try:
for slot in self._slots:
+102 -16
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):
@@ -553,6 +575,23 @@ def get_cached_full_steam_game_info(appid: int, fallback_name: str = "") -> dict
)
def get_cached_sgdb_cover(game_name: str) -> str:
"""Read a cached SGDB grid URL without creating a download worker."""
cache = CacheManager()
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
key = f"sgdb_{safe_name}_grids"
if not cache.is_fresh(key, SGDB_CACHE_DURATION):
return ""
cover = cache.load_json(key)
if not isinstance(cover, str):
return ""
from portprotonqt.portproton_api import get_user_conf_setting
if get_user_conf_setting("MIRROR") == "CLOUD":
cover = cover.replace("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/", 1)
return cover
def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
"""Return Steam metadata from local cache only."""
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
@@ -560,7 +599,8 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
game_exe = extract_exec_target_path(exec_line) or exec_line
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
names = [desktop_name] if exec_line.startswith("native://") else [desktop_name, exe_name, folder_name]
candidates = remove_duplicates(filter_candidates(names))
apps_path = CacheManager().cache_dir / "steam_apps.json"
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
if not steam_apps:
@@ -623,8 +663,22 @@ 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://"))
is_native = exec_line.startswith("native://")
if game_exe.lower().endswith(('.bat', '.cmd')):
if os.path.exists(game_exe):
@@ -646,7 +700,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 or is_native:
meta_data = {}
elif not game_exe.lower().endswith('.exe'):
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
@@ -670,9 +724,9 @@ def get_steam_game_info_async(
candidates.append(file_description)
if desktop_name:
candidates.append(desktop_name)
if exe_name and not is_store_uri:
if exe_name and not (is_store_uri or is_native):
candidates.append(exe_name)
if folder_name and not is_store_uri:
if folder_name and not (is_store_uri or is_native):
candidates.append(folder_name)
candidates = filter_candidates(candidates)
candidates = remove_duplicates(candidates)
@@ -685,10 +739,13 @@ 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
if is_native:
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
return
def on_sgdb_cover_failure(cover: str) -> None:
result = _build_game_info_result(
@@ -716,15 +773,20 @@ 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
game_name = desktop_name or exe_name.capitalize()
if not matching_app:
if is_native:
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
return
def on_sgdb_cover_non_steam(cover: str) -> None:
def on_anticheat_info_non_steam(anticheat_info: dict) -> None:
result = _build_game_info_result(
@@ -749,18 +811,39 @@ 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 is_native:
info = app_info or {}
result = {"appid": appid, "name": info.get("name", game_name),
"description": info.get("short_description", ""),
"controller_support": info.get("controller_support", "")}
def on_native_cover(cover: str) -> None:
result["cover"] = cover
callback(result)
local_cover = get_local_steam_cover(appid, prefer_exact=False)
if local_cover:
on_native_cover(local_cover)
else:
fetch_sgdb_cover_async(result["name"], on_native_cover)
return
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 +880,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
+96 -15
View File
@@ -1,19 +1,26 @@
"""Steam shortcuts management for PortProtonQt."""
import base64
import glob
import os
import random
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 PySide6.QtGui import QIcon, QImageReader
from PySide6.QtCore import QStandardPaths
from portprotonqt.config.portproton import get_custom_icon_path, parse_desktop_entry
from portprotonqt.logger import get_logger
from portprotonqt.downloader import Downloader
from portprotonqt.icon_extractor import generate_thumbnail
@@ -33,6 +40,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__)
@@ -167,7 +175,7 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
def _parse_exec_line(exec_line: str) -> tuple[str | None, str]:
"""Parse exec_line to extract executable path."""
try:
exe_path = extract_exec_target_path(exec_line)
exe_path = exec_line if exec_line.startswith("native://") else extract_exec_target_path(exec_line)
if not exe_path:
logger.error("Failed to parse exec_line: %s", exec_line)
return None, "Failed to parse executable command: no target path"
@@ -220,6 +228,33 @@ export LD_PRELOAD=
return script_path, ""
def _resolve_desktop_icon(source: str) -> QIcon:
"""Resolve a desktop icon through Qt, then standard XDG icon directories."""
if not source:
return QIcon()
if os.path.isfile(source):
return QIcon(source)
icon = QIcon.fromTheme(source)
if not icon.isNull() or os.path.basename(source) != source:
return icon
formats = {bytes(value.data()).decode("ascii") for value in QImageReader.supportedImageFormats()}
filename = glob.escape(source)
if Path(source).suffix.lstrip(".").lower() not in formats:
filename += ".*"
roots = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.GenericDataLocation)
patterns = (f"icons/hicolor/*/*/{filename}", f"icons/*/*/*/{filename}",
f"icons/{filename}", f"pixmaps/{filename}")
for root in roots:
for pattern in patterns:
for path in sorted(Path(root).glob(pattern)):
if not path.is_file() or path.suffix.lstrip(".").lower() not in formats:
continue
icon = QIcon(str(path))
if not icon.isNull():
return icon
return QIcon()
def _generate_icon(exe_path: str, portproton_dir: str) -> str:
"""Generate thumbnail icon for the game."""
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', os.path.basename(exe_path))
@@ -232,7 +267,13 @@ def _generate_icon(exe_path: str, portproton_dir: str) -> str:
if os.path.exists(generated_icon_path):
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if exe_path.endswith(".desktop"):
entry = parse_desktop_entry(exe_path) or {}
source = entry.get("Icon", "")
icon = _resolve_desktop_icon(source)
success = icon.pixmap(128, 128).save(generated_icon_path)
else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if not success or not os.path.exists(generated_icon_path):
logger.warning(f"Failed to generate thumbnail for {exe_path}")
return ""
@@ -328,11 +369,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 +391,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 +400,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 +460,9 @@ 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/"))
local_path = exe_path.removeprefix("native://")
if not is_store_uri and not os.path.exists(local_path):
logger.error(f"Executable not found: {exe_path}")
return (False, f"Executable file not found: {exe_path}")
@@ -396,7 +476,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(local_path)
icon_source = custom_icon or icon_source or (None if is_store_uri else local_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 +553,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 +569,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):
+3 -1
View File
@@ -9,7 +9,7 @@ from typing import BinaryIO
from PIL import Image, UnidentifiedImageError
import vdf
from portprotonqt.config import game_config
from portprotonqt.config import game_config, ui_config
from portprotonqt.logger import get_logger
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
@@ -349,6 +349,8 @@ def _is_steam_proton_dir(path: Path) -> bool:
def get_steam_proton_versions() -> list[str]:
"""Return Steam Proton install directories usable by PortProton."""
if ui_config.get_disable_steam_proton_scanning():
return []
roots = set()
for steam_home in _iter_existing_steam_data_dirs():
roots.add(steam_home / "compatibilitytools.d")
+119 -5
View File
@@ -1,7 +1,8 @@
from collections.abc import Callable
import os
from typing import TYPE_CHECKING, Any, cast
from PySide6.QtCore import Qt, QTimer
from PySide6.QtCore import Qt, QTimer, QThread, Signal
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QGridLayout,
@@ -20,6 +21,7 @@ from portprotonqt.config import ui_config
from portprotonqt.context_menu_manager import CustomLineEdit
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton, FlowLayout
from portprotonqt.game_card import GameCard
from portprotonqt.detail_pages.utils import check_autoinstall_installed
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
from portprotonqt.search_utils import SearchOptimizer, build_search_items, search_index
@@ -34,6 +36,34 @@ else:
_MainWindowTypingBase = object
class AutoInstallStatusWorker(QThread):
status_ready = Signal(str, object)
api: Any
games: list[tuple]
portproton_location: str | None
def run(self) -> None:
for game in self.games:
if self.isInterruptionRequested():
return
target = game[5][len("autoinstall:"):].strip()
try:
script = target
if target.startswith(("http://", "https://")):
script = self.api._get_autoinstall_script_path(target)
if not os.path.isfile(script):
script = self.api.download_autoinstall_script(target)
status = None
if script and os.path.isfile(script):
status = check_autoinstall_installed(
script, game[0], self.portproton_location
)
self.status_ready.emit(game[13], status)
except OSError as error:
logger.warning("Failed to check installation of %s: %s", game[0], error)
self.status_ready.emit(game[13], None)
class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
if TYPE_CHECKING:
autoInstallCustomDataThread: Any
@@ -109,6 +139,30 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
self._set_autoinstall_container_layout(auto_layout_mode)
self.autoInstallScrollArea.setWidget(self.autoInstallContainer)
self.autoInstallStatusHeader = QWidget()
header_config = self.theme.GAME_CARD_VERTICAL
self.autoInstallStatusHeader.setFixedHeight(header_config["header_height"])
self.autoInstallStatusHeader.setProperty("theme_style_name", "LIBRARY_HEADER_STYLE")
self.autoInstallStatusHeader.setStyleSheet(self.theme.LIBRARY_HEADER_STYLE)
header_layout = QHBoxLayout(self.autoInstallStatusHeader)
header_layout.setContentsMargins(*header_config["header_margins"])
header_layout.setSpacing(header_config["header_spacing"])
header_layout.addSpacing(header_config["header_cover_width"] + header_config["header_spacing"])
for text, stretch in zip(
(_("Game Title"), _("Library"), _("Installed")),
self.theme.autoinstallColumnStretches, strict=True,
):
heading = QLabel(text.upper())
heading.setAlignment(
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
if text == _("Game Title") else Qt.AlignmentFlag.AlignCenter
)
heading.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
heading.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
header_layout.addWidget(heading, stretch)
header_layout.addSpacing(self.autoInstallScrollArea.verticalScrollBar().sizeHint().width())
self.autoInstallStatusHeader.setVisible(auto_layout_mode in {"list", "vertical"})
autoInstallLayout.addWidget(self.autoInstallStatusHeader)
autoInstallLayout.addWidget(self.autoInstallScrollArea)
self.auto_size_slider = QSlider(Qt.Orientation.Horizontal, autoInstallPage)
@@ -137,6 +191,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
# Store cards
self.autoInstallGameCards = {}
self.autoInstallStatusLabels = {}
self.allAutoInstallCards = []
self.autoInstallSearchOptimizer = SearchOptimizer()
self.autoInstallLoaded = False
@@ -151,7 +206,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
)
list_layout = auto_layout_mode in {"list", "vertical"}
self.autoInstallContainer.setProperty(
"library_layout_mode", auto_layout_mode
"library_layout_mode", "vertical" if list_layout else auto_layout_mode
)
# Clear
@@ -163,6 +218,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
widget.deleteLater()
self.autoInstallGameCards.clear()
self.autoInstallStatusLabels.clear()
self.allAutoInstallCards.clear()
if not games:
@@ -208,6 +264,24 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
parent=self.autoInstallContainer,
)
card.autoinstall_exe_name = exe_name
if list_layout:
while card.layout_.count() > 2:
item = card.layout_.takeAt(2)
widget = item.widget() if item else None
if widget:
widget.deleteLater()
stretches = self.theme.autoinstallColumnStretches
card.layout_.setStretch(1, stretches[0])
source_label = QLabel(str(game_source), card)
status_label = QLabel("…", card)
for label, stretch in zip(
(source_label, status_label), stretches[1:], strict=True,
):
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setProperty("theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE")
label.setStyleSheet(self.theme.GAME_CARD_COLUMN_LABEL_STYLE)
card.layout_.addWidget(label, stretch)
self.autoInstallStatusLabels[exe_name] = status_label
card.hoverChanged.connect(self._on_autoinstall_card_active)
card.focusChanged.connect(self._on_autoinstall_card_active)
@@ -231,11 +305,39 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
self.autoInstallContainer.updateGeometry()
self.autoInstallScrollArea.updateGeometry()
self.filterAutoInstallGames()
if list_layout:
self._load_autoinstall_statuses(games)
self._on_autoinstall_games_loaded = on_autoinstall_games_loaded
self.stackedWidget.addWidget(autoInstallPage)
def _load_autoinstall_statuses(self, games: list[tuple]) -> None:
workers = getattr(self, "autoInstallStatusWorkers", [])
for previous in workers:
previous.requestInterruption()
self.autoInstallStatusWorkers = workers
cards = self.autoInstallGameCards.copy()
labels = self.autoInstallStatusLabels.copy()
def on_status(exe_name: str, installed: bool | None) -> None:
card = cards.get(exe_name)
if card is None or self.autoInstallGameCards.get(exe_name) is not card:
return
labels[exe_name].setText(
"?" if installed is None else "✓" if installed else "—"
)
worker = AutoInstallStatusWorker(self)
worker.api = self.portproton_api
worker.games = games
worker.portproton_location = self.portproton_location
worker.status_ready.connect(on_status)
worker.finished.connect(lambda: workers.remove(worker) if worker in workers else None)
worker.finished.connect(worker.deleteLater)
workers.append(worker)
worker.start()
def _on_autoinstall_card_active(self, game_name: str, active: bool) -> None:
if not active:
return
@@ -255,8 +357,10 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
if item and item.widget():
item.widget().deleteLater()
QWidget().setLayout(old_layout)
self.autoInstallContainer.setProperty("library_layout_mode", mode)
if mode == "vertical":
self.autoInstallContainer.setProperty(
"library_layout_mode", "vertical" if mode == "list" else mode
)
if mode in {"list", "vertical"}:
config = self.theme.GAME_CARD_VERTICAL
layout = QVBoxLayout()
layout.setContentsMargins(*config.get("layout_margins", (0, 0, 0, 0)))
@@ -279,7 +383,8 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
horizontal = mode in {"horizontal", "horizontal_top"}
vertical_policy = (
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
if horizontal else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if mode in {"list", "vertical"} else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
horizontal_policy = (
Qt.ScrollBarPolicy.ScrollBarAsNeeded
@@ -292,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"))
)
@@ -299,6 +409,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
"list", "vertical", "horizontal", "horizontal_top",
}
self._set_autoinstall_container_layout(mode)
self.autoInstallStatusHeader.setVisible(mode in {"list", "vertical"})
self.auto_size_slider.setVisible(not fixed_layout)
if fixed_layout:
self.auto_card_width = self.auto_size_slider.maximum()
@@ -381,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"):
+6 -2
View File
@@ -10,7 +10,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedWidget, QWidget
from portprotonqt.config import ui_config
from portprotonqt.custom_widgets import FlowLayout
from portprotonqt.input_manager import BUTTONS, GamepadType
from portprotonqt.localization import _
from portprotonqt.localization import _, retranslate
from portprotonqt.logger import get_logger
from portprotonqt.qt_utils import get_device_pixel_ratio
@@ -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"),
@@ -476,6 +477,7 @@ class MainWindowControlHintsMixin:
current_tab_index,
system_section_index,
getattr(self, "current_theme_name", ""),
ui_config.get_force_english(),
)
if not force_update and hints_state == getattr(self, "_last_control_hints_state", None):
return
@@ -601,7 +603,9 @@ class MainWindowControlHintsMixin:
if not default_texts:
return
for action, text in default_texts.items():
self._setGamepadHintText(action, text)
if action in VOLUME_HINT_ACTIONS:
text = _("Volume") + text[-2:]
self._setGamepadHintText(action, retranslate(text))
for action in VOLUME_HINT_ACTIONS:
self._setGamepadHintVisible(action, False)
+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:
+178 -43
View File
@@ -1,5 +1,7 @@
import os
import re
import shutil
from pathlib import Path
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
@@ -13,6 +15,7 @@ from PySide6.QtWidgets import (
QHBoxLayout,
QLineEdit,
QMessageBox,
QProgressDialog,
QSizePolicy,
QToolButton,
QVBoxLayout,
@@ -39,9 +42,11 @@ from portprotonqt.dialogs.proton_manager import (
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.native_games import NATIVE_URI_PREFIX, save_native_settings, is_native_executable, read_native_game
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__)
@@ -78,7 +83,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
combo.view().window().setAttribute(
Qt.WidgetAttribute.WA_TranslucentBackground
)
combo.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
combo.addItems(labels)
combo.setStyleSheet(self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE)
combo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -95,16 +100,30 @@ 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")
display_filter not in ("steam", "portproton", "native")
)
self._position_library_controls_widget()
self.searchEdit.clear()
self.games = []
self._preserve_library_focus_after_load = True
self.loadGames(force_load=True)
self._on_only_installed_changed(game_config.get_only_installed())
def _on_only_installed_changed(self, checked: bool) -> None:
game_config.set_only_installed(checked)
@@ -153,9 +172,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)
)
@@ -338,35 +365,52 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
display_filter = game_config.get_display_filter()
only_installed = game_config.get_only_installed()
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "native", "favorites"]
self.filter_labels = [
_("All"),
"Steam",
"GOG",
"Epic Games",
"PortProton",
_("Native"),
_("Favorites"),
]
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(_("Only Installed"))
self.onlyInstalledCheckBox = QCheckBox(_("Show only installed games"))
self.onlyInstalledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.onlyInstalledCheckBox.setChecked(only_installed)
self.onlyInstalledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self._register_gamepad_tooltip(self.onlyInstalledCheckBox, _("Only Installed"))
self._register_gamepad_tooltip(self.onlyInstalledCheckBox, _("Show only installed games"))
self.onlyInstalledCheckBox.toggled.connect(self._on_only_installed_changed)
controls_layout.addWidget(self.gamesDisplayCombo, 0, 1)
controls_layout.addWidget(self.onlyInstalledCheckBox, 1, 1, 1, 2)
controls_layout.addWidget(self.gamesDisplayCombo, 1, 0)
controls_layout.addWidget(self.onlyInstalledCheckBox, 4, 0)
self.onlyInstalledCheckBox.setVisible(
display_filter not in ("steam", "portproton")
display_filter not in ("steam", "portproton", "native")
)
self.badge_view_keys = ["detailed", "compact", "hidden"]
@@ -382,19 +426,19 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
self.gamesBadgeViewCombo.setEnabled(False)
self.gamesBadgeViewCombo.currentIndexChanged.connect(self._on_library_badge_view_changed)
self.gamesBadgeViewCombo.activated.connect(self._delay_library_controls_hover_close)
controls_layout.addWidget(self.gamesBadgeViewCombo, 0, 2)
controls_layout.addWidget(self.gamesBadgeViewCombo, 2, 0)
self.library_layout_keys = [
"theme", "grid", "list", "vertical", "horizontal",
"horizontal_vertical", "horizontal_top", "horizontal_top_vertical",
"theme", "grid", "list", "vertical", "horizontal_top",
"horizontal_top_vertical", "horizontal", "horizontal_vertical",
]
self.library_layout_labels = [
_("Theme"), _("Grid"), _("List"), _("Table"),
_("Theme"), _("Grid"), _("List"), _("Vertical"),
_("Horizontal Blocks"), _("Horizontal Cards"),
_("Horizontal Top Blocks"), _("Horizontal Top Cards"),
_("Horizontal endless Blocks"), _("Horizontal endless Cards"),
]
self.gamesLayoutCombo = self._create_library_combo(
self.library_layout_labels, _("Grid Type:")
self.library_layout_labels, _("Layout")
)
selected_layout = ui_config.get_library_layout_mode("theme")
if (
@@ -412,7 +456,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
self.gamesLayoutCombo.activated.connect(
self._delay_library_controls_hover_close
)
controls_layout.addWidget(self.gamesLayoutCombo, 0, 3)
controls_layout.addWidget(self.gamesLayoutCombo, 3, 0)
def _delay_library_controls_hover_close(self, _index: int = -1) -> None:
self._library_controls_hover_close_delayed = True
@@ -499,9 +543,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
@@ -539,8 +587,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:
@@ -607,18 +655,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
) or (os.path.isfile(url.toLocalFile()) and is_native_executable(url.toLocalFile())):
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):
@@ -629,19 +680,61 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
self.open_local_autoinstall_card(path)
event.acceptProposedAction()
break
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS):
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS) or (os.path.isfile(path) and is_native_executable(path)):
self.openAddGameDialog(path)
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()
@@ -666,6 +759,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
name = dialog.nameEdit.text().strip()
exe_path = dialog.exeEdit.text().strip()
user_cover = dialog.coverEdit.text().strip()
native = is_native_executable(exe_path)
if native:
user_cover = dialog.last_cover_path or user_cover
if not name or not exe_path:
return
@@ -700,14 +796,30 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
entry = parse_desktop_entry(desktop_path)
if not entry:
return
self._known_portproton_desktops.add(desktop_path)
if not native:
self._known_portproton_desktops.add(desktop_path)
elif cover_path:
desktop_entry = re.sub(r"^Icon=.*$", lambda _match: f"Icon={cover_path}", desktop_entry, flags=re.MULTILINE)
self._write_desktop_file(desktop_entry, desktop_path)
if native:
try:
save_native_settings(desktop_path, {})
except Exception as error:
logger.warning("Cannot create native PPDB: %s", error)
QMessageBox.warning(self, _("Error"), str(error))
return
for source in ("all", "native", "favorites"):
getattr(self, "_loaded_library_cache", {}).pop(source, None)
description = entry.get("Comment", "")
exec_line = entry.get("Exec", exe_path)
exec_line = NATIVE_URI_PREFIX + desktop_path if native else entry.get("Exec", exe_path)
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
self._sync_game_shortcuts_from_dialog(dialog, name, exec_line, cover_for_shortcut)
# User cover fallback
user_cover_path = cover_path # Already set if user provided
native_game = read_native_game(Path(desktop_path)) if native else None
if native_game:
user_cover_path = native_game[2] or user_cover_path
# Statistics (playtime, last launch - defaults for new)
playtime_seconds = 0
@@ -756,13 +868,15 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
steam_info.get("anticheat_status", ""),
last_played_timestamp,
playtime_seconds,
"portproton",
"native" if native else "portproton",
steam_info.get("anticheat_slug", ""),
steam_info.get("ppdb_id", ""),
steam_info.get("ppdb_rating", ""),
)
# Incremental add
if native and game_config.get_display_filter() not in ("all", "native"):
return
self.game_library_manager.add_game_incremental(game_data)
# Trigger visible images load
@@ -784,7 +898,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
cached_steam_info.get("anticheat_status", ""),
last_played_timestamp,
playtime_seconds,
"portproton",
"native" if native else "portproton",
cached_steam_info.get("anticheat_slug", ""),
cached_steam_info.get("ppdb_id", ""),
cached_steam_info.get("ppdb_rating", ""),
@@ -796,6 +910,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
def _sync_game_shortcuts_from_dialog(self, dialog, game_name, exec_line, cover_path):
"""Apply shortcut options selected in add/edit game dialog."""
if exec_line.startswith(NATIVE_URI_PREFIX):
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
for directory, checkbox in (
(os.path.join(data_home, "applications"), dialog.add_to_menu_checkbox),
(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), dialog.add_to_desktop_checkbox),
):
if directory and checkbox.isChecked():
try:
os.makedirs(directory, exist_ok=True)
shutil.copyfile(source, Path(directory) / source.name)
os.chmod(Path(directory) / source.name, 0o755)
except OSError as error:
logger.warning("Failed to copy native shortcut to %s: %s", directory, error)
QMessageBox.warning(self, _("Error"), str(error))
if dialog.add_to_steam_checkbox.isChecked():
if not is_game_in_steam(game_name):
self.context_menu_manager.add_to_steam(game_name, exec_line, cover_path)
elif is_game_in_steam(game_name):
self.context_menu_manager.remove_from_steam(game_name, exec_line, "native")
return
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
menu_path = os.path.join(applications_dir, f"{game_name}.desktop")
if dialog.add_to_menu_checkbox.isChecked():
+142 -13
View File
@@ -1,10 +1,11 @@
import os
from typing import TYPE_CHECKING, Any
from PySide6.QtCore import QObject, Qt, QTimer
from PySide6.QtCore import QObject, QRect, QSize, Qt, QTimer
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QAbstractButton,
QBoxLayout,
QCheckBox,
QComboBox,
QFormLayout,
@@ -44,7 +45,7 @@ from portprotonqt.config import (
ui_config,
)
from portprotonqt.context_menu_manager import CustomLineEdit
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox, FlowLayout
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
from portprotonqt.debug_utils import get_selectable_gpu_list
from portprotonqt.localization import _, retranslate
from portprotonqt.logger import get_logger
@@ -67,6 +68,42 @@ else:
_MainWindowTypingBase = object
class SettingsButtonsLayout(QBoxLayout):
def __init__(self) -> None:
super().__init__(QBoxLayout.Direction.TopToBottom)
self.setContentsMargins(0, 0, 0, 0)
def hasHeightForWidth(self) -> bool:
return True
def heightForWidth(self, width: int) -> int:
sizes = [item.sizeHint() for index in range(self.count())
if (item := self.itemAt(index)) is not None]
if sum(size.width() for size in sizes) + self.spacing() <= width:
return max((size.height() for size in sizes), default=0)
return sum(size.height() for size in sizes) + self.spacing()
def minimumSize(self) -> QSize:
sizes = [item.minimumSize() for index in range(self.count())
if (item := self.itemAt(index)) is not None]
return QSize(max((size.width() for size in sizes), default=0),
sum(size.height() for size in sizes) + self.spacing())
def setGeometry(self, rect: QRect) -> None:
width = sum(item.sizeHint().width() for index in range(self.count())
if (item := self.itemAt(index)) is not None)
direction = (QBoxLayout.Direction.LeftToRight
if width + self.spacing() <= rect.width()
else QBoxLayout.Direction.TopToBottom)
if self.direction() != direction:
self.setDirection(direction)
if direction == QBoxLayout.Direction.LeftToRight:
row_width = width + self.spacing()
rect = QRect(rect.x() + (rect.width() - row_width) // 2,
rect.y(), row_width, rect.height())
super().setGeometry(rect)
class MainWindowSettingsTabMixin(_MainWindowTypingBase):
if TYPE_CHECKING:
def __getattr__(self, name: str) -> Any: ...
@@ -293,6 +330,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
fullscreen_layout.addStretch()
uiForm.addRow(fullscreen_layout)
self.hideCursorCheckBox = QCheckBox()
self.hideCursorCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.hideCursorCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.hideCursorTitle = QLabel(_("Hide Cursor in Fullscreen"))
self.hideCursorTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.hideCursorTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
self.hideCursorTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.hideCursorCheckBox.setChecked(display_config.get_hide_cursor())
hide_cursor_layout = QHBoxLayout()
hide_cursor_layout.setContentsMargins(0, 0, 0, 0)
hide_cursor_layout.addWidget(self.hideCursorCheckBox)
hide_cursor_layout.addWidget(self.hideCursorTitle)
hide_cursor_layout.addStretch()
uiForm.addRow(hide_cursor_layout)
self.autoFullscreenGamepadCheckBox = QCheckBox()
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -309,6 +361,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
auto_fullscreen_layout.addStretch()
uiForm.addRow(auto_fullscreen_layout)
self.disableBootAnimationCheckBox = QCheckBox()
self.disableBootAnimationCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.disableBootAnimationCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.disableBootAnimationTitle = QLabel(_("Disable Boot Animation"))
self.disableBootAnimationTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.disableBootAnimationTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
self.disableBootAnimationTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.disableBootAnimationCheckBox.setChecked(not display_config.get_boot_animation_enabled())
boot_animation_layout = QHBoxLayout()
boot_animation_layout.setContentsMargins(0, 0, 0, 0)
boot_animation_layout.addWidget(self.disableBootAnimationCheckBox)
boot_animation_layout.addWidget(self.disableBootAnimationTitle)
boot_animation_layout.addStretch()
uiForm.addRow(boot_animation_layout)
self.minimizeToTrayCheckBox = QCheckBox()
self.minimizeToTrayCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -441,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()
@@ -503,6 +597,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
steam_compat_layout.addStretch()
uiForm.addRow(steam_compat_layout)
self.disableSteamProtonScanningCheckBox = QCheckBox()
self.disableSteamProtonScanningCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.disableSteamProtonScanningCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.disableSteamProtonScanningTitle = QLabel(_("Disable Steam Proton scanning"))
self.disableSteamProtonScanningTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.disableSteamProtonScanningTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
self.disableSteamProtonScanningTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.disableSteamProtonScanningCheckBox.setChecked(ui_config.get_disable_steam_proton_scanning())
disable_steam_proton_scanning_layout = QHBoxLayout()
disable_steam_proton_scanning_layout.setContentsMargins(0, 0, 0, 0)
disable_steam_proton_scanning_layout.addWidget(self.disableSteamProtonScanningCheckBox)
disable_steam_proton_scanning_layout.addWidget(self.disableSteamProtonScanningTitle)
disable_steam_proton_scanning_layout.addStretch()
uiForm.addRow(disable_steam_proton_scanning_layout)
if get_steam_compatibilitytools_dir() is not None:
self.downloadWineToSteamCheckBox = QCheckBox()
self.downloadWineToSteamCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
@@ -739,9 +848,12 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
layout.addWidget(self.settingsScrollArea)
# Buttons (outside scroll area, always visible)
buttonsLayout = FlowLayout(center_rows=True)
actionsLayout = SettingsButtonsLayout()
actionsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
buttonsLayout = QHBoxLayout()
buttonsLayout.setContentsMargins(0, 0, 0, 0)
buttonsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
buttonsLayout.addStretch()
self.saveButton = AutoSizeButton(_("Save Settings"), icon=self.theme_manager.get_icon("save", as_path=True))
self.saveButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
@@ -765,25 +877,34 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
self.globalGameSettingsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.globalGameSettingsButton.clicked.connect(self.openGlobalGameSettings)
buttonsLayout.addWidget(self.globalGameSettingsButton)
buttonsLayout.addStretch()
actionsLayout.addLayout(buttonsLayout)
maintenanceButtonsLayout = QHBoxLayout()
maintenanceButtonsLayout.setContentsMargins(0, 0, 0, 0)
maintenanceButtonsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
maintenanceButtonsLayout.addStretch()
self.migrateShortcutsButton = AutoSizeButton(_("Migrate legacy shortcuts"), icon=self.theme_manager.get_icon("update", as_path=True))
self.migrateShortcutsButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
self.migrateShortcutsButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.migrateShortcutsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.migrateShortcutsButton.clicked.connect(self.migrateLegacyShortcuts)
buttonsLayout.addWidget(self.migrateShortcutsButton)
maintenanceButtonsLayout.addWidget(self.migrateShortcutsButton)
if os.getenv("APPIMAGE"):
buttonsLayout.addWidget(self.integrateAppImageButton)
maintenanceButtonsLayout.addWidget(self.integrateAppImageButton)
self.clearCacheButton = AutoSizeButton(_("Clear Cache"), icon=self.theme_manager.get_icon("update", as_path=True))
self.clearCacheButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
self.clearCacheButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.clearCacheButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.clearCacheButton.clicked.connect(self.clearCache)
buttonsLayout.addWidget(self.clearCacheButton)
maintenanceButtonsLayout.addWidget(self.clearCacheButton)
maintenanceButtonsLayout.addStretch()
layout.addLayout(buttonsLayout)
actionsLayout.addLayout(maintenanceButtonsLayout)
layout.addLayout(actionsLayout)
self.stackedWidget.addWidget(self.portProtonWidget)
def openGlobalGameSettings(self) -> None:
@@ -799,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():
@@ -819,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
@@ -836,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()
@@ -965,9 +1086,12 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
fullscreen = self.fullscreenCheckBox.isChecked()
display_config.set_fullscreen(fullscreen)
display_config.set_hide_cursor(self.hideCursorCheckBox.isChecked())
self._update_cursor_visibility()
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
display_config.set_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
display_config.set_boot_animation_enabled(not self.disableBootAnimationCheckBox.isChecked())
hide_control_hints = self.hideControlHintsCheckBox.isChecked()
ui_config.set_hide_control_hints(hide_control_hints)
@@ -999,6 +1123,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
elif not steam_compat and currently_installed:
remove_steam_compat_tool()
ui_config.set_disable_steam_proton_scanning(self.disableSteamProtonScanningCheckBox.isChecked())
if hasattr(self, 'downloadWineToSteamCheckBox'):
ui_config.set_download_wine_to_steam(self.downloadWineToSteamCheckBox.isChecked())
@@ -1096,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:
+16
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,
@@ -21,7 +32,9 @@ class MainWindowWorkersMixin:
"egs_auth_worker": None,
"gog_library_worker": None,
"gog_metadata_worker": None,
"native_library_worker": None,
"gog_repair_worker": None,
"gog_delete_worker": None,
"store_dlc_worker": None,
"gog_auth_worker": None,
"gog_account_worker": None,
@@ -33,10 +46,13 @@ class MainWindowWorkersMixin:
for worker_name, timeout_ms in worker_timeouts.items():
self._stopWorkerThread(worker_name, timeout_ms)
for workers_name in (
"autoInstallStatusWorkers",
"gog_support_workers",
"_listWorkerPool",
"_imageWorkerPool",
"_detailImageWorkerPool",
"_themeArchiveWorkers",
"_gameFolderWorkers",
):
self._stopWorkerThreads(workers_name)
downloader = getattr(self, "downloader", None)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more