Compare commits

..
Author SHA1 Message Date
Boria138 c4b80522d1 chore: switch GOG downloads to the managed gogdl fork
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-17 00:19:38 +05: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
Boria138 13e89d8b2b chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-26 12:01:15 +05:00
Boria138 30c3bfb3b1 feat(settings): allow skipping ALT Linux dependency checks
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-26 11:59:37 +05:00
Boria138 8e12ec713f fix: repair malformed desktop entries
Code check / Check code (push) Successful in 3m43s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 22:38:30 +05:00
Boria138 e809f031ce fix: scroll horizontal library layouts with mouse wheel
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 14:28:09 +05:00
Boria138 1b42c672bb fix: include library layout in gamepad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 14:20:43 +05:00
Boria138 07b53f3160 chore(locale): update
Code check / Check code (push) Successful in 2m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 13:59:45 +05:00
Boria138 40d80f0ce8 fix: preserve theme card animations across library layouts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 13:58:53 +05:00
Boria138 0b8536b163 feat: add configurable library layout variant
Code check / Check code (push) Successful in 1m38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 13:37:05 +05:00
Boria138 31f6d84537 feat: add vertical cards to horizontal library layouts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 13:13:11 +05:00
Boria138 5c86b3d943 chore: remove unversioned LG Wine aliases from settings
Code check / Check code (push) Successful in 2m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 20:13:29 +05:00
Boria138 ec2ffee64d chore(locale): update
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 20:08:51 +05:00
Boria138 60e9a2866a chore: move Wine defaults to user.conf settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 20:07:47 +05:00
Boria138 94269455ec fix: restore gamepad control for user.conf combos
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 19:55:38 +05:00
Boria138 f5c07cf3ea fix: add resolution save to global game settings editor
Code check / Check code (push) Successful in 2m43s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 12:18:24 +05:00
Boria138 509d4272ab chore(locale): update
Code check / Check code (push) Successful in 1m54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 11:40:43 +05:00
Boria138 ff560fb43a feat: add global game settings editor
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 11:39:34 +05:00
Boria138 ed78af49d2 feat: add unified store download queue
Code check / Check code (push) Successful in 1m45s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-23 11:14:24 +05:00
Boria138 5c5a154a0d feat: improve completed download actions
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-23 10:59:46 +05:00
Boria138 ae011adfdf feat: add install all button to DLC dialog
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-23 10:36:12 +05:00
Boria138 b0d97e0e0b chore: added Temporarily disable OptiScaler hint to compatibility_report
Code check / Check code (push) Successful in 1m48s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-22 19:06:39 +05:00
Boria138 8a303bf9ed fix: improve game crash detection
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-22 19:03:40 +05:00
Boria138 a14563dd78 fix: prevent duplicate game card when adding a game
Code check / Check code (push) Successful in 2m42s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-21 15:49:39 +05:00
Codeberg TranslateandBoria138 abb511c173 Translated using Weblate (Russian)
Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-20 18:28:52 +00:00
Boria138 ef0626a069 chore(locale): merge Russian Weblate updates 2026-09-20 23:26:03 +05:00
Boria138 40f06caa16 fix: update library search after adding executable
Code check / Check code (push) Successful in 1m50s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 23:23:30 +05:00
Boria138 bfd4bf4065 chore: Added Kex Engine support to graphics_detector
Code check / Check code (push) Successful in 2m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 14:11:59 +05:00
Boria138 92ecc008f3 chore(locale): update
Code check / Check code (push) Successful in 4m4s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 13:25:21 +05:00
Boria138 0c78e9c689 feat: add MangoHud font settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 13:24:23 +05:00
Codeberg Translateandadem 4840d512b5 Translated using Weblate (Russian)
Currently translated at 100.0% (779 of 779 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-20 07:04:46 +00:00
Boria138 c22992b803 chore: use unity player to graphics_detector
Code check / Check code (push) Successful in 3m38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 12:04:22 +05:00
Renovate Bot 57f955d827 chore(deps): update debian docker tag to trixie-20260918
Code check / Check code (push) Successful in 2m42s
2026-09-20 06:48:57 +00:00
Renovate Bot f9694e576b chore(deps): update archlinux:base-devel docker digest to 4894f5a
Code check / Check code (pull_request) Successful in 1m57s
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 1m56s
2026-09-20 00:00:58 +00:00
Gitea Actions a74c5631ec chore: download stats 2026-09-18T17:06:55Z 2026-09-18 17:06:56 +00:00
Boria138 55361797c3 chore: round download stats
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 22:05:28 +05:00
Boria138 f3999e9bf1 fix: refresh detail page after editing a shortcut
Code check / Check code (push) Successful in 1m56s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 21:32:13 +05:00
Boria138 35366bbf12 fix: require confirmation before deleting EGS games
Code check / Check code (push) Successful in 1m36s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 11:44:40 +05:00
Boria138 bab6d6b84e feat: add DLC selection and installation for GOG and EGS
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 11:40:24 +05:00
Boria138 061ee7fedf fix: keep library search results packed in both layouts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 11:18:21 +05:00
Boria138 8707c93835 feat: verify existing GOG and Epic files during installation
Code check / Check code (push) Successful in 2m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-17 22:51:02 +05:00
Boria138 53a44c5708 fix: filter GOG and Epic DLCs from the game library
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-17 22:24:52 +05:00
Boria138 154cf43bf8 fix: show Steam demos and related applications
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-17 22:17:21 +05:00
Boria138 a70bf97774 ci: check Bash 5.1 conditional separator compatibility
Code check / Check code (push) Successful in 2m50s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-16 18:31:34 +05:00
Boria138 a06f76a2e9 fix: wait for background workers during shutdown
Code check / Check code (push) Successful in 2m41s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-15 19:33:04 +05:00
Boria138 649aec7229 fix: restore prefix backup support with libarchive-c 5.1
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-15 19:20:46 +05:00
Boria138 0da0d55537 docs: separate third-party license notices
Code check / Check code (push) Successful in 3m45s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-15 16:25:14 +05:00
Renovate Bot c2f611c73e chore(deps): update https://git.linux-gaming.ru/actions/anylinux-setup-action action to v4
Code check / Check code (push) Successful in 1m50s
2026-09-15 10:33:12 +00:00
Renovate Bot c4f9f05363 chore(deps): update archlinux:base-devel docker digest to 70d777a
Code check / Check code (pull_request) Successful in 3m11s
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 3m36s
2026-09-15 10:27:15 +00:00
Boria138 56c8a9781b fix: restore gamepad hotplug and active type hints
Code check / Check code (push) Successful in 1m55s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-14 17:11:54 +05:00
Boria138 9d72466402 fix: align autoinstall behavior with library layouts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-14 17:05:51 +05:00
Boria138 4df207fa3b chore: migrate AppImage builds to anylinux setup action
Code check / Check code (push) Successful in 1m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-14 13:11:57 +05:00
Renovate Bot bd6dc490b1 fix(deps): update dependency evdev to v2
Code check / Check code (push) Successful in 1m43s
2026-09-14 07:55:51 +00:00
Renovate Bot 383d17f523 fix(deps): update dependency tqdm to >=4.70.1
Code check / Check code (push) Successful in 1m33s
2026-09-14 07:55:36 +00:00
Codeberg Translateandadem 9b889a8d2e Translated using Weblate (Russian)
Currently translated at 100.0% (779 of 779 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-14 07:54:46 +00:00
Boria138 64c9f4d196 fix: support input from multiple gamepads
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-14 07:54:40 +00:00
Boria138 0de9e46485 fix: check ALT dependencies before GOG support setup
Code check / Check code (push) Successful in 2m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-13 12:53:44 +05:00
Boria138 bdc14d1d86 fix: preserve terminal errors for inspection
Code check / Check code (push) Successful in 1m46s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-13 12:48:39 +05:00
Boria138 1fc8d62a85 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-13 11:58:12 +05:00
Codeberg Translateandadem4ik 0e3a0be146 Translated using Weblate (Russian)
Currently translated at 100.0% (776 of 776 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: adem4ik <adem4ik@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-11 19:32:38 +00:00
Codeberg Translateandrogerio-mattos 6e2f7452bd Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (773 of 773 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-11 19:32:38 +00:00
Codeberg Translateandgallegonovato 37168f8892 Translated using Weblate (Spanish)
Currently translated at 44.9% (349 of 776 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: gallegonovato <gallegonovato@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/
Translation: PortProtonQt/PortProtonQt
2026-09-11 19:32:38 +00:00
Codeberg TranslateandRubin13 141f98d81f Translated using Weblate (German)
Currently translated at 100.0% (779 of 779 strings)

Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-09-11 19:32:38 +00:00
Boria138 7031051dc4 feat: added vulkan to graphics API executable detection
Code check / Check code (push) Failing after 30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-12 00:29:42 +05:00
Boria138 769ad421a2 feat: improve graphics API executable detection
Code check / Check code (push) Successful in 1m58s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-12 00:25:41 +05:00
Boria138 2316efe3d6 fix: prevent autoinstall custom data collisions
Code check / Check code (push) Successful in 2m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 17:40:57 +05:00
Boria138 09b790fc26 feat: add ID-based GOG and EGS shortcut editing
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 17:19:11 +05:00
Boria138 a48b9050d2 chore: migrate legacy data to persistent storage
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 17:12:15 +05:00
Boria138 b602880907 chore: move cache and launcher storage to XDG data directory
Code check / Check code (push) Successful in 2m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 12:33:40 +05:00
Boria138 05a5b136e9 chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 12:19:18 +05:00
Boria138 bde4ba9602 feat: offer removal or executable replacement for missing games
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 12:16:59 +05:00
Renovate Bot 2634370ee3 chore(deps): update archlinux:base-devel docker digest to 61f7de2
Code check / Check code (push) Successful in 2m5s
2026-09-10 15:56:58 +00:00
Renovate Bot 9cea141f9c chore(deps): update dependency pyright to >=1.1.414
Code check / Check code (push) Successful in 2m16s
2026-09-10 15:56:48 +00:00
Boria138 3d417c1a83 fix: refresh button sizing during live theme changes
Code check / Check code (push) Successful in 3m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 20:56:21 +05:00
Boria138 27003d4967 fix: support library background during live theme switch
Code check / Check code (push) Successful in 1m57s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 20:42:27 +05:00
Boria138 c75755bb64 fix: initialize tray before silent launch
Code check / Check code (push) Failing after 55s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 13:55:41 +05:00
Boria138 67652ea258 chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 13:50:14 +05:00
Boria138 3735c7514c feat: add game pause controls to system tray
Code check / Check code (push) Failing after 1m28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 13:49:01 +05:00
Boria138 803c98b6ff fix: prevent empty tray icon during slow startup
Code check / Check code (push) Failing after 1m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 12:55:19 +05:00
Boria138 9214018c03 fix: wrap game titles in horizontal library modes
Code check / Check code (push) Successful in 4m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-09 17:45:51 +05:00
Boria138 d2597fa8d6 feat: support horizontal auto-hide scrollbars
Code check / Check code (push) Successful in 3m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-09 17:43:08 +05:00
Boria138 25e20c3ea1 feat: reload custom themes when files change
Code check / Check code (push) Successful in 1m46s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-09 17:32:14 +05:00
Boria138 f1a5db4f34 fix: use XDG data paths on NixOS
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 20:26:34 +05:00
Boria138 096d86cb7b fix: set icon for built-in terminal window
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 20:16:06 +05:00
Boria138 7b7553a1fe feat: add ALT i586 dependency CLI check
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 20:13:56 +05:00
Boria138 7c30418d61 fix: filter Wine builds by host architecture
Code check / Check code (push) Successful in 2m52s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 14:57:17 +05:00
Boria138 fa30835e97 fix: handle store game shutdown without stale UI state
Code check / Check code (push) Successful in 2m33s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 14:37:06 +05:00
Renovate Bot 0bdab8fd40 fix(deps): update python dependencies
Code check / Check code (push) Successful in 2m41s
2026-09-08 09:19:59 +00:00
Renovate Bot 43f4d0a665 chore(deps): update https://git.linux-gaming.ru/actions/setup-python action to v7
Code check / Check code (push) Successful in 3m29s
2026-09-08 09:19:48 +00:00
Renovate Bot 779a5eca65 chore(deps): update https://git.linux-gaming.ru/actions/setup-node action to v7
Code check / Check code (pull_request) Successful in 3m36s
Code check / Check code (push) Successful in 2m21s
2026-09-08 08:59:41 +00:00
Boria138 1a30a105d0 test: use built-in TimeoutError in D-Bus test
Code check / Check code (push) Successful in 2m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 13:54:40 +05:00
Boria138 45fe72d393 build: require Python 3.11
Code check / Check code (push) Failing after 1m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 13:44:42 +05:00
149 changed files with 81439 additions and 6111 deletions

No files matched your search

+27 -8
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 }}
@@ -32,10 +32,10 @@ jobs:
options: --privileged --device /dev/fuse
steps:
- name: Prepare container
run: |
pacman-key --init
pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
- name: Install CI dependencies
run: pacman -S --noconfirm --needed nodejs npm
- uses: https://git.linux-gaming.ru/actions/checkout@v4
@@ -128,8 +128,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 +177,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
+19 -3
View File
@@ -22,12 +22,28 @@ jobs:
name: Check code
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
steps:
- name: Rank Arch mirrors
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Install system dependencies
run: |
pacman -Syuu --noconfirm --disable-download-timeout
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
pacman -Syu --noconfirm --needed git nodejs npm mesa libarchive libpulse sdl3 pyside6 qt6-svg qt6-imageformats uv
pacman -Scc --noconfirm
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version-file: "pyproject.toml"
+64 -11
View File
@@ -80,14 +80,31 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
options: --privileged --device /dev/fuse
steps:
- name: Prepare container
- name: Rank Arch mirrors
run: |
pacman-key --init
pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@b2d1ce4216f71162e00ae58fa11ee9aeb723d0d5 # v5
- name: Install CI dependencies
run: pacman -S --noconfirm --needed nodejs npm
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'pull_request'
@@ -135,8 +152,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 +207,29 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
steps:
- name: Rank Arch mirrors
run: |
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
- name: Prepare container
run: |
pacman -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 +290,28 @@ jobs:
needs: changes
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
container:
image: debian:trixie-20260824@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
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
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up Node.js
uses: https://git.linux-gaming.ru/actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
uses: https://git.linux-gaming.ru/actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: 20
+1 -1
View File
@@ -1 +1 @@
3.10
3.11
+29 -9
View File
@@ -1,7 +1,7 @@
# PortProtonQt — AI Agent Guidelines
**Project:** PortProtonQt — GUI for PortProton, Steam, Epic Games Store
**Language:** Python 3.10+
**Project:** PortProtonQt — Game library manager and launcher for Linux
**Language:** Python 3.11+
**Platform:** Linux (POSIX)
**License:** GPL-3.0
**Build:** Meson + uv
@@ -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").
---
@@ -302,6 +304,9 @@ Do not delete or disable caching or downloaders to satisfy this rule. Caching is
- No heavy operations in paintEvent
- Use methods instead of lambda for complex logic
- Use QThread for long tasks
- Retain every background worker that can outlive its caller in an owner field or collection until it finishes
- Register window-owned workers and retained worker collections in the application shutdown path; cancel when supported and wait before interpreter exit
- Add or update shutdown regression tests when adding or changing a background worker, including executor-backed workers
---
@@ -329,7 +334,7 @@ Do not assume conventions. Never invent behavior.
```bash
# Setup
uv python install 3.10
uv python install 3.11
uv sync
source .venv/bin/activate
pre-commit install --install-hooks
@@ -409,13 +414,20 @@ tests/
├── test_appimage_updater.py # AppImage auto-update worker, mirrors, changelog parsing
├── test_dark_theme.py # Dark theme detection (XFCE/MATE/Cinnamon/GNOME)
├── test_steam_cache.py # exiftool skip, cache eviction, delete_cached_app_files
├── test_downloader.py # Cache/error callbacks, worker ownership, and shutdown regressions
├── test_base_config.py # BaseConfig read/write, caching, versioning
├── test_cli.py # normalize_launch_path, URL/resolution parsing
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
├── test_input_manager.py # Gamepad input navigation and focus regressions
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
├── test_boot_animation.py # Theme video inheritance, fullscreen boot rendering, gamepad skip, loading transitions, and shutdown
├── test_main_window.py # Main window data processing and callback regressions
├── test_library_resize.py # Library resize batching, cover reuse, and flow layout regressions
├── test_library_loading.py # Incremental loading, source switching, first-paint focus, and cover callbacks
├── test_store_games.py # GOG and EGS library, launch, install, and stop regressions
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
├── test_portproton_scripts.py # Shell script launch, second exe, exit codes, user.conf
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
@@ -457,6 +469,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 |
@@ -492,6 +513,7 @@ pre-commit run pytest
- [ ] Prefer dedicated functions for subprocess calls when practical within task scope
- [ ] No shared mutable global state (except logger and explicit cache/session infrastructure)
- [ ] No blocking calls introduced in the UI thread
- [ ] Background workers have explicit ownership and shutdown cleanup with regression coverage
- [ ] **No hardcoded styles or constants (use theme constants)**
- [ ] **New constants added to theme files only**
@@ -984,7 +1006,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
@@ -1061,8 +1083,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)
---
+80 -1
View File
@@ -7,15 +7,65 @@
### Added
### Changed
- Загрузка игр GOG переведена на форк `linux-gaming-ru/gogdl` с выбором CDN по скорости, исправленной статистикой загрузки и ограничением повторных запросов; ранее установленный gogdl автоматически заменяется форком
### Fixed
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
- Исправлена гонка при тихом запуске игр
### Contributors
---
## [1.5.0] - 2026-10-14
### Added
- Добавлен поиск настроек игры вводом с клавиатуры без предварительного выбора поля поиска
- Добавлена навигация геймпадом к кнопкам под таблицами
- Добавлена настройка скрытия курсора в полноэкранном режиме, включённая по умолчанию
- Добавлена настройка отключения поиска установленных Proton в Steam для списков выбора версий и окна управления WINE/Proton
- Добавлена возможность добавления загрузочной WebM-заставки из тем при переходе в fullscreen, включая автоматический переход при подключении геймпада
- Добавлен переход к загрузке Wine/Proton из списка версий в настройках игры
- Добавлены выбор и установка DLC при установке игр GOG
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
- Добавлена настройка отключения проверки i586-зависимостей в ALT Linux
- Добавлена настройка принудительного применения английского языка
- Добавлено редактирование названия и обложки игр GOG по ID
- Добавлена интеграция Epic Games Store с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
- Добавлена поддержка сборок Proton CachyOS WineLand
- Добавлена кнопка возврата на рабочий стол в системной вкладке при указании команды в `PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT`
- Добавлены режимы библиотеки `horizontal` и `horizontal_top` с циклической навигацией и плиткой открытия полной сетки в топ-режиме
- В панель библиотеки добавлен выбор типа сетки
- В виджет с исчезающей полосой прокрутки добавлена поддержка горизонтальной ориентации
- Добавлен режим библиотеки `vertical` с табличным отображением названия, последнего запуска, проведённого времени и источника игры
- При открытии карточки с отсутствующим исполняемым файлом добавлен выбор: удалить карточку или указать другой файл
- Добавлен отдельный раздел настройки шрифтов MangoHud с масштабом, размером и выбором шрифта
- Добавлен графический редактор глобальных настроек игр из `user.conf`
- В режиме `PW_USE_SUPPLIED_DXVK_VKD3D` добавлена поддержка D7VK из сборки Proton
### Changed
- Настройка `WINE_FULLSCREEN_FSR` по умолчанию отключена
- Кнопки менеджера Wine/Proton приведены к общему стилю приложения и дополнены иконками
- Списки DLL и шрифтов в Winetricks теперь сортируются по алфавиту
- Оформление Winetricks приведено к общему стилю приложения
- В поле запуска второго `.exe` / враппера теперь можно указать тот же исполняемый файл, что и основной
- Описания приложения в README, метаданных и пакетах сделаны нейтральными, без перечисления источников игр и привязки к Windows
- Ускорено изменение размера карточек библиотеки
- Удалён GDK Proton из списка доступных сборок
- Улучшено отображение описаний параметров в настройках игры: они размещаются под названием, а текущая строка выделяется рамкой
- После завершения загрузки показывается системное уведомление, а строка результата открывает карточку игры мышью или кнопкой A геймпада
- Улучшено определение вылетов игр: учитывается код завершения команды Wine/Proton и время работы игровых процессов, включая дочерний движок, без учёта времени завершения скрипта запуска
- Каталоги пользовательских данных игр переведены на единый формат `источник-ID`
- Данные приложения организованы в `$XDG_DATA_HOME/PortProtonQt` (по умолчанию `~/.local/share/PortProtonQt`): кеш перенесён из `.cache` в `cache`, данные лаунчеров — в `launcher`, загружаемые бинарники — в `bin`, чтобы временный кеш AnyLinux не вызывал повторную загрузку при каждом запуске AppImage
- Минимальная поддерживаемая версия Python повышена с 3.10 до 3.11
- Поиск в библиотеке теперь находит совпадения в любой части названия и показывает одноимённые игры из разных источников
- Тема теперь применяется без перезапуска приложения
- Добавлена сборка Fedora 45
@@ -24,27 +74,56 @@
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
- Обновлён перевод на Испанский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/`
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
- Порог определения быстрого вылета Windows-программы увеличен с 5 до 7 секунд
- Открытие карточек и тихий запуск унифицированы
- `minazuki` добавлен в список конфликтующих с GameMode процессов
- Встроенный терминал после ошибки остаётся открытым и закрывается клавишей Enter или кнопкой A геймпада
- В список советов при вылете добавлено отключение Optiscaler
- Повышен статус разработки в `pyproject.toml` с Alpha до Production
- DXVK и VKD3D-Proton теперь загружаются только для выбранного режима Vulkan, а D7VK — только при его включении
### Fixed
- Исправлена запись `MANGOHUD_CONFIG` в `.ppdb` при выключенном MangoHud
- Исправлены ложный статус установки и открытие чужой карточки для Rockstar Games Launcher при совпадении имени `launcher.exe`
- Исправлен запуск второго `.exe` через `PW_RUN_AFTER_EXE` из пути с кириллицей
- Исправлены мерцание сетки и задержка выделения карточек при открытии библиотеки
- Исправлено обновление сетки комбинацией Guide + Select
- Исправлены создание и восстановление `.desktop`-файлов с переносами строк в названии игры
- Исправлено появление дубля карточки при добавлении и после установки игры
- Исправлено отсутствие новых `.exe` в поиске библиотеки до обновления сетки
- Исправлено обновление названия на детальной странице после редактирования ярлыка без повторного открытия страницы
- Исправлено отображение дополнений и наборов GOG как отдельных игр в библиотеке
- Исправлено отсутствие установленных демоверсий, модов, приложений и бета-версий Steam в библиотеке
- Исправлена синтаксическая ошибка в `functions_helper`, препятствовавшая запуску PortProton с Bash 5.1
- Исправлено управление интерфейсом при одновременном подключении нескольких геймпадов
- Исправлена коллизия пользовательских данных автоустановок с одинаковыми именами исходных исполняемых файлов
- Исправлен запуск PortProtonQt на NixOS через `steam-run`
- Исправлена проверка и установка недостающих i586-зависимостей в ALT Linux перед запуском игр
- Исправлено отсутствие иконки у окна встроенного терминала
- Исправлена потеря финального вывода при ошибке встроенного терминала
- Исправлено открытие карточек GOG через URI при старте приложения
- Исправлен сброс состояния кнопки записи лога при переключении детальной страницы в компактный режим
- Исправлено отображение значка PortProtonQt в системном трее при медленной инициализации приложения
- Исправлено отсутствие вкладок Wi-Fi и Bluetooth при запуске до инициализации системных адаптеров
- Исправлена ошибка, из-за которой не работал поиск по настройкам при отсутствии доступных параметров MangoHud или Gamescope
- Фильтр установленных игр теперь переключается без повторной загрузки библиотеки
- Некорректные изображения в кэше Steam теперь пропускаются при поиске обложек
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
- Исправлено создание резервной копии префикса с `libarchive-c 5.1`
- Исправлено завершение приложения при активных фоновых операциях
- Исправлено автоматическое включение NVAPI, DLSS и трассировки лучей для видеокарт NVIDIA RTX
- Исправлено применение принудительно выбранного языка в конфигурациях эмуляторов Steam
### Contributors
- @dark_siders
- @rogerio-mattos
- @Rubin13
- @adem4ik
- @gallegonovato
---
-436
View File
@@ -15,439 +15,3 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
We make use of various projects, some of which require their license to be preserved. Here's their respective licenses:
-----------------------------------------------------------------------------------------------------------------------
===============
= icoextract: =
===============
MIT License
Copyright (c) 2015-2016 Fadhil Mandaga
Copyright (c) 2019-2025 James Lu <james@overdrivenetworks.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===============
= portproton: =
===============
MIT License
Copyright (c) 2025 Mikhail Tergoev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===============================
= HowLongToBeat-Python-API : =
===============================
MIT License
Copyright (c) 2020 JaeguKim
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
===============================
= mdf2iso : =
===============================
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.
=====================================================================
===========================
= Kenney Interface Sounds =
===========================
Interface Sounds (1.0)
Created/distributed by Kenney (www.kenney.nl)
License: Creative Commons Zero, CC0
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
+23 -9
View File
@@ -3,16 +3,27 @@
---
<div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-10488-green?style=flat-square" alt="Total Downloads"></a>
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, Epic Games Store, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
<p align="center">An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.</p>
<p>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&amp;label=Release" alt="Releases"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-14.1k-green?style=flat-square" alt="Total Downloads"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&amp;logo=python" alt="Python 3.11+"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt"><img src="https://img.shields.io/badge/Platform-Linux-blue?style=flat-square&amp;logo=linux" alt="Platform: Linux"></a>
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Translations-Weblate-2eccaa?style=flat-square&amp;logo=weblate" alt="Translate on Weblate"></a>
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg" alt="Packaging status on Repology"></a>
</p>
<p><a href="https://github.com/linux-gaming-ru/PortProtonQt/releases">Releases</a> · <a href="https://github.com/linux-gaming-ru/PortProtonQt/issues">Issue tracker</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Translations</a> · <a href="https://repology.org/project/portprotonqt/versions">Packages</a></p>
</div>
### Installation (devel)
```sh
uv python install 3.10
uv python install 3.11
uv sync
source .venv/bin/activate # For bash/zsh
# or
@@ -29,7 +40,7 @@ Launch using the `portprotonqt` command or via the shortcut in your application
### Development
To automatically prepare the environment (install Python 3.10, dependencies, pre-commit hooks, and generate translations), run the following script:
To automatically prepare the environment (install Python 3.11, dependencies, pre-commit hooks, and generate translations), run the following script:
```sh
./dev-scripts/prepare_env.sh
@@ -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
@@ -65,13 +76,16 @@ PortProtonQt uses code and dependencies from the following projects:
- [Icoextract](https://github.com/jlu5/icoextract) — icon extraction library, [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE) license.
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — library for interacting with HowLongToBeat, [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md) license.
- [iat](https://sourceforge.net/projects/iat.berlios) — library for converting mdf and nrg to iso, GPLv3 license.
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — library for converting mdf and nrg to iso, GPLv2 license.
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — GOG downloading module, [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE) license.
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — Epic Games launcher alternative, [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE) license.
- [pyte](https://github.com/selectel/pyte) — ANSI escape code parser, [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file) license.
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — base for virtual keyboard layout data, GPLv3 license.
- [omikuji](https://github.com/omikuji-launcher/omikuji) — inspiration and source material for the procedural detail page backgrounds, [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE) license.
- [Bottles](https://github.com/bottlesdevs/Bottles) — source of the compatibility analysis rules, GPL-3.0-only license.
See the [LICENSE](LICENSE) file for the full text of the licenses.
PortProtonQt is licensed under the terms in [LICENSE](LICENSE). See
[THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) for third-party licenses.
> [!WARNING]
> **Be careful!** If you are using a theme not from the official repository or a trusted source, make sure its `styles.py` file does not contain malicious or unwanted code. Since `styles.py` is a regular Python file, it can contain any instructions. Always check the contents of third-party themes before use.
+23 -9
View File
@@ -1,18 +1,29 @@
📖 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-10488-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/Загрузки-14.1k-green?style=flat-square" alt="Всего загрузок"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&amp;logo=python" alt="Python 3.11+"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"><img src="https://img.shields.io/badge/Платформа-Linux-blue?style=flat-square&amp;logo=linux" alt="Платформа: Linux"></a>
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Переводы-Weblate-2eccaa?style=flat-square&amp;logo=weblate" alt="Перевести на Weblate"></a>
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg?header=Репозитории" alt="Пакеты в Repology"></a>
</p>
<p><a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases">Релизы</a> · <a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/issues">Сообщить о проблеме</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Переводы</a> · <a href="https://repology.org/project/portprotonqt/versions">Пакеты</a></p>
</div>
### Установка (devel)
```sh
uv python install 3.10
uv python install 3.11
uv sync
source .venv/bin/activate # For bash/zsh
# or
@@ -29,7 +40,7 @@ source .venv/bin/activate.fish # For fish
### Разработка
Для автоматической подготовки окружения (установка Python 3.10, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
Для автоматической подготовки окружения (установка Python 3.11, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
```sh
./dev-scripts/prepare_env.sh
@@ -65,13 +76,16 @@ PortProtonQt использует код и зависимости от след
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [iat](https://sourceforge.net/projects/iat.berlios) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv3
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv2
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — модуль загрузки игр GOG, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE).
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — альтернатива Epic Games Launcher, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE).
- [pyte](https://github.com/selectel/pyte) — разбор ANSI escape-кодов, лицензия [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file)
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — основа данных раскладок виртуальной клавиатуры, лицензия GPLv3.
- [omikuji](https://github.com/omikuji-launcher/omikuji) — источник вдохновения и материалов для процедурных фонов детальной страницы, лицензия [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE).
- [Bottles](https://github.com/bottlesdevs/Bottles) — источник правил анализа совместимости, лицензия GPL-3.0-only.
Полный текст лицензий см. в файле [LICENSE](LICENSE).
Условия лицензии PortProtonQt приведены в файле [LICENSE](LICENSE). Лицензии
сторонних проектов приведены в файле [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES).
> [!WARNING]
> **Будьте осторожны!** Если вы берёте тему не из официального репозитория или надёжного источника, убедитесь, что в её файле `styles.py` нет вредоносного или нежелательного кода. Поскольку `styles.py` — это обычный Python-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.
+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.
+1278
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -52,7 +52,6 @@ while [ $# -gt 0 ]; do
done
ARCH="$(uname -m)"
EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/get-debloated-pkgs.sh"
if [ "$LOCAL_MODE" = true ]; then
echo "Using local PKGBUILD-git from repository..."
@@ -104,9 +103,7 @@ makepkg -si --noconfirm
echo "Installing debloated packages..."
echo "---------------------------------------------------------------"
wget --retry-connrefused --tries=30 "$EXTRA_PACKAGES" -O ./get-debloated-pkgs.sh
chmod +x ./get-debloated-pkgs.sh
./get-debloated-pkgs.sh --add-common --prefer-nano
get-debloated-pkgs --add-common --prefer-nano
if [ "$LOCAL_MODE" = true ]; then
# For git version, we use portprotonqt-git
+10 -6
View File
@@ -2,7 +2,6 @@
set -eu
SHARUN="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/quick-sharun.sh"
ARCH="$(uname -m)"
VERSION="$(cat ~/version)"
export ARCH VERSION
@@ -17,19 +16,24 @@ export DEPLOY_PYTHON=1
export MAIN_BIN=portprotonqt
export OPTIMIZE_LAUNCH=1
# sharun 2.2.7 needs the matching deployment and preload format from 1.4.1.
ANYLINUX_TOOLS="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/bf5e3c44dc2daf8b5de51cdf6b2d11589d047104/useful-tools"
export SHARUN_LINK="https://github.com/pkgforge-dev/Anylinux-sharun/releases/download/2.2.7/sharun-$ARCH"
export ANYLINUX_LIB_SOURCE="$ANYLINUX_TOOLS/lib/anylinux.c"
export HOOKSRC="$ANYLINUX_TOOLS/hooks"
wget --retry-connrefused --tries=30 "$ANYLINUX_TOOLS/quick-sharun.sh" -O ./quick-sharun
echo '6cd9e45a099d7173aa906aaca51e48e51db78069ead14ef8e6683db4cbb4be44 ./quick-sharun' | sha256sum -c -
chmod +x ./quick-sharun
# Adjust comp settings to bypass oom-killer
export DWARFS_COMP="zstd:level=15 -S22 -B5"
# DEPLOY ALL LIBS
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
chmod +x ./quick-sharun
# Add udev rules
mkdir -p ./AppDir/etc/udev/rules.d
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
# Add PortProton scripts
# Copy manual because im to lazzy for wait ./quick-sharun strace 208 .ppdb
# Copy manually to avoid tracing 208 .ppdb files with quick-sharun
mkdir -p ./AppDir/share
cp -r /usr/share/portproton ./AppDir/share
+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}
Binary file not shown.
@@ -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>
@@ -16,4 +16,10 @@
<sub-class-of type="text/plain"/>
<glob pattern="*.ppdb" weight="100"/>
</mime-type>
<mime-type type="application/x-portproton-user-conf">
<comment>PortProton user configuration</comment>
<comment xml:lang="ru">Пользовательская конфигурация PortProton</comment>
<sub-class-of type="text/plain"/>
<glob pattern="user.conf" weight="100"/>
</mime-type>
</mime-info>
@@ -20,7 +20,30 @@ 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 () {
local action="${1:-check}" installed list package
installed="$(rpm -qa --qf '%{NAME}\n')" || return 2
list=""
for package in glibc-nss glibc-gconv-modules libnm libvulkan1 libfreetype \
$(grep '^libnss-' <<< "$installed" | grep -v '^libnss-fallback$') \
$(grep '^xorg-dri-' <<< "$installed"); do
if grep -Fqx "$package" <<< "$installed" \
&& ! grep -Fqx "i586-$package" <<< "$installed" ; then
list+=" i586-$package"
fi
done
if [[ "$action" == "check" ]] ; then
[[ -z "$list" ]]
return $?
fi
[[ -z "$list" ]] && return 0
apt-get update && apt-get install -y $list
}
python_term () {
@@ -154,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"
}
@@ -533,21 +556,6 @@ try_download () {
return 0
}
prepare_epic_wrapper () {
local expected_hash="1feb21e21e19cbb34e881791c8f2557769f59231e46850915d49a6d0c1ce4583"
export PW_EPIC_WRAPPER_EXE="${PORT_WINE_TMP_PATH}/plugins${PW_PLUGINS_VER}/epic/EpicGamesLauncher.exe"
if [[ ! -f "$PW_EPIC_WRAPPER_EXE" ]] ; then
create_new_dir "$(dirname "$PW_EPIC_WRAPPER_EXE")"
try_download \
"https://github.com/BananaWorks07/heroic-epic-integration/releases/download/v${PW_EPIC_WRAPPER_VER}/EpicGamesLauncher.exe" \
"$PW_EPIC_WRAPPER_EXE" no_mirror || return 1
fi
if [[ "$(sha256sum "$PW_EPIC_WRAPPER_EXE" | awk '{print $1}')" != "$expected_hash" ]] ; then
try_remove_file "$PW_EPIC_WRAPPER_EXE"
fatal "Epic launcher wrapper checksum verification failed"
fi
}
var_winedlloverride_update () {
export WINEDLLOVERRIDES="${1}${WINEDLLOVERRIDES:+;$WINEDLLOVERRIDES}"
}
@@ -1079,6 +1087,7 @@ EOF
-e '/export PW_SOUND_DRIVER_USE=/d' \
"$USER_CONF"
fi
chmod 644 "${USER_CONF}"
# shellcheck source=/dev/null
source "$USER_CONF"
@@ -1205,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
@@ -1296,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"
}
@@ -1505,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
@@ -1631,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" \
@@ -1659,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" \
@@ -1687,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
@@ -1834,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
@@ -2189,8 +2211,8 @@ manage_user_conf_value () {
local var_name="$2"
local var_value="$3"
if [[ -z "${action}" ]] || [[ -z "${var_name}" ]]; then
print_error "Action and variable name must be provided"
if [[ -z "${action}" ]]; then
print_error "Action must be provided"
return 1
fi
@@ -2201,6 +2223,11 @@ manage_user_conf_value () {
case "$action" in
get)
if [[ -z "${var_name}" ]]; then
grep -E '^export [A-Za-z_][A-Za-z0-9_]*=' "${USER_CONF}" 2>/dev/null \
| sed -E 's/^export //' || true
return 0
fi
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
# Extract the value, handling both quoted and unquoted values
local value=$(grep "^export ${var_name}=" "${USER_CONF}" | sed -E 's/^export [^=]+=("?)(.*)\1$/\2/')
@@ -2212,6 +2239,10 @@ manage_user_conf_value () {
fi
;;
set)
if [[ -z "${var_name}" ]]; then
print_error "Variable name must be provided"
return 1
fi
if [[ -z "${var_value}" ]]; then
print_error "Variable value must be provided for set action"
return 1
@@ -2222,6 +2253,10 @@ manage_user_conf_value () {
return 0
;;
delete)
if [[ -z "${var_name}" ]]; then
print_error "Variable name must be provided"
return 1
fi
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
sed -i "/^export ${var_name}=/d" "${USER_CONF}"
print_info "Removed ${var_name} from USER.conf"
@@ -2584,21 +2619,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
@@ -2621,6 +2651,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
@@ -2890,7 +2924,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"
;;
@@ -3221,7 +3257,7 @@ start_portproton () {
# DGVOODOO2 enable:
if [[ "${PW_DGVOODOO2}" == "1" ]] \
&& [[ "${PW_USE_SPECIALK}" != "1" ]] \
&& [[ "${PW_USE_D7VK}" != "1" ]] \
&& [[ "${PW_USE_D7VK}" != "1" ]]
then
if [[ ! -f "${PW_VULKAN_DIR}/dgvoodoo2-${DGV2_VER}/dgVoodoo.conf" ]] ; then
print_info "Download and install DGVOODOO2 v.${DGV2_VER}"
@@ -3457,10 +3493,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"
@@ -3751,10 +3794,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
@@ -3791,6 +3843,7 @@ start_portproton () {
}
pw_run () {
local pw_game_exit_code
if [[ "$PW_USE_TERMINAL" == 1 ]] \
&& [[ "${PRESSURE_VESSEL_TERMINAL:-}" != "tty" ]]
then
@@ -3857,6 +3910,7 @@ pw_run () {
${PW_GAMEMODERUN_SLR} \
${PW_ADD_VAR_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
else
if [[ "$PW_USE_TERMINAL" == 1 ]] ; then
pw_init_runtime
@@ -3877,6 +3931,7 @@ pw_run () {
${PW_GAMEMODERUN_SLR} \
${PW_ADD_VAR_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
print_info "Update prefix log:"
[[ -f "${PW_TMPFS_PATH}/update_pfx_log" ]] && cat "${PW_TMPFS_PATH}/update_pfx_log"
echo
@@ -3906,6 +3961,7 @@ pw_run () {
${PW_INHIBIT_SLR} \
${PW_TASKSET_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
wait_wineserver
else
print_info "Update prefix log:"
@@ -3926,9 +3982,12 @@ pw_run () {
${PW_INHIBIT_SLR} \
${PW_TASKSET_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters}
pw_game_exit_code=$?
wait_wineserver
fi
fi
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$pw_game_exit_code"
return "$pw_game_exit_code"
}
export -f pw_run
@@ -4094,6 +4153,7 @@ steamplay_launch () {
${PW_GAMEMODERUN_SLR} \
${PW_TASKSET_SLR} \
"${proton_runner}" waitforexitandrun "${PW_EXE_FILE}" "$@"
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$?"
if [[ $PW_LOG != 1 ]] && [[ -n $START_PW_TIME_IN_GAME ]] ; then
debug_timer --end -s "PW_TIME_IN_GAME"
PW_TIME_IN_GAME=$(( PW_TIME_IN_GAME / 1000 ))
@@ -4131,12 +4191,11 @@ portwine_launch () {
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]] \
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
then
local wait_delay wait_delay_int run_after_bat
local run_after_dir run_after_name
local pw_exe_file_win
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%%.*}"
@@ -4155,27 +4214,33 @@ portwine_launch () {
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
fi
pw_exe_file_win="${pw_exe_file_win//\//\\}"
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
if [[ -z "${run_after_exe_win}" ]] ; then
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
run_after_exe_win="c:\\${PW_RUN_AFTER_EXE#"${wine_drive_c}/"}"
else
run_after_exe_win="z:${PW_RUN_AFTER_EXE//\//\\}"
fi
fi
run_after_exe_win="${run_after_exe_win//\//\\}"
cat > "${run_after_bat}" <<EOF
@echo off
chcp 65001 >nul
start "" "${pw_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_AFTER_MAIN_PATH="${pw_exe_file_win}" \
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
try_remove_file "${run_after_bat}"
else
case "${PW_EXE_FILE,,}" in
*.exe)
if [[ -n "${PW_EPIC_WRAPPER_EXE:-}" ]] ; then
pw_run "${PW_VD_TMP[@]}" "${PW_EPIC_WRAPPER_EXE}" \
"${PW_EXE_FILE}" "$@"
else
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
fi
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
;;
*.bat|*.cmd)
PW_USE_TERMINAL=1
@@ -4366,7 +4431,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 ))"
@@ -4910,7 +4977,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 $?
}
@@ -4964,7 +5031,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 $?
}
+19 -19
View File
@@ -6,6 +6,19 @@ export url_git="https://git.linux-gaming.ru/CastroFidel/PortWINE"
########################################################################
$PW_DEBUG
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
else
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
fi
if [[ "${1:-}" == "cli" ]] \
&& [[ "${2:-}" == "--alt-i586-dependencies" ]] ; then
source "$PORT_SCRIPTS_PATH/functions_helper"
pw_alt_i586_dependencies "${3:-}"
exit $?
fi
if [[ $(id -u) = 0 ]] \
&& [[ ! -e "/userdata/system/batocera.conf" ]]
then
@@ -13,12 +26,6 @@ then
exit 1
fi
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
else
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
fi
PORT_CONF_PATH="$(dirname "$PORT_SCRIPTS_PATH")/conf"
if [[ -z "$PORT_DATA_PATH" ]] ; then
@@ -73,9 +80,6 @@ fi
export PW_START_PID="$$"
# shellcheck source=/dev/null
source "${PORT_SCRIPTS_PATH}/var"
read -r -a pw_full_command_line <<< "$0 $*"
export pw_full_command_line
export orig_IFS="$IFS"
@@ -122,9 +126,6 @@ elif [[ "$1" =~ ^--(debug|launch|edit-db)$ && "${2,,}" =~ \.(exe|bat|cmd|msi|reg
elif [[ -f "$OLDPWD/$2" ]] ; then
PW_EXE_FILE="$(realpath -s "$OLDPWD/$2")"
fi
elif [[ "$1" == "--epic-wrapper" && "${2,,}" =~ \.exe$ ]] ; then
prepare_epic_wrapper || fatal "Failed to prepare Epic launcher wrapper"
PW_EXE_FILE="$(realpath -s "$2")"
fi
export PW_EXE_FILE
@@ -170,6 +171,9 @@ export PW_VULKAN_DIR="${PORT_WINE_TMP_PATH}/VULKAN"
create_new_dir "${PW_VULKAN_DIR}"
cd "${PORT_SCRIPTS_PATH}" || fatal "Scripts directory not found: ${PORT_SCRIPTS_PATH}"
# shellcheck source=/dev/null
source "${PORT_SCRIPTS_PATH}/var"
# HACK: Avoid inheriting a system scripts cwd in pressure-vessel.
cd "${XDG_DATA_HOME:-$HOME}" || fatal "Data directory not found: ${XDG_DATA_HOME:-$HOME}"
@@ -218,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 () {
@@ -269,7 +273,7 @@ case "$1" in
--launch Launches the application immediately, requires the path to the .exe file
--edit-db After the variable, the path to the .exe file is required and then the variables.
(List their variables and values for example PW_MANGOHUD=1 PW_VKBASALT=0, etc.)
--get-user-conf Get a value from user.conf file, requires variable name
--get-user-conf Get one or all values from user.conf file
--set-user-conf Set a value in user.conf file, requires variable name and value
--del-user-conf Delete a value from user.conf file, requires variable name
--list-db List all available database variables
@@ -579,10 +583,6 @@ Usage examples:
portwine_launch
stop_portproton
;;
--epic-wrapper)
portwine_launch "${@:3}"
stop_portproton
;;
--stop)
start_path="$(realpath "$0")"
pgrep -f -- "${start_path}.*--autoinstall" | while read -r autoinstall_pid ; do
+1 -2
View File
@@ -16,7 +16,6 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
export PW_SPECIALK_VER="SK_26_5_15"
export PW_EPIC_WRAPPER_VER="0.4"
export PW_GALLIUM_NINE_VER="0.10"
export DOTPFX_VER="48v9"
export DEFPFX_VER="v1"
@@ -63,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###
+141 -35
View File
@@ -3,28 +3,119 @@
#include <stdint.h>
#include <stdlib.h>
#define AXIS_ACTIVATION_THRESHOLD 16000
typedef struct {
SDL_Gamepad *gamepad;
SDL_JoystickID instance_id;
int sdl_type;
bool buttons[SDL_GAMEPAD_BUTTON_COUNT];
bool axes_active[SDL_GAMEPAD_AXIS_COUNT];
} GamepadEntry;
typedef struct {
GamepadEntry *entries;
int count;
SDL_JoystickID active_instance_id;
} PortProtonGamepad;
static PortProtonGamepad *open_gamepad(SDL_JoystickID instance_id)
static bool has_gamepad(const PortProtonGamepad *gamepads,
SDL_JoystickID instance_id)
{
for (int index = 0; index < gamepads->count; index++) {
if (gamepads->entries[index].instance_id == instance_id) {
return true;
}
}
return false;
}
static bool add_gamepad(PortProtonGamepad *gamepads,
SDL_JoystickID instance_id)
{
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
if (gamepad == NULL) {
return NULL;
return false;
}
PortProtonGamepad *result = malloc(sizeof(*result));
if (result == NULL) {
GamepadEntry *entries = realloc(
gamepads->entries, sizeof(*entries) * (gamepads->count + 1));
if (entries == NULL) {
SDL_CloseGamepad(gamepad);
SDL_SetError("Failed to allocate gamepad handle");
return SDL_SetError("Failed to allocate gamepad handle");
}
gamepads->entries = entries;
GamepadEntry entry = {0};
entry.gamepad = gamepad;
entry.instance_id = instance_id;
entry.sdl_type = SDL_GetGamepadTypeForID(instance_id);
entries[gamepads->count] = entry;
gamepads->count++;
return true;
}
static void remove_disconnected(PortProtonGamepad *gamepads)
{
int active_count = 0;
for (int index = 0; index < gamepads->count; index++) {
GamepadEntry entry = gamepads->entries[index];
if (SDL_GamepadConnected(entry.gamepad)) {
gamepads->entries[active_count++] = entry;
} else {
if (gamepads->active_instance_id == entry.instance_id) {
gamepads->active_instance_id = 0;
}
SDL_CloseGamepad(entry.gamepad);
}
}
gamepads->count = active_count;
}
static void update_gamepads(PortProtonGamepad *gamepads)
{
SDL_PumpEvents();
SDL_UpdateGamepads();
remove_disconnected(gamepads);
int count = 0;
SDL_JoystickID *instance_ids = SDL_GetGamepads(&count);
if (instance_ids == NULL) {
return;
}
for (int index = 0; index < count; index++) {
if (!has_gamepad(gamepads, instance_ids[index])) {
add_gamepad(gamepads, instance_ids[index]);
}
}
SDL_free(instance_ids);
}
static bool update_entry_activity(GamepadEntry *entry)
{
bool activated = false;
for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; button++) {
bool pressed = SDL_GetGamepadButton(entry->gamepad, button);
activated |= pressed && !entry->buttons[button];
entry->buttons[button] = pressed;
}
for (int axis = 0; axis < SDL_GAMEPAD_AXIS_COUNT; axis++) {
int value = SDL_GetGamepadAxis(entry->gamepad, axis);
bool active = abs(value) >= AXIS_ACTIVATION_THRESHOLD;
activated |= active && !entry->axes_active[axis];
entry->axes_active[axis] = active;
}
return activated;
}
static const GamepadEntry *get_active_gamepad(const PortProtonGamepad *gamepads)
{
if (gamepads == NULL) {
return NULL;
}
result->gamepad = gamepad;
result->instance_id = instance_id;
result->sdl_type = SDL_GetGamepadTypeForID(instance_id);
return result;
for (int index = 0; index < gamepads->count; index++) {
if (gamepads->entries[index].instance_id == gamepads->active_instance_id) {
return &gamepads->entries[index];
}
}
return NULL;
}
PortProtonGamepad *portproton_gamepad_find(void)
@@ -33,23 +124,17 @@ PortProtonGamepad *portproton_gamepad_find(void)
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
return NULL;
}
SDL_PumpEvents();
SDL_UpdateGamepads();
int count = 0;
SDL_JoystickID *gamepads = SDL_GetGamepads(&count);
if (gamepads == NULL) {
PortProtonGamepad *result = calloc(1, sizeof(*result));
if (result == NULL) {
SDL_SetError("Failed to allocate gamepad collection");
return NULL;
}
if (count == 0) {
SDL_free(gamepads);
update_gamepads(result);
if (result->count == 0) {
free(result);
SDL_ClearError();
return NULL;
}
PortProtonGamepad *result = NULL;
for (int index = 0; index < count && result == NULL; index++) {
result = open_gamepad(gamepads[index]);
}
SDL_free(gamepads);
return result;
}
@@ -64,56 +149,77 @@ void portproton_gamepad_close(PortProtonGamepad *gamepad)
if (gamepad == NULL) {
return;
}
if (gamepad->gamepad != NULL) {
SDL_CloseGamepad(gamepad->gamepad);
for (int index = 0; index < gamepad->count; index++) {
SDL_CloseGamepad(gamepad->entries[index].gamepad);
}
free(gamepad->entries);
free(gamepad);
}
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
{
return gamepad != NULL && gamepad->gamepad != NULL &&
SDL_GamepadConnected(gamepad->gamepad);
return gamepad != NULL && gamepad->count > 0;
}
void portproton_gamepad_update(void)
void portproton_gamepad_update(PortProtonGamepad *gamepad)
{
SDL_UpdateGamepads();
if (gamepad != NULL) {
update_gamepads(gamepad);
for (int index = 0; index < gamepad->count; index++) {
if (update_entry_activity(&gamepad->entries[index])) {
gamepad->active_instance_id = gamepad->entries[index].instance_id;
}
}
}
}
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active == NULL) {
return 0;
}
return SDL_GetGamepadButton(gamepad->gamepad, button);
return SDL_GetGamepadButton(active->gamepad, button);
}
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active == NULL) {
return 0;
}
return SDL_GetGamepadAxis(gamepad->gamepad, axis);
return SDL_GetGamepadAxis(active->gamepad, axis);
}
uint32_t portproton_gamepad_get_active_instance_id(
const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->active_instance_id : 0;
}
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
if (gamepad == NULL || gamepad->count == 0) {
return "";
}
const char *name = SDL_GetGamepadName(gamepad->gamepad);
const char *name = SDL_GetGamepadName(gamepad->entries[0].gamepad);
return name != NULL ? name : "";
}
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->instance_id : 0;
return gamepad != NULL && gamepad->count > 0
? gamepad->entries[0].instance_id : 0;
}
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->sdl_type : 0;
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active != NULL) {
return active->sdl_type;
}
return gamepad != NULL && gamepad->count > 0
? gamepad->entries[0].sdl_type : 0;
}
void portproton_gamepad_shutdown(void)
+7 -2
View File
@@ -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.
+67506 -2690
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
+1007 -17
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.
+12
View File
@@ -19,6 +19,18 @@ for file in "$@"; do
echo "$file: bash -n syntax check failed"
status=1
fi
# Bash 5.1 requires a separator before then after a conditional command.
if ! awk '
/\]\][ \t]+then([ \t;]|$)/ || (continued && /^[ \t]*then([ \t;]|$)/) {
printf "%s:%d: Bash 5.1 requires a newline or semicolon before then\n", FILENAME, NR
failed = 1
}
{ continued = /\]\][ \t]*\\$/ }
END { exit failed }
' "$file"; then
status=1
fi
done
exit "$status"
+23 -18
View File
@@ -1,10 +1,12 @@
#!/bin/bash
set -euo pipefail
GITHUB_REPOS=("Boria138/PortProtonQt" "linux-gaming-ru/PortProtonQt")
GITHUB_REPOS=("linux-gaming-ru/PortProtonQt")
GITEA_REPO="Linux-Gaming/PortProtonQt"
GITEA_URL="https://git.linux-gaming.ru"
jq_format='def format_downloads: if . < 1000 then tostring else ((. / 100 | round) / 10 | tostring) + "k" end;'
jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | .download_count] | add // 0'
echo "=== PortProtonQt Download Stats ==="
@@ -12,49 +14,52 @@ echo
echo "--- GitHub (${GITHUB_REPOS[*]}) ---"
GH_RAW=$(for repo in "${GITHUB_REPOS[@]}"; do
curl -sf "https://api.github.com/repos/${repo}/releases?per_page=100"
curl -sfL "https://api.github.com/repos/${repo}/releases?per_page=100"
done | jq -s 'add')
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
echo "Releases: $GH_RELEASES"
echo "Total downloads: $GH_COUNT"
GH_DISPLAY=$(jq -nr --argjson count "$GH_COUNT" "$jq_format"'$count | format_downloads')
echo "Total downloads: $GH_DISPLAY"
echo
echo "Per release:"
echo "$GH_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
echo "$GH_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
echo
echo "--- Gitea (${GITEA_REPO}) ---"
GIT_RAW=$(curl -sf "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
GIT_RAW=$(curl -sfL "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
GIT_COUNT=$(echo "$GIT_RAW" | jq "$jq_total")
GIT_RELEASES=$(echo "$GIT_RAW" | jq 'length')
echo "Releases: $GIT_RELEASES"
echo "Total downloads: $GIT_COUNT"
GIT_DISPLAY=$(jq -nr --argjson count "$GIT_COUNT" "$jq_format"'$count | format_downloads')
echo "Total downloads: $GIT_DISPLAY"
echo
echo "Per release:"
echo "$GIT_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
echo "$GIT_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
echo
TOTAL=$((GH_COUNT + GIT_COUNT))
BADGE_TOTAL=$(jq -nr --argjson total "$TOTAL" "$jq_format"'$total | format_downloads')
echo "========================"
echo "GitHub total: $GH_COUNT"
echo "Gitea total: $GIT_COUNT"
echo "Grand total: $TOTAL"
echo "GitHub total: $GH_DISPLAY"
echo "Gitea total: $GIT_DISPLAY"
echo "Grand total: $BADGE_TOTAL"
echo
echo "--- Top downloads (by file) ---"
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total)\t\(.name)"'
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total | format_downloads)\t\(.name)"'
echo "GitHub:"
echo "$GH_RAW" | jq -r "$jq_filter_top"
echo "$GH_RAW" | jq -r "$jq_format $jq_filter_top"
echo
echo "Gitea:"
echo "$GIT_RAW" | jq -r "$jq_filter_top"
echo "$GIT_RAW" | jq -r "$jq_format $jq_filter_top"
echo
echo "--- Top downloads (by type) ---"
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total)"'
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total | format_downloads)"'
echo "GitHub:"
echo "$GH_RAW" | jq -r "$jq_type"
echo "$GH_RAW" | jq -r "$jq_format $jq_type"
echo
echo "Gitea:"
echo "$GIT_RAW" | jq -r "$jq_type"
echo "$GIT_RAW" | jq -r "$jq_format $jq_type"
if [[ "${CI:-}" != "true" ]]; then
echo
@@ -62,9 +67,9 @@ if [[ "${CI:-}" != "true" ]]; then
exit 0
fi
sed -i "s|-[0-9]*-green?style=flat-square|-$TOTAL-green?style=flat-square|g" README.md README.ru.md
sed -i -E "s|-[0-9]+(\.[0-9]+)?k?-green\?style=flat-square|-$BADGE_TOTAL-green?style=flat-square|g" README.md README.ru.md
echo
echo "Badge updated: $TOTAL"
echo "Badge updated: $BADGE_TOTAL"
git diff --quiet README.md README.ru.md && { echo "No changes to commit"; exit 0; }
+6 -6
View File
@@ -17,13 +17,13 @@ if ! command -v uv &> /dev/null; then
exit 1
fi
# 2. Check if Python 3.10 is already installed via uv
# We look for cpython-3.10 and ensure it doesn't say "<download available>"
if uv python list | grep "cpython-3.10" | grep -qv "<download available>"; then
echo "✅ Python 3.10 is already available."
# 2. Check if Python 3.11 is already installed via uv
# We look for cpython-3.11 and ensure it doesn't say "<download available>"
if uv python list | grep "cpython-3.11" | grep -qv "<download available>"; then
echo "✅ Python 3.11 is already available."
else
echo "🐍 Installing Python 3.10..."
uv python install 3.10
echo "🐍 Installing Python 3.11..."
uv python install 3.11
fi
# 3. Sync dependencies
+2 -2
View File
@@ -44,7 +44,7 @@ Translations submitted online are periodically reviewed and merged into the main
1. Run:
```bash
uv python install 3.10
uv python install 3.11
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --create-new <locale_code>
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
If you’ve added new strings to the code:
```bash
uv python install 3.10
uv python install 3.11
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --update-all
@@ -44,7 +44,7 @@
1. Выполните:
```bash
uv python install 3.10
uv python install 3.11
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --create-new <код_локали>
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
Если вы добавили новые строки в код:
```bash
uv python install 3.10
uv python install 3.11
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --update-all
+20 -1
View File
@@ -291,6 +291,7 @@ GAME_CARD_GRID = {
}
GAME_CARD_HORIZONTAL = {
"card_orientation": "vertical",
"card_animation_type": "scale",
"extra_margin": 20,
"spacing": 5,
@@ -302,6 +303,9 @@ GAME_CARD_HORIZONTAL = {
}
```
Set `card_orientation` to `"vertical"` to reuse the grid card proportions in
the horizontal strip. Omit it to keep the horizontal block proportions.
Values are logical pixels. `cover_radius` applies to static and animated covers;
`border_radius` applies to the painted focus and hover border. Grid cover width
continues to follow the library card-size setting.
@@ -312,7 +316,8 @@ widths, pulse settings, gradient angles and colors, fill/stripe/glow colors and
opacity, scale values, animation durations, and easing curves. For example,
`horizontal_top` can define its own `scale` type, scale values, duration, and
easing while the complete grid uses separately configured `gradient` values.
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback.
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback;
`card_animation_type` defaults to `gradient`.
```python
GAME_CARD_HORIZONTAL = {
@@ -953,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`:
+22 -1
View File
@@ -291,6 +291,7 @@ GAME_CARD_GRID = {
}
GAME_CARD_HORIZONTAL = {
"card_orientation": "vertical",
"card_animation_type": "scale",
"extra_margin": 20,
"spacing": 5,
@@ -302,6 +303,10 @@ GAME_CARD_HORIZONTAL = {
}
```
Укажите `card_orientation = "vertical"`, чтобы использовать в горизонтальной
ленте вертикальные пропорции карточек из сетки. Без параметра сохраняются
пропорции горизонтальных блоков.
Значения задаются в логических пикселях. `cover_radius` применяется к
статическим и анимированным обложкам, а `border_radius` — к отрисовываемой
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
@@ -313,7 +318,8 @@ GAME_CARD_HORIZONTAL = {
fill/stripe/glow, масштаб, длительности и easing-кривые. Например,
`horizontal_top` может задавать собственные тип `scale`, значения масштаба,
длительность и easing, а полная сетка — отдельные параметры `gradient`.
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`.
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`, а
`card_animation_type` по умолчанию использует `gradient`.
```python
GAME_CARD_HORIZONTAL = {
@@ -954,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',
)
+1 -4
View File
@@ -25,10 +25,7 @@ class GameCardAnimations:
def _animation_type(self) -> str:
layout_config = getattr(self.game_card, "card_layout_cfg", {})
return layout_config.get(
"card_animation_type",
self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient"),
)
return layout_config.get("card_animation_type", "gradient")
def _config_value(self, key: str) -> Any:
layout_config = getattr(self.game_card, "card_layout_cfg", {})
+59 -14
View File
@@ -2,12 +2,15 @@ import argparse
import sys
import os
import subprocess
import signal
import threading
import urllib.error
import urllib.request
from urllib.parse import quote, unquote
from logging import Logger
import psutil
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
@@ -52,7 +55,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"],
@@ -102,7 +105,7 @@ def get_portproton_tray_icon() -> QIcon:
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
"""Launch a game with a minimal tray stop action."""
"""Launch a game with minimal tray game controls."""
logger = get_logger(__name__)
app.setQuitOnLastWindowClosed(False)
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
@@ -132,6 +135,39 @@ def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> No
if process.poll() is not None:
end_silent_run()
def update_pause_action() -> list[psutil.Process]:
try:
children = psutil.Process(process.pid).children(recursive=True)
paused = bool(children) and all(
child.status() == psutil.STATUS_STOPPED for child in children
)
except (psutil.NoSuchProcess, psutil.AccessDenied):
children = []
paused = False
pause_action.setEnabled(bool(children))
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
return children
def toggle_pause() -> None:
children = update_pause_action()
if not children:
return
try:
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
for child in children:
try:
child.send_signal(pause_signal)
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
logger.warning("Failed to inspect game pause state: %s", error)
update_pause_action()
pause_action = QAction(_("Pause Game"), tray_menu)
pause_action.triggered.connect(toggle_pause)
tray_menu.addAction(pause_action)
tray_menu.aboutToShow.connect(update_pause_action)
stop_action = QAction(_("Stop Game"), tray_menu)
stop_action.triggered.connect(stop_game)
tray_menu.addAction(stop_action)
@@ -200,6 +236,7 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
def main():
parsed_args = parse_args()
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
from portprotonqt.appimage_integration import integrate_appimage
@@ -509,6 +546,8 @@ def main():
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:
@@ -617,12 +656,12 @@ 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())
@@ -721,7 +760,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)
@@ -768,13 +807,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."""
@@ -861,10 +901,15 @@ def main():
finally:
self.finished.emit()
if start_sh:
# Initial setup can stop Wine and must not race a silent game launch.
if start_sh and not silent_game_request:
worker = InitialCommandWorker(start_sh)
window.initialCommandWorker = worker
worker.finished.connect(
lambda: setattr(window, "initialCommandWorker", None)
)
worker.start()
else:
elif not start_sh:
logger.warning("PortProton start command not available, skipping initial command")
# --- Cleanup ---
+1
View File
@@ -183,6 +183,7 @@ def integrate_appimage() -> Path:
resolved_icon = icon_source.resolve()
icon = icon_dir / f"{APP_ID}.svg"
_copy_appimage(source, destination)
# Remove after the legacy AppImage migration period.
legacy_appimage = Path.home() / "Applications/PortProtonQt.AppImage"
if legacy_appimage.resolve() != destination.resolve():
portproton_location = get_portproton_location()
+3 -2
View File
@@ -14,7 +14,8 @@ import requests
from PySide6.QtCore import QThread, Signal
from portprotonqt.downloader import get_requests_session
from portprotonqt.config import CACHE_DIR, ui_config
from portprotonqt.config import ui_config
from portprotonqt.config.base import XDG_DATA_HOME
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -59,7 +60,7 @@ def _get_appimage_arch() -> str:
def _cached_tool_path() -> Path:
return CACHE_DIR / "bin" / "appimageupdatetool"
return XDG_DATA_HOME / "PortProtonQt" / "bin" / "appimageupdatetool"
def _get_appimageupdatetool_url() -> str:
+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)
+41 -5
View File
@@ -14,6 +14,7 @@ from typing import Any
import orjson
import pefile
import psutil
from portprotonqt.debug_utils import (
get_portproton_env,
@@ -98,16 +99,46 @@ class CompatibilityLaunch:
duration: float
def is_suspected_crash(duration: float, stopped_by_user: bool, executable: str) -> bool:
"""Return whether an executable closed too soon after launch."""
def is_suspected_crash(
duration: float, stopped_by_user: bool, executable: str, exit_code: int | None = None
) -> bool:
"""Prefer Wine's command status; use short runtime only when it is unknown."""
always_report = os.getenv(COMPATIBILITY_ALWAYS_REPORT_ENV) == "1"
return (
(always_report or (not stopped_by_user and duration < CRASH_THRESHOLD_SECONDS))
(always_report or (not stopped_by_user and (
exit_code != 0 if exit_code is not None else duration < CRASH_THRESHOLD_SECONDS
)))
and executable.lower().endswith((".exe", ".msi"))
and os.path.isfile(executable)
)
def get_running_game_processes(executable: str, started: float) -> dict[tuple[int, float], str]:
"""Find new Windows processes in the game's directory, including engine children."""
directory = os.path.dirname(os.path.abspath(executable)).lower() + "/"
running = {}
for process in psutil.process_iter(attrs=["pid", "name", "cmdline", "cwd", "create_time", "status"]):
info = process.info
created = info.get("create_time")
if created is None or created < started or info.get("status") == psutil.STATUS_ZOMBIE:
continue
arguments = info.get("cmdline") or []
command = (arguments[0] if arguments else info.get("name") or "").replace("\\", "/")
if not command.lower().endswith((".exe", ".msi")):
continue
if command.lower().startswith("z:/"):
command = command[2:]
if not command.startswith("/"):
cwd = info.get("cwd")
if not cwd:
continue
command = os.path.join(cwd, os.path.basename(command) if ":" in command else command)
command = os.path.normpath(command)
if command.lower().startswith(directory):
running[(info["pid"], created)] = command
return running
def _read_imports(pe: pefile.PE) -> set[str]:
imports = set()
for directory_name in ("DIRECTORY_ENTRY_IMPORT", "DIRECTORY_ENTRY_DELAY_IMPORT"):
@@ -288,9 +319,12 @@ def _analyze_pe(executable: str) -> tuple[str, dict[str, list[str]]]:
try:
optional_header = getattr(pe, "OPTIONAL_HEADER", None)
data_directories = getattr(optional_header, "DATA_DIRECTORY", [])
clr = data_directories[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"]
descriptor_index = pefile.DIRECTORY_ENTRY[
"IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"
]
if not isinstance(descriptor_index, int):
raise IndexError
clr = data_directories[descriptor_index]
if clr.VirtualAddress and ".NET Framework" not in findings.setdefault("Runtimes", []):
findings["Runtimes"].append(".NET Framework")
except (AttributeError, IndexError):
@@ -420,6 +454,8 @@ def _compatibility_suggestions(
)
if uses_directx_8_11 and environment.get("PW_VULKAN_USE") == "0":
suggestions.append("Switch from WineD3D to DXVK for DirectX 8-11.")
if environment.get("PW_USE_OPTISCALER") == "1":
suggestions.append("Temporarily disable OptiScaler in the game settings and retry to check for a conflict.")
if _uses_integrated_gpu(environment):
suggestions.append("Use the discrete GPU.")
runtimes = findings.get("Runtimes", [])
+22 -1
View File
@@ -1,6 +1,7 @@
"""Base configuration class for PortProtonQt."""
import os
import configparser
import shutil
from pathlib import Path
from portprotonqt.logger import get_logger
from portprotonqt.config.validators import validate_string, validate_int, validate_bool, ValidationError
@@ -24,7 +25,27 @@ THEMES_DIRS = [
]
# Cache paths
CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
CACHE_DIR = XDG_DATA_HOME / "PortProtonQt" / "cache"
LEGACY_CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
# Remove after the legacy cache migration period.
def _migrate_legacy_cache() -> None:
"""Move the legacy XDG cache into persistent application data."""
if not LEGACY_CACHE_DIR.is_dir():
return
try:
if not CACHE_DIR.exists():
CACHE_DIR.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(LEGACY_CACHE_DIR), str(CACHE_DIR))
return
shutil.copytree(LEGACY_CACHE_DIR, CACHE_DIR, dirs_exist_ok=True)
shutil.rmtree(LEGACY_CACHE_DIR)
except OSError as error:
logger.warning("Failed to migrate legacy cache directory: %s", error)
_migrate_legacy_cache()
# Module-level cache storage
_config_cache: dict[str, configparser.ConfigParser] = {}
+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)
+46 -3
View File
@@ -164,13 +164,19 @@ def get_portproton_scripts_path() -> str | None:
if appimage_path:
appimage_root = Path(sys.executable).resolve().parent.parent
prefixes.append(("AppImage executable", appimage_root))
if os.getenv("FLATPAK_ID"):
prefixes.append(("Flatpak package", Path("/app")))
prefixes.append(("system package", Path("/usr")))
xdg_scripts_dirs = [
("XDG data", Path(data_dir) / "portproton" / "scripts")
for data_dir in os.getenv(
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
).split(os.pathsep)
if data_dir
]
scripts_dirs = (
("repository", Path.cwd() / "build-aux" / "share" / "portproton" / "scripts"),
*[(source, prefix / "share" / "portproton" / "scripts") for source, prefix in prefixes],
*xdg_scripts_dirs,
)
for source, scripts_dir in scripts_dirs:
if (scripts_dir / "start.sh").is_file():
@@ -283,6 +289,7 @@ def _get_desktop_paths(desktop_dir: str | None) -> tuple[str, ...]:
return (os.path.join(os.path.expanduser("~"), "Desktop"),)
# Remove after the legacy shortcut migration period.
def migrate_legacy_shortcut(
portproton_path: str,
desktop_dir: str | None = None,
@@ -448,7 +455,42 @@ def migrate_legacy_shortcut(
def parse_desktop_entry(file_path: str) -> configparser.SectionProxy | None:
"""Read and parse a .desktop file using configparser."""
cp = configparser.ConfigParser(interpolation=None)
cp.read(file_path, encoding="utf-8")
content = ""
try:
content = Path(file_path).read_text(encoding="utf-8")
cp.read_string(content, source=file_path)
except configparser.ParsingError:
lines = []
for line in content.splitlines():
stripped = line.strip()
if stripped and "=" not in line and not stripped.startswith(("[", "#", ";")):
if not lines or "=" not in lines[-1]:
return None
lines[-1] = f"{lines[-1]} {stripped}"
continue
lines.append(line)
repaired_content = "\n".join(lines) + ("\n" if content.endswith("\n") else "")
try:
cp.read_string(repaired_content, source=file_path)
except configparser.Error as error:
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
return None
desktop_path = Path(file_path)
temp_path = desktop_path.with_name(f".{desktop_path.name}.tmp")
try:
temp_path.write_text(repaired_content, encoding="utf-8")
os.chmod(temp_path, desktop_path.stat().st_mode)
os.replace(temp_path, desktop_path)
logger.warning("Repaired malformed desktop file %s", file_path)
except OSError as error:
logger.warning("Failed to save repaired desktop file %s: %s", file_path, error)
try:
temp_path.unlink(missing_ok=True)
except OSError as cleanup_error:
logger.debug("Failed to remove temporary file %s: %s", temp_path, cleanup_error)
except (configparser.Error, OSError, UnicodeError) as error:
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
return None
if "Desktop Entry" not in cp:
return None
return cp["Desktop Entry"]
@@ -556,6 +598,7 @@ def create_desktop_file(
if not game_name:
game_name = os.path.splitext(os.path.basename(exe_path))[0]
game_name = " ".join(game_name.splitlines()).strip()
base_path = os.path.join(portproton_path, "data")
icon_name = _sanitize_icon_name(game_name)
has_icon_source = bool(icon_source and os.path.isfile(icon_source))
+39
View File
@@ -383,6 +383,27 @@ class UIConfig(BaseConfig):
validate_string(mode, "badge_view_mode", min_len=1, max_len=20)
self._save_value("badge_view_mode", mode, "str")
def get_library_layout_mode(self, theme_mode: str = "grid") -> str:
"""Get the selected library layout or the theme default."""
modes = {"grid", "list", "vertical", "horizontal", "horizontal_top"}
mode = self._get_str("library_layout_mode", "theme")
return mode if mode in modes else theme_mode.lower()
def set_library_layout_mode(self, mode: str) -> None:
"""Set the library layout mode."""
validate_string(mode, "library_layout_mode", min_len=1, max_len=20)
self._save_value("library_layout_mode", mode, "str")
def get_horizontal_card_orientation(self, theme_mode: str) -> str:
"""Get the horizontal card orientation or the theme default."""
mode = self._get_str("horizontal_card_orientation", "theme")
return mode if mode in {"horizontal", "vertical"} else theme_mode
def set_horizontal_card_orientation(self, mode: str) -> None:
"""Set the horizontal card orientation."""
validate_string(mode, "horizontal_card_orientation", min_len=1, max_len=20)
self._save_value("horizontal_card_orientation", mode, "str")
def get_economy_mode(self) -> bool:
"""Get economy mode setting."""
return self._get_download_bool("economy_mode", False)
@@ -432,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"))
@@ -489,6 +519,15 @@ class UIConfig(BaseConfig):
validate_bool(enabled, "crash_reports_enabled")
self._save_value("crash_reports_enabled", enabled, "bool")
def get_disable_alt_i586_dependency_check(self) -> bool:
"""Get ALT i586 dependency check setting."""
return self._get_bool("disable_alt_i586_dependency_check", False)
def set_disable_alt_i586_dependency_check(self, enabled: bool) -> None:
"""Set ALT i586 dependency check setting."""
validate_bool(enabled, "disable_alt_i586_dependency_check")
self._save_value("disable_alt_i586_dependency_check", enabled, "bool")
def load_theme_metainfo(theme_name: str) -> dict:
"""Load theme metadata from metainfo.ini."""
+141 -37
View File
@@ -2,6 +2,7 @@ import os
import glob
import shutil
import tempfile
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
@@ -34,6 +35,9 @@ from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
logger = get_logger(__name__)
if TYPE_CHECKING:
from portprotonqt.game_card import GameCard
class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads."""
show_warning_dialog = Signal(str, str)
@@ -544,6 +548,14 @@ class ContextMenuManager:
import_action.triggered.connect(
lambda: self.parent._import_gog_game(game)
)
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect(
lambda: self._edit_id_shortcut(
game_card.name,
game_card.cover_path,
("gog", str(game_card.appid)),
)
)
if game_card.game_source == "egs":
app_id = str(game_card.appid)
@@ -607,6 +619,12 @@ class ContextMenuManager:
import_action.triggered.connect(
lambda: self.parent._import_egs_game(app_id)
)
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect(
lambda: self._edit_id_shortcut(
game_card.name, game_card.cover_path, ("egs", app_id)
)
)
if game_card.game_source == "steam":
desktop_dir = QStandardPaths.writableLocation(
@@ -1007,7 +1025,7 @@ class ContextMenuManager:
if not resolved_exec_line:
return
shortcut_paths = self._get_installed_shortcut_paths(game_name)
exe_path = self._parse_exe_path(resolved_exec_line, game_name)
exe_path = extract_exec_target_path(resolved_exec_line)
if not self._remove_file(
desktop_path,
_("Failed to delete .desktop file: {error}"),
@@ -1284,29 +1302,34 @@ class ContextMenuManager:
):
self._update_desktop_database()
def _edit_steam_shortcut(self, game_name: str, appid: int | str, cover_path: str) -> None:
appid_str = str(appid).strip()
if not appid_str.isdigit():
logger.warning("Invalid Steam appid for shortcut editing: %s", appid)
def _edit_id_shortcut(self, game_name: str, cover_path: str,
store: tuple[str, str]) -> None:
source, app_id = store
app_id = str(app_id).strip()
invalid_path = app_id in {".", ".."} or os.path.basename(app_id) != app_id
if not app_id or invalid_path or (source == "steam" and not app_id.isdigit()):
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
return
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
game_name=game_name, cover_path=cover_path, steam_appid=appid_str)
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
new_name = dialog.nameEdit.text().strip()
if not new_name:
return
selected_cover = dialog.coverEdit.text().strip()
if not os.path.isfile(selected_cover) and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path):
if (not os.path.isfile(selected_cover) and dialog.last_cover_path
and os.path.isfile(dialog.last_cover_path)):
selected_cover = dialog.last_cover_path
xdg_data_home = os.getenv(
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
)
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", appid_str)
default_data_home = os.path.join(os.path.expanduser("~"), ".local", "share")
xdg_data_home = os.getenv("XDG_DATA_HOME", default_data_home)
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data",
f"{source}-{app_id}")
try:
os.makedirs(game_dir, exist_ok=True)
clean_name = new_name.replace("\r", " ").replace("\n", " ").strip()
with open(os.path.join(game_dir, "metadata.txt"), "w", encoding="utf-8") as metadata_file:
metadata_path = os.path.join(game_dir, "metadata.txt")
with open(metadata_path, "w", encoding="utf-8") as metadata_file:
metadata_file.write(f"name={clean_name}\n")
if selected_cover and os.path.isfile(selected_cover):
extension = os.path.splitext(selected_cover)[1].lower()
@@ -1318,22 +1341,90 @@ class ContextMenuManager:
shutil.copyfile(selected_cover, target_cover)
cover_path = target_cover
except OSError as e:
self.signals.show_warning_dialog.emit(
_("Error"), _("Failed to save custom data: {error}").format(error=str(e))
)
message = _("Failed to save custom data: {error}").format(error=str(e))
self.signals.show_warning_dialog.emit(_("Error"), message)
return
for game in self.game_library_manager.games:
if str(game[3]) != appid_str or game[12] != "steam":
if str(game[3]) != app_id or game[12] != source:
continue
updated_game = list(game)
updated_game[0] = new_name
updated_game[2] = cover_path
self.game_library_manager.replace_game_incremental(
game[0], game[5], tuple(updated_game)
)
self.game_library_manager.replace_game_incremental(game[0], game[5], tuple(updated_game))
QTimer.singleShot(0, self.game_library_manager.load_visible_images)
return
def handle_missing_executable(self, game_card: "GameCard") -> None:
"""Offer removal or shortcut repair when the launch file is missing."""
msg_box = QMessageBox(self.parent)
msg_box.setIcon(QMessageBox.Icon.Question)
msg_box.setWindowTitle(_("Executable not found"))
msg_box.setText(_("Executable not found: {0}").format(game_card.missing_executable_path))
delete_button = msg_box.addButton(_("Delete from PortProton"), QMessageBox.ButtonRole.DestructiveRole)
edit_button = msg_box.addButton(_("Select another executable"), QMessageBox.ButtonRole.ActionRole)
msg_box.addButton(QMessageBox.StandardButton.Cancel)
for button in msg_box.buttons():
button.ensurePolished()
button.setMinimumWidth(button.sizeHint().width())
msg_box.exec()
if msg_box.clickedButton() == delete_button:
self.delete_game(game_card.name, game_card.exec_line)
elif msg_box.clickedButton() == edit_button:
self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
def _relocate_shortcut_data(self, old_exe: str, new_exe: str) -> list[tuple[str, str]]:
"""Move path-specific data, refusing to overwrite another game's data."""
if os.path.abspath(old_exe) == os.path.abspath(new_exe):
return []
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
old_folder = resolve_custom_data_dir(custom_root, old_exe)
new_folder = os.path.join(custom_root, get_custom_data_dir_name(new_exe))
paths = [(old_folder, new_folder), (old_exe + ".ppdb", new_exe + ".ppdb")]
moves = [(source, target) for source, target in paths if os.path.exists(source)]
for _source, target in moves:
if os.path.lexists(target):
raise FileExistsError(_("File already exists: {file_name}").format(file_name=target))
completed = []
try:
for source, target in moves:
shutil.move(source, target)
completed.append((source, target))
except OSError:
for source, target in reversed(completed):
shutil.move(target, source)
raise
return completed
def _relocate_shortcut_statistics(self, old_exe: str, new_exe: str) -> None:
"""Keep playtime and last launch associated with a repaired shortcut."""
from portprotonqt.time_utils import get_last_launch_path, get_statistics_path
old_name = os.path.splitext(os.path.basename(old_exe))[0]
new_name = os.path.splitext(os.path.basename(new_exe))[0]
last_launch_path = get_last_launch_path()
records = (
(get_statistics_path(), os.path.normpath(old_exe).replace(" ", "#@_@#"), new_exe.replace(" ", "#@_@#")),
(last_launch_path, old_name, new_name),
)
for path, old_key, new_key in records:
if not os.path.isfile(path):
continue
try:
with open(path, encoding="utf-8") as stream:
lines = stream.readlines()
updated = []
for line in lines:
parts = line.rsplit(maxsplit=1) if path == last_launch_path else line.split(maxsplit=1)
if len(parts) == 2 and parts[0] == old_key:
line = f"{new_key} {parts[1].rstrip()}\n"
updated.append(line)
if updated != lines:
with open(path, "w", encoding="utf-8") as stream:
stream.writelines(updated)
except OSError as error:
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
"""
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
@@ -1344,7 +1435,7 @@ class ContextMenuManager:
cover_path: The path to the game's cover image.
"""
if appid is not None:
self._edit_steam_shortcut(game_name, appid, cover_path)
self._edit_id_shortcut(game_name, cover_path, ("steam", str(appid)))
return
if not self._check_portproton():
return
@@ -1352,7 +1443,7 @@ class ContextMenuManager:
if not exec_line:
return
old_exec_line = exec_line
exe_path = self._parse_exe_path(exec_line, game_name)
exe_path = extract_exec_target_path(exec_line)
if not exe_path:
return
dialog = AddGameDialog(
@@ -1383,6 +1474,27 @@ class ContextMenuManager:
return
old_path = self._get_desktop_path(game_name)
moved_paths = []
try:
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
except OSError as e:
for source, target in reversed(moved_paths):
shutil.move(target, source)
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to save .desktop file: {error}").format(error=str(e))
)
return
for source, target in moved_paths:
if new_cover_path.startswith(source + os.sep):
new_cover_path = target + new_cover_path[len(source):]
if dialog.last_cover_path and dialog.last_cover_path.startswith(source + os.sep):
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
if exe_path != new_exe_path:
self._relocate_shortcut_statistics(exe_path, new_exe_path)
if game_name != new_name and os.path.exists(old_path):
self._remove_file(
old_path,
@@ -1390,17 +1502,6 @@ class ContextMenuManager:
_("Removed old .desktop file for '{game_name}'"),
game_name
)
try:
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
except OSError as e:
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to save .desktop file: {error}").format(error=str(e))
)
return
saved_entry = parse_desktop_entry(new_desktop_path)
updated_exec_line = new_exe_path
if saved_entry:
@@ -1461,8 +1562,8 @@ class ContextMenuManager:
old_menu_path = self._get_menu_shortcut_path(game_name)
new_menu_path = self._get_menu_shortcut_path(new_name)
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
old_desktop_path = os.path.join(desktop_dir, f"{game_name}.desktop")
new_desktop_path_target = os.path.join(desktop_dir, f"{new_name}.desktop")
old_desktop_path = self._get_shortcut_path(game_name, desktop_dir)
new_desktop_path_target = self._get_shortcut_path(new_name, desktop_dir)
if game_name != new_name:
if os.path.exists(old_menu_path):
@@ -1473,19 +1574,22 @@ class ContextMenuManager:
self.remove_from_steam(game_name, new_exe_path, "portproton")
if dialog.add_to_menu_checkbox.isChecked():
if not os.path.exists(new_menu_path):
if exe_path != new_exe_path or not os.path.exists(new_menu_path):
self.add_to_menu(new_name, updated_exec_line)
elif os.path.exists(new_menu_path):
self.remove_from_menu(new_name)
if dialog.add_to_desktop_checkbox.isChecked():
if not os.path.exists(new_desktop_path_target):
if exe_path != new_exe_path or not os.path.exists(new_desktop_path_target):
self.add_to_desktop(new_name, updated_exec_line)
elif os.path.exists(new_desktop_path_target):
self.remove_from_desktop(new_name)
is_in_steam = is_game_in_steam(new_name)
if dialog.add_to_steam_checkbox.isChecked():
if is_in_steam and exe_path != new_exe_path:
self.remove_from_steam(new_name, old_exec_line, "portproton")
is_in_steam = False
if not is_in_steam:
self.add_to_steam(new_name, updated_exec_line, new_cover_path)
elif is_in_steam:
@@ -1569,7 +1673,7 @@ class ContextMenuManager:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
exe_path = extract_exec_target_path(exec_line)
if not exe_path:
return
logger.debug("Removing game '%s' from Steam", game_name)
+143 -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
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
from PySide6.QtSvg import QSvgRenderer
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config import ui_config
@@ -40,6 +41,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 +57,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 +67,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 +91,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 +153,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 +169,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 +185,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 +201,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 +264,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 +316,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
@@ -547,7 +580,8 @@ class AutoSizeButton(QPushButton):
self.current_theme_name = selected_theme
self.theme = self.theme_manager.apply_theme(selected_theme)
if padding is None:
self._uses_theme_padding = padding is None
if self._uses_theme_padding:
padding = getattr(self.theme, 'autoSizeButtonPadding', 20)
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = self._normalize_padding(padding)
@@ -574,6 +608,18 @@ class AutoSizeButton(QPushButton):
self.adjustFontSize()
def refresh_theme(self, theme: object) -> None:
"""Refresh sizing values supplied by the active theme."""
self.theme = theme
if not self._uses_theme_padding:
return
padding = getattr(theme, "autoSizeButtonPadding", 20)
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = (
self._normalize_padding(padding)
)
self.adjustFontSize()
self.updateGeometry()
def _extract_icon_name(self, icon_path):
if isinstance(icon_path, str):
base = os.path.basename(icon_path)
@@ -827,6 +873,8 @@ class NavLabel(QLabel):
super().mousePressEvent(event)
class AutoHideScrollArea(QScrollArea):
viewportResized = Signal()
def __init__(
self,
theme,
@@ -845,18 +893,30 @@ class AutoHideScrollArea(QScrollArea):
self.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
self._v_scrollbar = self.verticalScrollBar()
self._h_scrollbar = self.horizontalScrollBar()
self._v_scrollbar.installEventFilter(self)
self._h_scrollbar.installEventFilter(self)
self._opacity_effect = QGraphicsOpacityEffect(self._v_scrollbar)
self._opacity_effect.setOpacity(0.0)
self._v_scrollbar.setGraphicsEffect(self._opacity_effect)
self._h_opacity_effect = QGraphicsOpacityEffect(self._h_scrollbar)
self._h_opacity_effect.setOpacity(0.0)
self._h_scrollbar.setGraphicsEffect(self._h_opacity_effect)
self._fade_animation = QPropertyAnimation(self._opacity_effect, b"opacity")
self._fade_animation.setDuration(self.fade_duration_ms)
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
self._h_fade_animation = QPropertyAnimation(self._h_opacity_effect, b"opacity")
self._h_fade_animation.setDuration(self.fade_duration_ms)
self._h_fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
self._is_visible = False
self._scroll_needed = False
self._h_is_visible = False
self._h_scroll_needed = False
self._apply_visible_style()
@@ -864,7 +924,12 @@ class AutoHideScrollArea(QScrollArea):
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self._start_fade_out)
self._h_hide_timer = QTimer(self)
self._h_hide_timer.setSingleShot(True)
self._h_hide_timer.timeout.connect(self._start_horizontal_fade_out)
self._v_scrollbar.valueChanged.connect(self._on_scroll)
self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
self.viewport().installEventFilter(self)
initial_widget = self.widget()
@@ -875,6 +940,25 @@ class AutoHideScrollArea(QScrollArea):
def _apply_visible_style(self) -> None:
self._v_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
self._h_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
def _start_horizontal_fade_in(self) -> None:
if not self._h_scroll_needed:
return
self._h_fade_animation.stop()
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
self._h_fade_animation.setEndValue(1.0)
self._h_fade_animation.start()
self._h_is_visible = True
def _start_horizontal_fade_out(self) -> None:
if not self._h_scroll_needed:
return
self._h_fade_animation.stop()
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
self._h_fade_animation.setEndValue(0.0)
self._h_fade_animation.start()
self._h_is_visible = False
def _start_fade_in(self) -> None:
if not self._scroll_needed:
@@ -899,17 +983,28 @@ class AutoHideScrollArea(QScrollArea):
self._opacity_effect.setOpacity(opacity)
self._is_visible = opacity == 1.0
def _set_horizontal_opacity_immediately(self, opacity: float) -> None:
self._h_fade_animation.stop()
self._h_opacity_effect.setOpacity(opacity)
self._h_is_visible = opacity == 1.0
def _update_scroll_needed(self) -> None:
widget = self.widget()
if widget is None:
self._scroll_needed = False
self._h_scroll_needed = False
self._set_opacity_immediately(0.0)
self._set_horizontal_opacity_immediately(0.0)
self._hide_timer.stop()
self._h_hide_timer.stop()
return
content_height = widget.sizeHint().height()
content_width = widget.sizeHint().width()
viewport_height = self.viewport().height()
viewport_width = self.viewport().width()
self._scroll_needed = content_height > viewport_height
self._h_scroll_needed = content_width > viewport_width
if not self._scroll_needed:
self._set_opacity_immediately(0.0)
@@ -918,27 +1013,58 @@ class AutoHideScrollArea(QScrollArea):
if not self._is_visible:
self._set_opacity_immediately(0.0)
if not self._h_scroll_needed:
self._set_horizontal_opacity_immediately(0.0)
self._h_hide_timer.stop()
elif not self._h_is_visible:
self._set_horizontal_opacity_immediately(0.0)
def _on_scroll(self, value: int) -> None:
if not self._scroll_needed:
return
self._start_fade_in()
self._hide_timer.start(self.hide_delay_ms)
def _on_horizontal_scroll(self, value: int) -> None:
if not self._h_scroll_needed:
return
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
def enterEvent(self, event):
if self._scroll_needed:
self._start_fade_in()
self._hide_timer.start(self.hide_delay_ms)
if self._h_scroll_needed:
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
def leaveEvent(self, event):
if self._scroll_needed and self._is_visible:
self._hide_timer.start(self.hide_delay_ms)
if self._h_scroll_needed and self._h_is_visible:
self._h_hide_timer.start(self.hide_delay_ms)
def wheelEvent(self, event: QWheelEvent) -> None:
widget = self.widget()
horizontal_library = widget is not None and widget.property(
"library_layout_mode"
) in {"horizontal", "horizontal_top"}
if self._h_scrollbar.maximum() > 0 and (
horizontal_library or self._v_scrollbar.maximum() == 0
):
self._h_scrollbar.event(event)
return
super().wheelEvent(event)
def eventFilter(self, obj, event):
if not hasattr(self, "_v_scrollbar"):
if not hasattr(self, "_v_scrollbar") or not hasattr(self, "_h_scrollbar"):
return super().eventFilter(obj, event)
if event.type() == QEvent.Type.Resize:
self._update_scroll_needed()
if obj == self.viewport():
self.viewportResized.emit()
elif obj == self._v_scrollbar:
if event.type() == QEvent.Type.Enter:
if self._scroll_needed:
@@ -947,6 +1073,14 @@ class AutoHideScrollArea(QScrollArea):
elif event.type() == QEvent.Type.Leave:
if self._scroll_needed and self._is_visible:
self._hide_timer.start(self.hide_delay_ms)
elif obj == self._h_scrollbar:
if event.type() == QEvent.Type.Enter:
if self._h_scroll_needed:
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
elif event.type() == QEvent.Type.Leave:
if self._h_scroll_needed and self._h_is_visible:
self._h_hide_timer.start(self.hide_delay_ms)
return super().eventFilter(obj, event)
def setWidget(self, widget: QWidget | None) -> None:
+20 -5
View File
@@ -613,16 +613,23 @@ class DetailPageManager:
)
buttons_layout.addWidget(play_button)
if str(game_source).lower() == "steam" and appid:
if source in {"steam", "gog", "egs"} and appid:
edit_button = self._make_action_button(
_("Edit Shortcut"),
self.main_window.theme_manager.get_icon("edit", as_path=True),
)
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
game_name, exec_line, cover_path, appid
if source == "steam":
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
game_name, exec_line, cover_path, appid
)
)
else:
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager._edit_id_shortcut(
game_name, cover_path, (source, str(appid))
)
)
)
buttons_layout.addWidget(edit_button)
elif self._has_game_shortcut(game_name):
edit_button = self._make_action_button(
@@ -1016,6 +1023,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(
@@ -1191,6 +1201,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:
@@ -1441,6 +1454,8 @@ class DetailPageManager:
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
if resolved_exe_path is None:
return
if not self.main_window._check_alt_i586_dependencies_before_launch():
return
start_command = get_portproton_start_command()
if not start_command:
+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:
+27 -61
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."""
@@ -274,7 +240,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 +256,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)
@@ -352,7 +318,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()
@@ -418,15 +384,14 @@ class ProtonManager(DraggableDialog):
def filter_entries_by_cpu_level(self, entries, source_name):
is_arm = platform.machine().lower().startswith(('arm', 'aarch64'))
if not is_arm:
entries = [
entry for entry in entries
if 'aarch64' not in entry.get('name', '').lower()
and '-arm' not in entry.get('name', '').lower()
and 'aarch64' not in entry.get('url', '').lower()
and '-arm' not in entry.get('url', '').lower()
]
if source_name.lower() != 'proton_cachyos':
filtered_entries = []
for entry in entries:
entry_info = f"{entry.get('name', '')} {entry.get('url', '')}".lower()
entry_is_arm = 'aarch64' in entry_info or '-arm' in entry_info
if entry_is_arm == is_arm:
filtered_entries.append(entry)
entries = filtered_entries
if source_name.lower() not in ('proton_cachyos', 'proton_wineland'):
return entries
if self.cpu_level >= 4:
return entries
@@ -483,7 +448,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)
@@ -673,15 +638,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):
+424 -150
View File
@@ -3,13 +3,24 @@
import os
import re
import subprocess
from html import escape
from typing import cast, TYPE_CHECKING
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, QProcess, QTimer, QUrl
from PySide6.QtGui import QColor, QContextMenuEvent, QDesktopServices, QGuiApplication, QIcon
from PySide6.QtGui import (
QColor,
QContextMenuEvent,
QDesktopServices,
QGuiApplication,
QHideEvent,
QIcon,
QKeyEvent,
QPalette,
)
from PySide6.QtWidgets import (
QApplication,
QAbstractItemView,
QAbstractSpinBox,
QCheckBox,
QComboBox,
QHBoxLayout,
@@ -22,8 +33,10 @@ from PySide6.QtWidgets import (
QTabWidget,
QTableWidget,
QTableWidgetItem,
QPlainTextEdit,
QVBoxLayout,
QWidget,
QTextEdit,
)
if TYPE_CHECKING:
@@ -39,12 +52,14 @@ from portprotonqt.config import (
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
from portprotonqt.dialogs.base import DraggableDialog
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
from portprotonqt.dialogs.proton_manager import show_proton_manager
from portprotonqt.dialogs.settings_mangohud import MANGOHUD_ENV_KEYS, MangoHudSettingsMixin
from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, GamescopeSettingsMixin
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
from portprotonqt.localization import _, format_setting_name_for_display
from portprotonqt.logger import get_logger
from portprotonqt.preloader import Preloader
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
from portprotonqt.settings_manager import (
ADVANCED_SETTING_KEYS,
get_available_prefix_options,
@@ -65,8 +80,11 @@ TOGGLE_BOOL_KEYS = {
'PW_VKBASALT',
'PW_VKBASALT_USER_CONF',
}
DEFAULT_WINE_SETTING_KEYS = {
'PW_WINE_USE': 'PW_DEFAULT_WINE_USE',
'PW_PREFIX_NAME': 'PW_DEFAULT_PREFIX_NAME',
'PW_VULKAN_USE': 'PW_DEFAULT_VULKAN_USE',
}
def _normalize_prefix_directories(prefixes_dir):
if not os.path.isdir(prefixes_dir):
return
@@ -135,13 +153,15 @@ class ExeSettingsDialog(
):
"""Dialog for configuring executable-specific settings."""
def __init__(self, parent=None, theme=None, exe_path=None, appid=None, game_source=None):
def __init__(self, parent=None, theme=None, exe_path=None, appid=None, game_source=None,
user_conf=False):
super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(ui_config.get_theme())
self.exe_path = exe_path
self.appid = appid
self.game_source = str(game_source).lower() if game_source else ""
if not self.exe_path and not self.appid:
self.user_conf = user_conf
if not self.user_conf and not self.exe_path and not self.appid:
return
self.portproton_path = get_portproton_location()
if self.portproton_path is None:
@@ -154,12 +174,21 @@ class ExeSettingsDialog(
self.dist_options = []
self.lg_dist_aliases = {}
self.plugins_ver = ""
self.prefix_options = []
if self.portproton_path:
scripts_path = get_portproton_scripts_path()
if scripts_path:
var_path = os.path.join(scripts_path, "var")
self.lg_dist_aliases = read_lg_dist_versions_from_var(var_path)
try:
with open(var_path, encoding="utf-8") as var_file:
for line in var_file:
if line.startswith('export PW_PLUGINS_VER='):
self.plugins_ver = line.split('=', 1)[1].strip().strip('"\'')
break
except OSError as exc:
logger.warning("Failed to read PW_PLUGINS_VER: %s", exc)
system_wine_label = "" if self.game_source == "steam" else _('System WINE')
self.dist_options = get_available_wine_options(
self.portproton_path, system_wine_label, self.game_source == "steam"
@@ -185,7 +214,7 @@ class ExeSettingsDialog(
self.logical_core_options = []
self._gamepad_tooltip_map = {}
self.setWindowTitle(_("Exe Settings"))
self.setWindowTitle(_("Global Game Settings") if self.user_conf else _("Exe Settings"))
self.setModal(True)
self.resize(1100, 720)
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
@@ -194,9 +223,20 @@ class ExeSettingsDialog(
self.toggle_settings = get_toggle_settings()
self.setup_ui()
if self.user_conf:
self.open_ppdb_button.clicked.disconnect()
self.open_ppdb_button.setText(_("Edit user.conf"))
self.open_ppdb_button.clicked.connect(self.open_user_conf)
self.clear_ppdb_button.clicked.disconnect()
self.clear_ppdb_button.setText(_("Clear All"))
self.clear_ppdb_button.clicked.connect(self.clear_user_conf)
self.settings_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeMode.ResizeToContents
)
app = QApplication.instance()
if isinstance(app, QApplication):
app.focusChanged.connect(self._on_focus_changed)
app.installEventFilter(self)
self.input_manager = None
self.main_window = None
@@ -301,7 +341,6 @@ class ExeSettingsDialog(
self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt")
if self.gamescope_available:
self.tab_widget.addTab(self.gamescope_tab, "Gamescope")
self.tab_widget.currentChanged.connect(self.on_table_selection_changed)
self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True)
@@ -343,8 +382,6 @@ class ExeSettingsDialog(
self.settings_container.addWidget(settings_preloader_container)
self.settings_container.addWidget(self.settings_table)
self.main_tab_layout.addWidget(self.settings_container)
self.settings_table.currentCellChanged.connect(self.on_table_selection_changed)
self.settings_table.cellEntered.connect(self.on_table_cell_hovered)
self.settings_table.installEventFilter(self)
self.advanced_table = QTableWidget()
@@ -396,9 +433,13 @@ class ExeSettingsDialog(
self.favorites_table.customContextMenuRequested.connect(
lambda pos: self.show_setting_context_menu(self.favorites_table, pos)
)
for table in (self.settings_table, self.advanced_table, self.favorites_table):
table.viewport().setProperty("ppqt_settings_values", True)
table.viewport().installEventFilter(self)
table.setColumnHidden(2, True)
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
self.favorites_tab_layout.addWidget(self.favorites_table)
self.favorites_table.currentCellChanged.connect(self.on_table_selection_changed)
self.favorites_table.cellEntered.connect(self.on_table_cell_hovered)
self.advanced_preloader = Preloader()
advanced_preloader_container = QWidget()
@@ -417,8 +458,6 @@ class ExeSettingsDialog(
self.advanced_container.addWidget(advanced_preloader_container)
self.advanced_container.addWidget(self.advanced_table)
self.advanced_tab_layout.addWidget(self.advanced_container)
self.advanced_table.currentCellChanged.connect(self.on_table_selection_changed)
self.advanced_table.cellEntered.connect(self.on_table_cell_hovered)
self.advanced_table.installEventFilter(self)
self.setup_mangohud_tab()
@@ -472,7 +511,10 @@ class ExeSettingsDialog(
process = QProcess(self)
process.finished.connect(self.on_show_ppdb_finished)
args = self._get_process_args(["cli", "--show-ppdb", f"{self.exe_path}"])
command = ["cli", "--get-user-conf"] if self.user_conf else [
"cli", "--show-ppdb", f"{self.exe_path}"
]
args = self._get_process_args(command)
process.start(args[0], args[1:])
def on_show_ppdb_finished(self, exit_code, exit_status):
@@ -484,6 +526,13 @@ class ExeSettingsDialog(
self.logical_core_options = []
self.locale_options = []
if self.user_conf and (
exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit
):
QMessageBox.warning(self, _("Error"), _("Failed to load global game settings."))
self.reject()
return
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
for line in output.splitlines():
@@ -504,6 +553,7 @@ class ExeSettingsDialog(
if (
key in self.toggle_settings
or key in ADVANCED_SETTING_KEYS
or key in DEFAULT_WINE_SETTING_KEYS.values()
or key in MANGOHUD_ENV_KEYS
or key in GAMESCOPE_ENV_KEYS
or key in VKBASALT_ENV_KEYS
@@ -515,6 +565,8 @@ class ExeSettingsDialog(
self.current_settings[key] = val
except ValueError:
continue
if self.user_conf and self.plugins_ver:
self.current_settings['PW_PLUGINS_VER'] = self.plugins_ver
if self.game_source == "steam":
self.blocked_keys.update({
@@ -539,17 +591,18 @@ class ExeSettingsDialog(
self.current_settings[key] = ''
for key in VKBASALT_ENV_KEYS:
self.current_settings[key] = ''
else:
elif not self.user_conf:
self.current_settings.setdefault('PW_MANGOHUD', '0')
self.current_settings.setdefault('PW_VKBASALT', '0')
for key in self.blocked_keys:
self.current_settings[key] = '0'
current_wine_version = self.current_settings.get('PW_WINE_USE')
wine_key = 'PW_DEFAULT_WINE_USE' if self.user_conf else 'PW_WINE_USE'
current_wine_version = self.current_settings.get(wine_key)
if current_wine_version in self.lg_dist_aliases:
self.current_settings['PW_WINE_USE'] = self.lg_dist_aliases[current_wine_version]
current_wine_version = self.current_settings['PW_WINE_USE']
self.current_settings[wine_key] = self.lg_dist_aliases[current_wine_version]
current_wine_version = self.current_settings[wine_key]
if (
current_wine_version
and current_wine_version not in self.dist_options
@@ -560,7 +613,7 @@ class ExeSettingsDialog(
self.original_values = self.current_settings.copy()
for key in set(self.toggle_settings.keys()):
self.original_values.setdefault(key, '0')
self.original_values.setdefault(key, '' if self.user_conf else '0')
self.populate_table()
self.populate_advanced()
@@ -588,6 +641,14 @@ class ExeSettingsDialog(
if not QDesktopServices.openUrl(QUrl.fromLocalFile(db_path)):
QMessageBox.critical(self, _("Error"), _("Failed to open PPDB file:\n") + db_path)
def open_user_conf(self) -> None:
user_conf = os.path.join(cast(str, self.portproton_path), "data", "user.conf")
if not os.path.exists(user_conf):
QMessageBox.critical(self, _("Error"), _("Failed to open file:\n") + user_conf)
return
if not QDesktopServices.openUrl(QUrl.fromLocalFile(user_conf)):
QMessageBox.critical(self, _("Error"), _("Failed to open file:\n") + user_conf)
def clear_ppdb_file(self):
"""Remove the PPDB file and reload settings."""
if not self.exe_path:
@@ -620,6 +681,40 @@ class ExeSettingsDialog(
self.load_current_settings()
def clear_user_conf(self) -> None:
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Icon.Question)
msg_box.setWindowTitle(_("Confirm Clear"))
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
user_conf = os.path.join(cast(str, self.portproton_path), "data", "user.conf")
try:
os.remove(user_conf)
except FileNotFoundError:
pass
except OSError as exc:
logger.warning("Failed to remove user.conf: %s", exc)
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
return
screen_resolution, screen_primary = get_screen_info()
self.user_conf_changes = [
screen_resolution,
screen_primary,
f"PW_WINE_DPI_VALUE={get_system_dpi_for_wine()}",
]
self.user_conf_changes = [
change for change in self.user_conf_changes if change.split('=', 1)[1]
]
self.close_after_user_conf_changes = False
self.apply_button.setEnabled(False)
self._apply_next_user_conf_change()
def populate_table(self):
"""Populate the table with settings."""
self.settings_table.setRowCount(0)
@@ -640,27 +735,46 @@ class ExeSettingsDialog(
name_item.setData(Qt.ItemDataRole.UserRole, toggle)
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
current_val = self.current_settings.get(toggle, '0')
current_val = self.current_settings.get(toggle, '') if self.user_conf else (
self.current_settings.get(toggle, '0')
)
is_blocked = toggle in self.blocked_keys
checkbox_widget = QCheckBox()
checkbox_widget.setStyleSheet(self.theme.CHECKBOX_STYLE)
checkbox_widget.setChecked(current_val == '1' and not is_blocked)
checkbox_widget.setEnabled(not is_blocked)
checkbox_widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
checkbox_widget.installEventFilter(self)
checkbox_container = QWidget()
checkbox_container.setStyleSheet(self.theme.CHECKBOX_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
checkbox_layout = QHBoxLayout(checkbox_container)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.addWidget(checkbox_widget)
checkbox_container.setFocusPolicy(Qt.FocusPolicy.NoFocus)
checkbox_item = QTableWidgetItem()
checkbox_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
if self.user_conf:
value_widget = CustomComboBox(theme=self.theme)
value_widget.setObjectName("settingsTableCombo")
value_widget.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
)
value_widget.view().window().setAttribute(
Qt.WidgetAttribute.WA_TranslucentBackground
)
value_widget.addItem(_("Default"), "")
value_widget.addItem(_("Yes"), "1")
value_widget.addItem(_("No"), "0")
value_widget.setCurrentIndex(max(0, value_widget.findData(current_val)))
else:
value_widget = QCheckBox()
value_widget.setStyleSheet(self.theme.CHECKBOX_STYLE)
value_widget.setChecked(current_val == '1' and not is_blocked)
value_widget.setEnabled(not is_blocked)
value_widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
value_widget.installEventFilter(self)
if is_blocked:
name_item.setForeground(QColor(self.theme.color_disabled_text))
self.settings_table.setItem(row, 1, checkbox_item)
self.settings_table.setCellWidget(row, 1, checkbox_container)
if self.user_conf:
self.settings_table.setCellWidget(row, 1, value_widget)
else:
checkbox_container = QWidget()
checkbox_container.setStyleSheet(self.theme.CHECKBOX_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
checkbox_layout = QHBoxLayout(checkbox_container)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.addWidget(value_widget)
checkbox_container.setFocusPolicy(Qt.FocusPolicy.NoFocus)
checkbox_item = QTableWidgetItem()
checkbox_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
self.settings_table.setItem(row, 1, checkbox_item)
self.settings_table.setCellWidget(row, 1, checkbox_container)
desc_item = QTableWidgetItem(description)
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
@@ -669,8 +783,9 @@ class ExeSettingsDialog(
desc_item.setForeground(QColor(self.theme.color_disabled_text))
self.settings_table.setItem(row, 2, desc_item)
name_item.setText(f"{name_item.text()}\n{description}")
self.settings_table.setItem(row, 0, name_item)
self.value_widgets[(row, 1)] = checkbox_widget
self.value_widgets[(row, 1)] = value_widget
self.settings_table.resizeRowsToContents()
if self.settings_table.rowCount() > 0:
@@ -682,7 +797,6 @@ class ExeSettingsDialog(
else:
self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason)
self.on_table_selection_changed()
def populate_advanced(self):
"""Populate the advanced tab with table format."""
@@ -702,6 +816,11 @@ class ExeSettingsDialog(
dist_options=self.dist_options,
prefix_options=self.prefix_options
)
if self.user_conf:
for setting in advanced_settings:
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
if setting['type'] == 'combo':
setting['options'] = [_('Default')] + setting['options']
self.advanced_settings_by_key = {
setting['key']: setting for setting in advanced_settings
}
@@ -726,7 +845,15 @@ class ExeSettingsDialog(
Qt.WidgetAttribute.WA_TranslucentBackground
)
combo.addItems(setting['options'])
if setting['key'] == 'PW_PREFIX_NAME':
if setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
combo.insertSeparator(combo.count())
combo.addItem(_('Download Wine/Proton...'), 'download_wine')
combo.activated.connect(
lambda index, c=combo, k=setting['key']: self._open_wine_manager_from_combo(
index, c, k
)
)
if setting['key'] in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
combo.setEditable(True)
combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
prefix_line_edit = combo.lineEdit()
@@ -735,17 +862,21 @@ class ExeSettingsDialog(
combo.highlighted.connect(
lambda row, c=combo: self._on_combo_highlighted(row, c),
)
elif setting['key'] == 'PW_WINE_USE':
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
combo.highlighted.connect(
lambda row, c=combo: self._on_combo_highlighted(row, c),
)
current_raw = current.get(setting['key'], setting['default'])
if setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
current_raw = current.get(setting['key'], '') if self.user_conf else (
current.get(setting['key'], setting['default'])
)
if self.user_conf and not current_raw:
current_val = _('Default')
elif setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
current_val = disabled_text if current_raw == 'disabled' else (
current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw
)
elif setting['key'] == 'PW_WINE_USE':
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
current_val = _('System WINE') if current_raw == 'USE_SYSTEM_WINE' else current_raw
else:
current_val = disabled_text if current_raw == 'disabled' else current_raw
@@ -781,6 +912,9 @@ class ExeSettingsDialog(
else:
self.original_display_values[setting['key']] = current_val_text
if self.user_conf and not current_raw:
self.original_display_values[setting['key']] = ''
if '_value_map' in setting:
reverse_map = {v: k for k, v in setting['_value_map'].items()}
self.value_mapping[setting['key']] = {
@@ -790,7 +924,9 @@ class ExeSettingsDialog(
elif setting['type'] == 'text':
line_edit = QLineEdit()
current_val = current.get(setting['key'], setting['default'])
current_val = current.get(setting['key'], '') if self.user_conf else (
current.get(setting['key'], setting['default'])
)
line_edit.setText(current_val)
if not current_val and not setting['default']:
line_edit.setPlaceholderText(_("Default value"))
@@ -844,6 +980,7 @@ class ExeSettingsDialog(
self.advanced_widgets[setting['key']] = line_edit
self.original_display_values[setting['key']] = current_val
name_item.setText(f"{setting['name']}\n{setting['description']}")
desc_item = QTableWidgetItem(setting['description'])
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
@@ -851,8 +988,7 @@ class ExeSettingsDialog(
desc_item.setForeground(QColor(self.theme.color_disabled_text))
self.advanced_table.setItem(row, 2, desc_item)
if self.advanced_table.rowCount() > 0:
self.on_table_selection_changed()
self.advanced_table.resizeRowsToContents()
self._install_line_edit_event_filters()
def _get_current_settings_table(self) -> QTableWidget | None:
@@ -960,7 +1096,7 @@ class ExeSettingsDialog(
source_header = source_table.horizontalHeader()
target_header = self.favorites_table.horizontalHeader()
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
target_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
target_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
target_header.resizeSection(0, source_header.sectionSize(0))
@@ -996,6 +1132,10 @@ class ExeSettingsDialog(
source_widget = self._find_toggle_widget(key)
checkbox = QCheckBox()
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
if isinstance(source_widget, QComboBox):
self.favorites_table.removeCellWidget(row, 1)
self._add_favorite_combo(row, source_widget)
return
checkbox.setChecked(source_widget.isChecked() if source_widget else False)
checkbox.setEnabled(source_widget.isEnabled() if source_widget else False)
checkbox.stateChanged.connect(
@@ -1030,7 +1170,7 @@ class ExeSettingsDialog(
self._add_favorite_line_edit(row, source_widget)
def _set_favorite_text_cells(self, row: int, key: str, name: str, description: str) -> None:
name_item = QTableWidgetItem(name)
name_item = QTableWidgetItem(f"{name}\n{description}")
name_item.setData(Qt.ItemDataRole.UserRole, key)
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
desc_item = QTableWidgetItem(description)
@@ -1039,7 +1179,7 @@ class ExeSettingsDialog(
self.favorites_table.setItem(row, 0, name_item)
self.favorites_table.setItem(row, 2, desc_item)
def _find_toggle_widget(self, key: str) -> QCheckBox | None:
def _find_toggle_widget(self, key: str) -> QCheckBox | QComboBox | None:
for (row, _column), widget in self.value_widgets.items():
item = self.settings_table.item(row, 0)
if item and item.data(Qt.ItemDataRole.UserRole) == key:
@@ -1047,8 +1187,8 @@ class ExeSettingsDialog(
return None
def _sync_checkbox(self, source_widget: QCheckBox | None, checkbox: QCheckBox) -> None:
if source_widget is not None and source_widget.isChecked() != checkbox.isChecked():
source_widget.setChecked(checkbox.isChecked())
if source_widget is not None and source_widget.checkState() != checkbox.checkState():
source_widget.setCheckState(checkbox.checkState())
def _add_favorite_combo(self, row: int, source_widget: QComboBox) -> None:
combo = CustomComboBox(theme=self.theme)
@@ -1109,6 +1249,10 @@ class ExeSettingsDialog(
def _on_combo_highlighted(self, row: int, combo: QComboBox) -> None:
view = combo.view()
view.installEventFilter(self)
if not self.isVisible() or not combo.isVisible() or not view.isVisible():
self.show_gamepad_tooltip(show=False)
return
index = view.model().index(row, 0)
if not index.isValid():
self.gamepad_tooltip.hide()
@@ -1118,10 +1262,14 @@ class ExeSettingsDialog(
self.gamepad_tooltip.hide()
return
fm = view.fontMetrics()
item_rect = view.visualRect(index)
text_width = max(fm.horizontalAdvance(text), view.sizeHintForIndex(index).width())
if text_width <= item_rect.intersected(view.viewport().rect()).width():
self.show_gamepad_tooltip(show=False)
return
text_rect = fm.boundingRect(0, 0, 480, 1000, Qt.TextFlag.TextWordWrap, text)
w = min(500, text_rect.width() + 25)
h = min(300, text_rect.height() + 25)
item_rect = view.visualRect(index)
item_center_y = view.viewport().mapToGlobal(item_rect.center()).y()
combo_right_x = combo.mapToGlobal(combo.rect().topRight()).x()
pos = QPoint(combo_right_x + 4, item_center_y - h // 2)
@@ -1140,6 +1288,43 @@ class ExeSettingsDialog(
self.gamepad_tooltip.setVisible(True)
self.gamepad_tooltip_timer.start(max(2500, min(12000, 1500 + len(text) * 30)))
def _open_wine_manager_from_combo(self, index: int, combo: QComboBox, key: str) -> None:
if combo.itemData(index) != 'download_wine':
return
combo.setCurrentText(self.original_display_values.get(key, ''))
show_proton_manager(
self, self.portproton_path, input_manager=self.input_manager
)
def refresh_wine_combo(self) -> None:
wine_key = 'PW_DEFAULT_WINE_USE' if self.user_conf else 'PW_WINE_USE'
combo = self.advanced_widgets.get(wine_key)
if not self.portproton_path or not isinstance(combo, QComboBox):
return
current_text = combo.currentText()
self.dist_options = get_available_wine_options(
self.portproton_path,
'' if self.game_source == 'steam' else _('System WINE'),
self.game_source == 'steam',
)
options = next(setting['options'] for setting in get_advanced_settings(
disabled_text=_('disabled'),
logical_core_options=self.logical_core_options,
numa_nodes=self.numa_nodes,
dist_options=self.dist_options,
prefix_options=self.prefix_options,
) if setting['key'] == 'PW_WINE_USE')
if self.user_conf:
options = [_('Default')] + options
combo.clear()
combo.addItems(options)
if current_text and current_text not in options:
combo.addItem(current_text)
combo.insertSeparator(combo.count())
combo.addItem(_('Download Wine/Proton...'), 'download_wine')
combo.setCurrentText(current_text)
def _select_checkbox_row(self, widget: QCheckBox) -> bool:
for (row, column), checkbox in self.value_widgets.items():
if checkbox != widget:
@@ -1173,7 +1358,7 @@ class ExeSettingsDialog(
desc_item = self.favorites_table.item(row, 2)
should_show = False
if name_item and search_text in name_item.text().lower():
if name_item and search_text in (name_item.data(Qt.ItemDataRole.AccessibleTextRole) or name_item.text()).lower():
should_show = True
elif desc_item and search_text in desc_item.text().lower():
should_show = True
@@ -1185,7 +1370,7 @@ class ExeSettingsDialog(
desc_item = self.settings_table.item(row, 2)
should_show = False
if name_item and search_text in name_item.text().lower():
if name_item and search_text in (name_item.data(Qt.ItemDataRole.AccessibleTextRole) or name_item.text()).lower():
should_show = True
elif desc_item and search_text in desc_item.text().lower():
should_show = True
@@ -1197,7 +1382,7 @@ class ExeSettingsDialog(
desc_item = self.advanced_table.item(row, 2)
should_show = False
if name_item and search_text in name_item.text().lower():
if name_item and search_text in (name_item.data(Qt.ItemDataRole.AccessibleTextRole) or name_item.text()).lower():
should_show = True
elif desc_item and search_text in desc_item.text().lower():
should_show = True
@@ -1226,10 +1411,12 @@ class ExeSettingsDialog(
continue
widget = self.value_widgets.get((row, 1))
if not isinstance(widget, QCheckBox):
if isinstance(widget, QComboBox) and self.user_conf:
new_val = str(widget.currentData() or '')
elif isinstance(widget, QCheckBox):
new_val = '1' if widget.isChecked() else '0'
else:
continue
new_val = '1' if widget.isChecked() else '0'
if new_val != orig_val:
changes.append(f"{key}={new_val}")
# Track if PW_MANGOHUD is being enabled
@@ -1240,9 +1427,11 @@ class ExeSettingsDialog(
orig_val = self.original_display_values.get(key, '')
if isinstance(widget, QComboBox):
new_val = widget.currentText()
if self.user_conf and new_val == _('Default'):
new_val = ''
if key in ('PW_PREFIX_NAME', 'PW_VULKAN_USE') and self.game_source == "steam":
continue
if key == 'PW_PREFIX_NAME':
if key in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
new_val = re.sub(r"[ \t]", "_", new_val.strip()).upper()
if key in self.value_mapping and 'forward' in self.value_mapping[key]:
@@ -1253,7 +1442,7 @@ class ExeSettingsDialog(
else:
has_changed = (new_val != orig_val)
if key == 'PW_WINE_USE' and new_val == _('System WINE'):
if key in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE') and new_val == _('System WINE'):
new_val = 'USE_SYSTEM_WINE'
if new_val.lower() == _('disabled').lower():
@@ -1275,9 +1464,32 @@ class ExeSettingsDialog(
gamescope_changes = []
if self.gamescope_available:
gamescope_changes = self._collect_gamescope_changes()
changes.extend(mangohud_changes)
changes.extend(self._collect_vkbasalt_changes())
changes.extend(gamescope_changes)
vkbasalt_changes = self._collect_vkbasalt_changes()
if self.user_conf:
if self.current_settings.get('PW_MANGOHUD') != '1':
mangohud_changes = [
change for change in mangohud_changes
if change.startswith(('PW_MANGOHUD=', 'PW_MANGOHUD_USER_CONF='))
]
if self.current_settings.get('PW_VKBASALT') != '1':
vkbasalt_changes = [
change for change in vkbasalt_changes
if change.startswith(('PW_VKBASALT=', 'PW_VKBASALT_USER_CONF='))
]
if self.current_settings.get('PW_GAMESCOPE') != '1':
gamescope_changes = [
change for change in gamescope_changes
if change.startswith('PW_GAMESCOPE=')
]
specialized_changes = mangohud_changes + vkbasalt_changes + gamescope_changes
if self.user_conf:
defaults = ('', '0', '0.00', 'Home')
specialized_changes = [
change for change in specialized_changes
if change.split('=', 1)[0] in self.original_values
or change.split('=', 1)[1] not in defaults
]
changes.extend(specialized_changes)
# Check if PW_GAMESCOPE toggle changes are already in the list
has_gamescope_toggle = any(change.startswith("PW_GAMESCOPE=") for change in gamescope_changes)
@@ -1298,6 +1510,13 @@ class ExeSettingsDialog(
if not changes:
return
if self.user_conf:
self.user_conf_changes = changes
self.close_after_user_conf_changes = True
self.apply_button.setEnabled(False)
self._apply_next_user_conf_change()
return
process = QProcess(self)
process.finished.connect(self.on_edit_db_finished)
process_args = ["cli", "--edit-db", self.exe_path] + changes
@@ -1305,6 +1524,36 @@ class ExeSettingsDialog(
process.start(args[0], args[1:])
self.apply_button.setEnabled(False)
def _apply_next_user_conf_change(self) -> None:
if not self.user_conf_changes:
self.apply_button.setEnabled(True)
if self.close_after_user_conf_changes:
self.close()
else:
self.load_current_settings()
return
key, value = self.user_conf_changes.pop(0).split('=', 1)
action = "--set-user-conf" if value else "--delete-user-conf"
command = ["cli", action, key]
if value:
command.append(value)
self.user_conf_process = QProcess(self)
self.user_conf_process.finished.connect(self._on_user_conf_change_finished)
args = self._get_process_args(command)
self.user_conf_process.start(args[0], args[1:])
def _on_user_conf_change_finished(self, exit_code, exit_status) -> None:
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
error_output = bytes(self.user_conf_process.readAllStandardError().data()).decode(
'utf-8', 'ignore'
)
self.apply_button.setEnabled(True)
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
logger.error("Failed to update user.conf: %s", error_output)
return
self._apply_next_user_conf_change()
def on_edit_db_finished(self, exit_code, exit_status):
"""Handle --edit-db output."""
process = cast(QProcess, self.sender())
@@ -1318,6 +1567,9 @@ class ExeSettingsDialog(
self.close()
def closeEvent(self, event):
app = QApplication.instance()
if isinstance(app, QApplication):
app.removeEventFilter(self)
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
if self.input_manager:
@@ -1325,6 +1577,111 @@ class ExeSettingsDialog(
super().closeEvent(event)
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
if (
event.type() == QEvent.Type.KeyPress
and self.isVisible()
and QApplication.activeWindow() is self
):
focused_widget = QApplication.focusWidget()
if focused_widget is None or focused_widget.window() is self:
text_input = isinstance(
focused_widget,
(QLineEdit, QTextEdit, QPlainTextEdit, QAbstractSpinBox, QComboBox),
)
if focused_widget is not self.search_edit and not text_input:
key_event = cast(QKeyEvent, event)
modifiers = (
Qt.KeyboardModifier.ControlModifier
| Qt.KeyboardModifier.AltModifier
| Qt.KeyboardModifier.MetaModifier
)
if not key_event.modifiers() & modifiers:
if key_event.key() == Qt.Key.Key_Backspace:
search_text = self.search_edit.text()
if search_text:
self.search_edit.setText(search_text[:-1])
return True
if key_event.text() and key_event.text().isprintable():
self.search_edit.setText(self.search_edit.text() + key_event.text())
return True
if isinstance(obj, QAbstractItemView) and event.type() == QEvent.Type.Show:
combo = obj.window().parentWidget()
if isinstance(combo, QComboBox):
self._on_combo_highlighted(obj.currentIndex().row(), combo)
return False
if isinstance(obj, QAbstractItemView) and event.type() == QEvent.Type.Hide:
self.show_gamepad_tooltip(show=False)
return False
if event.type() == QEvent.Type.MouseButtonPress and obj.property("ppqt_settings_values"):
cast(QTableWidget, obj.parent()).clearFocus()
return False
if event.type() == QEvent.Type.Paint and obj.property("ppqt_settings_values"):
table = cast(QTableWidget, obj.parent())
for row in range(table.rowCount()):
item = table.item(row, 0)
if item is None:
continue
label = cast(QLabel | None, table.cellWidget(row, 0))
if label is None:
text = item.text()
label = QLabel()
label.setTextFormat(Qt.TextFormat.RichText)
label.setWordWrap(True)
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
item.setText("")
table.setCellWidget(row, 0, label)
label.setAutoFillBackground(False)
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
selected = row == table.currentRow()
palette = table.palette()
if selected:
palette.setColor(QPalette.ColorRole.Text, QColor(self.theme.color_text_accent_hover))
palette.setColor(QPalette.ColorRole.Window, QColor(self.theme.color_accent))
elif item.data(Qt.ItemDataRole.ForegroundRole) is not None:
palette.setBrush(QPalette.ColorRole.Text, item.foreground())
label.setAutoFillBackground(selected)
label.setPalette(palette)
if label.foregroundRole() != QPalette.ColorRole.Text:
label.setForegroundRole(QPalette.ColorRole.Text)
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
name, separator, description = text.partition("\n")
formatted_text = escape(name)
if separator:
description_style = (
self.theme.SETTINGS_SELECTED_DESCRIPTION_HTML if selected
else self.theme.SETTINGS_DESCRIPTION_HTML
)
formatted_text += "<br>" + description_style.format(
description=escape(description).replace("\n", "<br>"),
)
if selected:
formatted_text = self.theme.SETTINGS_SELECTED_TEXT_HTML.format(text=formatted_text)
if label.text() != formatted_text:
label.setText(formatted_text)
width = max(1, table.columnWidth(0))
height_key = [width, formatted_text, label.font().toString()]
if label.property("ppqt_height_key") != height_key:
height = label.heightForWidth(width) + table.fontMetrics().height()
label.setProperty("ppqt_height_key", height_key)
table.setRowHeight(row, height)
for row in range(table.rowCount()):
widget = table.cellWidget(row, 1)
if widget is None:
continue
rect = table.visualRect(table.model().index(row, 1))
height = widget.sizeHint().height()
widget.setFixedHeight(height)
widget.setGeometry(
rect.left(), rect.top() + (rect.height() - height) // 2,
rect.width(), height,
)
return False
if isinstance(obj, QWidget) and self._handle_vkbasalt_key_button_event(obj, event):
return True
@@ -1433,92 +1790,9 @@ class ExeSettingsDialog(
self.gamepad_tooltip_timer.stop()
self.gamepad_tooltip.setVisible(False)
def get_current_description(self):
"""Get the description text for the currently selected row."""
current_table = self._get_current_settings_table()
if current_table is None:
return ""
current_row = current_table.currentRow()
if current_row >= 0:
desc_item = current_table.item(current_row, 2)
if desc_item:
return desc_item.text()
return ""
def _is_description_clipped(self, table: QTableWidget, row: int) -> bool:
"""Check whether description text is clipped in the table cell."""
if row < 0:
return False
desc_item = table.item(row, 2)
if not desc_item:
return False
description = desc_item.text()
if not description:
return False
item_rect = table.visualRect(table.model().index(row, 2))
if not item_rect.isValid() or item_rect.width() <= 0 or item_rect.height() <= 0:
return False
wrap_rect = table.fontMetrics().boundingRect(
0,
0,
max(1, item_rect.width() - 12),
10000,
Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextExpandTabs,
description
)
if wrap_rect.height() > (item_rect.height() - 6):
return True
single_line_width = table.fontMetrics().horizontalAdvance(description)
return single_line_width > (item_rect.width() - 12)
def on_table_selection_changed(self):
"""Called when table selection changes to update the gamepad tooltip."""
current_table = self._get_current_settings_table()
if current_table is None:
self.show_gamepad_tooltip(show=False)
return
current_column = current_table.currentColumn() if current_table else -1
if current_column != 2:
self.show_gamepad_tooltip(show=False)
return
current_row = current_table.currentRow()
if not self._is_description_clipped(current_table, current_row):
self.show_gamepad_tooltip(show=False)
return
description = self.get_current_description()
if description:
self.show_gamepad_tooltip(show=True, text=description)
else:
self.show_gamepad_tooltip(show=False)
def on_table_cell_hovered(self, row, column):
"""Show custom tooltip on hover for description cells."""
if column != 2:
self.show_gamepad_tooltip(show=False)
return
table = cast(QTableWidget | None, self.sender())
if table is None:
self.show_gamepad_tooltip(show=False)
return
desc_item = table.item(row, 2)
description = desc_item.text() if desc_item else ""
should_show_tooltip = self._is_description_clipped(table, row) or len(description) > 80
if description and should_show_tooltip:
item_rect = table.visualRect(table.model().index(row, 2))
cell_pos = table.mapToGlobal(item_rect.bottomLeft())
self.show_gamepad_tooltip(show=True, text=description, anchor_global_pos=cell_pos)
else:
self.show_gamepad_tooltip(show=False)
def hideEvent(self, event: QHideEvent) -> None:
self.show_gamepad_tooltip(show=False)
super().hideEvent(event)
def reject(self):
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
+78 -4
View File
@@ -3,6 +3,7 @@
import configparser
import os
import re
import subprocess
from pathlib import Path
from typing import Any, cast
@@ -151,8 +152,14 @@ MANGOHUD_VALUE_SPECS = [
'options': [f'{i / 10:.1f}' for i in range(11)]},
{'key': 'round_corners', 'label': _("Round corners (px)"), 'type': 'combo',
'options': [str(i) for i in range(16)]},
{'key': 'font_scale', 'label': _("Font scale"), 'type': 'combo',
'options': ['', '0.5', '0.75', '1.0', '1.25', '1.5', '2.0']},
{'key': 'font_size', 'label': _("Font size"), 'type': 'line'},
{'key': 'font_file', 'label': _("Font"), 'type': 'combo', 'options': []},
]
MANGOHUD_FONT_KEYS = {'font_scale', 'font_size', 'font_file'}
MANGOHUD_VALUE_OPTION_TRANSLATIONS = {
'position': {
'top-left': _("Top-left"),
@@ -391,6 +398,7 @@ class MangoHudSettingsMixin:
self.mangohud_presets_group = None
self.mangohud_toggle_group = None
self.mangohud_values_group = None
self.mangohud_fonts_group = None
self.mangohud_fps_group = None
self.mangohud_extra_group = None
self.mangohud_action_buttons = []
@@ -412,6 +420,7 @@ class MangoHudSettingsMixin:
self._add_mangohud_presets_group(layout)
self._add_mangohud_toggle_group(layout)
self._add_mangohud_values_group(layout)
self._add_mangohud_fonts_group(layout)
self._add_mangohud_fps_group(layout)
self._add_mangohud_extra_group(layout)
layout.addStretch()
@@ -433,6 +442,8 @@ class MangoHudSettingsMixin:
gpu_count = sum(1 for _text, value in self.mangohud_gpu_options if value.isdigit())
for spec in MANGOHUD_VALUE_SPECS:
if spec['key'] in MANGOHUD_FONT_KEYS:
continue
if spec['key'] == 'fps_limit_method':
continue
if spec['key'] == 'gpu_list' and gpu_count < 2:
@@ -441,8 +452,32 @@ class MangoHudSettingsMixin:
parent_layout.addWidget(group)
def _add_mangohud_fonts_group(self, parent_layout):
"""Add MangoHud font controls."""
group = QGroupBox(_("Fonts"))
group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
form = QFormLayout(group)
form.setVerticalSpacing(self.theme.exeSettingsGroupBoxElementVerticalSpacing)
form.setHorizontalSpacing(self.theme.exeSettingsGroupBoxElementHorizontalSpacing)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.mangohud_fonts_group = group
for spec in MANGOHUD_VALUE_SPECS:
if spec['key'] in MANGOHUD_FONT_KEYS:
form.addRow(spec['label'], self._create_mangohud_value_widget(spec))
parent_layout.addWidget(group)
def _create_mangohud_value_widget(self, spec):
"""Create a MangoHud value widget."""
if spec['type'] == 'line':
widget = QLineEdit()
widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
widget.installEventFilter(cast(QWidget, self))
widget.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.mangohud_widgets[spec['key']] = widget
return widget
widget = CustomComboBox(theme=self.theme)
widget.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
@@ -458,6 +493,10 @@ class MangoHudSettingsMixin:
gpu_options = self.mangohud_gpu_options or self._get_mangohud_gpu_options()
for text, value in gpu_options:
widget.addItem(text, value)
elif spec['key'] == 'font_file':
widget.addItem(placeholder_text, '')
for text, value in self._get_mangohud_font_options():
widget.addItem(text, value)
else:
value_translations = MANGOHUD_VALUE_OPTION_TRANSLATIONS.get(spec['key'], {})
for option in options:
@@ -475,6 +514,30 @@ class MangoHudSettingsMixin:
self.mangohud_widgets[spec['key']] = widget
return widget
def _get_mangohud_font_options(self) -> list[tuple[str, str]]:
"""Get installed font names and files from fontconfig."""
try:
result = subprocess.run(
['fc-list', '--format=%{family[0]} %{style[0]}\t%{file}\n'],
capture_output=True,
text=True,
check=False,
timeout=3,
)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("Failed to list system fonts: %s", exc)
return []
if result.returncode != 0:
logger.warning("fc-list failed with exit code %s", result.returncode)
return []
fonts = set()
for line in result.stdout.splitlines():
name, separator, path = line.partition('\t')
if separator and path.lower().endswith(('.ttf', '.otf')):
fonts.add((name.strip(), path.strip()))
return sorted(fonts, key=lambda font: font[0].lower())
def _add_mangohud_presets_group(self, parent_layout):
"""Add preset buttons for common MangoHud layouts."""
presets_group = QGroupBox(_("Presets"))
@@ -866,6 +929,8 @@ class MangoHudSettingsMixin:
self.mangohud_presets_group.setVisible(config_visible)
if self.mangohud_values_group is not None:
self.mangohud_values_group.setVisible(config_visible)
if self.mangohud_fonts_group is not None:
self.mangohud_fonts_group.setVisible(config_visible)
if self.mangohud_fps_group is not None:
self.mangohud_fps_group.setVisible(config_visible)
if self.mangohud_extra_group is not None:
@@ -1065,6 +1130,9 @@ class MangoHudSettingsMixin:
if widget is None:
return
text = value if isinstance(value, str) else ''
if isinstance(widget, QLineEdit):
widget.setText(text)
return
index = widget.findData(text)
if text and index < 0:
widget.addItem(text, text)
@@ -1179,10 +1247,13 @@ class MangoHudSettingsMixin:
if widget is None:
return ''
current_data = widget.currentData()
value = '' if current_data is None else str(current_data).strip()
if not value and current_data is None:
value = widget.currentText().strip()
if isinstance(widget, QLineEdit):
value = widget.text().strip()
else:
current_data = widget.currentData()
value = '' if current_data is None else str(current_data).strip()
if not value and current_data is None:
value = widget.currentText().strip()
if not value:
return ''
parsed_original, _raw_tokens = self._parse_mangohud_config(
@@ -1220,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():
+25 -5
View File
@@ -38,17 +38,32 @@ class EGSAPI:
def __init__(self) -> None:
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
self.data_dir = data_home / "PortProtonQt" / "egs"
self.data_dir = data_home / "PortProtonQt" / "launcher" / "egs"
self.bin_dir = data_home / "PortProtonQt" / "bin"
# Remove after the legacy launcher storage migration period.
legacy_dir = data_home / "PortProtonQt" / "egs"
if legacy_dir.exists() and not self.data_dir.exists():
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
legacy_dir.rename(self.data_dir)
legacy_bin = self.data_dir / "bin"
if legacy_bin.is_dir():
self.bin_dir.mkdir(parents=True, exist_ok=True)
for legacy_binary in legacy_bin.iterdir():
target = self.bin_dir / legacy_binary.name
if not target.exists():
legacy_binary.rename(target)
if not any(legacy_bin.iterdir()):
legacy_bin.rmdir()
self.config_dir = self.data_dir / "legendary"
self.user_path = self.config_dir / "user.json"
self.library_path = self.data_dir / "library.json"
self.sizes_path = self.data_dir / "sizes.json"
self.version_path = self.data_dir / "bin/legendary.version"
self.version_path = self.bin_dir / "legendary.version"
self.games_dir = Path.home() / "Games"
def get_legendary_path(self) -> str | None:
"""Return an available Legendary executable."""
bundled = self.data_dir / "bin/legendary"
bundled = self.bin_dir / "legendary"
if bundled.is_file() and os.access(bundled, os.X_OK):
return str(bundled)
return shutil.which("legendary")
@@ -63,7 +78,7 @@ class EGSAPI:
def update_legendary(self) -> str:
"""Update the bundled Legendary binary when a newer release exists."""
bundled = self.data_dir / "bin/legendary"
bundled = self.bin_dir / "legendary"
if bundled.is_file() and os.access(bundled, os.X_OK):
try:
last_check = self.version_path.stat().st_mtime
@@ -109,7 +124,7 @@ class EGSAPI:
return ""
def _download_legendary(self, asset: dict, release_tag: str) -> str:
target = self.data_dir / "bin/legendary"
target = self.bin_dir / "legendary"
temporary = target.with_suffix(".part")
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
@@ -218,6 +233,8 @@ class EGSAPI:
games = []
for game in raw_games:
metadata = game.get("metadata", {})
if metadata.get("mainGameItem"):
continue
release_info = metadata.get("releaseInfo", [])
mobile_only = bool(release_info) and all(
info.get("platform")
@@ -444,6 +461,9 @@ class EGSAPI:
"description": str(metadata.get("description", "")),
"cover": cover,
"namespace": str(metadata.get("namespace", "")),
"folder_name": str(metadata.get("customAttributes", {}).get(
"FolderName", {}
).get("value") or game.get("app_name") or game.get("appName") or ""),
"description_locale": "",
"description_preference": "",
"description_cache_version": 0,
+76 -24
View File
@@ -277,7 +277,15 @@ 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)
def enterEvent(self, event: QEnterEvent) -> None:
self.raise_()
@@ -313,7 +321,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 +330,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 +349,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
@@ -397,7 +407,8 @@ class GameCard(AnimatedCard):
config_name = "GAME_CARD_HORIZONTAL"
else:
config_name = "GAME_CARD_GRID"
self.card_layout_cfg = getattr(self.theme, config_name, {})
self.card_layout_cfg = self._get_layout_config(self.theme, config_name)
self.card_geometry_cfg = self._get_card_geometry_config(self.theme)
default_margin = 8 if self.list_layout else 20
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
card_style_name = (
@@ -453,6 +464,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:
@@ -551,6 +563,8 @@ class GameCard(AnimatedCard):
self.nameLabel.setContentsMargins(0, 0, 10, 0)
else:
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
if self.horizontal_layout:
self.nameLabel.setWordWrap(True)
name_style = (
self.theme.GAME_CARD_VERTICAL_NAME_STYLE
if self.vertical_layout
@@ -570,7 +584,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"
)
@@ -608,7 +622,8 @@ class GameCard(AnimatedCard):
config_name = "GAME_CARD_HORIZONTAL"
else:
config_name = "GAME_CARD_GRID"
self.card_layout_cfg = getattr(theme, config_name, {})
self.card_layout_cfg = self._get_layout_config(theme, config_name)
self.card_geometry_cfg = self._get_card_geometry_config(theme)
default_margin = 8 if self.list_layout else 20
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
spacing = self.card_layout_cfg.get("spacing", 12 if self.list_layout else 5)
@@ -665,6 +680,43 @@ class GameCard(AnimatedCard):
else:
self.update()
def _get_card_geometry_config(self, theme: Any) -> dict:
theme_orientation = self.card_layout_cfg.get(
"card_orientation", "horizontal"
)
if (
self.horizontal_layout
and ui_config.get_horizontal_card_orientation(theme_orientation)
== "vertical"
):
return getattr(theme, "GAME_CARD_GRID", {})
return self.card_layout_cfg
@staticmethod
def _get_layout_config(theme: Any, config_name: str) -> dict:
config = getattr(theme, config_name, {})
get_override = getattr(theme, "get_theme_override", None)
if not callable(get_override) or get_override(config_name) is not None:
return config
config = dict(config)
for name in (
"GAME_CARD_GRID", "GAME_CARD_HORIZONTAL",
"GAME_CARD_LIST", "GAME_CARD_VERTICAL",
):
override = get_override(name)
if isinstance(override, dict) and "card_animation_type" in override:
config["card_animation_type"] = override["card_animation_type"]
break
else:
animation = getattr(theme, "GAME_CARD_ANIMATION", {})
config["card_animation_type"] = animation.get(
"card_animation_type", "gradient"
)
radius = str(getattr(theme, "border_radius_card", "")).removesuffix("px")
if radius.isdigit():
config.update(cover_radius=int(radius), border_radius=int(radius))
return config
def on_cover_loaded(self, pixmap):
self.animated_cover_path = ""
self.base_pixmap = pixmap
@@ -677,7 +729,7 @@ class GameCard(AnimatedCard):
height = cover_size
else:
width = self.base_card_width
layout_config = getattr(self, "card_layout_cfg", {})
layout_config = getattr(self, "card_geometry_cfg", {})
height = int(self.base_card_width * layout_config.get("cover_aspect_ratio", 1.5))
if self._set_animated_cover(cover_path, width, height):
return
@@ -686,7 +738,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,
@@ -885,11 +937,11 @@ class GameCard(AnimatedCard):
small_card_mode = self.base_card_width < self.theme.COMPACT_CARD["width_threshold"]
height_ratio = (
self.theme.COMPACT_CARD["height_ratio"]
if small_card_mode else self.card_layout_cfg.get("card_height_ratio", 1.8)
if small_card_mode else self.card_geometry_cfg.get("card_height_ratio", 1.8)
)
scaled_height = int(self.base_card_width * height_ratio * self._scale)
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
cover_ratio = self.card_layout_cfg.get("cover_aspect_ratio", 1.5)
cover_ratio = self.card_geometry_cfg.get("cover_aspect_ratio", 1.5)
cover_height = int(scaled_width * cover_ratio)
self.coverWidget.setFixedSize(scaled_width, cover_height)
self.coverLabel.setFixedSize(scaled_width, cover_height)
@@ -965,23 +1017,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):
@@ -1170,6 +1216,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:
@@ -1207,6 +1256,9 @@ class GameCard(AnimatedCard):
super().showEvent(event)
def click(self) -> None:
if self._get_missing_executable_path() and self.context_menu_manager:
self.context_menu_manager.handle_missing_executable(self)
return
game_data = {
"name": self.name,
"description": self.description,
+204 -148
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
@@ -26,7 +26,9 @@ class FullLibraryTile(AnimatedCard):
self.name = ""
self.animation_base_size = theme.fullLibraryTileSize
self.tile_pixmap = QPixmap()
self.setup_card_animations(theme, theme.GAME_CARD_HORIZONTAL)
self.setup_card_animations(
theme, GameCard._get_layout_config(theme, "GAME_CARD_HORIZONTAL")
)
self.update_scale()
def set_tile_pixmap(self, pixmap: QPixmap) -> None:
@@ -72,7 +74,8 @@ class GameLibraryManager:
self.game_card_cache = {}
self.pending_images = {}
self.card_width = ui_config.get_card_width()
self.layout_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
theme_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid"))
self.layout_mode = ui_config.get_library_layout_mode(theme_mode)
self.gamesListWidget: QWidget | None = None
self.gamesListLayout: FlowLayout | QHBoxLayout | QVBoxLayout | None = None
self.gamesScrollArea: QScrollArea | None = None
@@ -85,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()
@@ -105,17 +114,17 @@ class GameLibraryManager:
self.gamesLibraryWidget.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
stack_layout = QGridLayout(self.gamesLibraryWidget)
if isinstance(library_background, dict):
stack_layout = QGridLayout(self.gamesLibraryWidget)
stack_layout.setContentsMargins(*library_background["margins"])
self.libraryBackgroundLabel = QLabel()
stack_layout.addWidget(self.libraryBackgroundLabel, 0, 0)
content_widget = QWidget()
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
layout = QVBoxLayout(content_widget)
stack_layout.addWidget(content_widget, 0, 0)
else:
layout = QVBoxLayout(self.gamesLibraryWidget)
stack_layout.setContentsMargins(0, 0, 0, 0)
self.libraryBackgroundLabel = QLabel()
stack_layout.addWidget(self.libraryBackgroundLabel, 0, 0)
content_widget = QWidget()
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
layout = QVBoxLayout(content_widget)
stack_layout.addWidget(content_widget, 0, 0)
self.libraryContentLayout = layout
layout.setSpacing(15)
@@ -123,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")
@@ -139,15 +144,19 @@ class GameLibraryManager:
self.gamesListWidget.setProperty("theme_style_name", "LIST_WIDGET_STYLE")
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
if self.layout_mode in {"horizontal", "horizontal_top"}:
layout_config = self.theme.GAME_CARD_HORIZONTAL
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
elif self.layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
@@ -157,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
@@ -185,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
@@ -205,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:
@@ -223,13 +244,17 @@ class GameLibraryManager:
self.fullLibraryTile = None
old_layout = self.gamesListLayout
if layout_mode in {"horizontal", "horizontal_top"}:
layout_config = self.theme.GAME_CARD_HORIZONTAL
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
self.gamesListLayout = QHBoxLayout()
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
elif layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout()
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
@@ -243,13 +268,18 @@ class GameLibraryManager:
vertical_policy = (
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
if horizontal
else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if layout_mode == "vertical"
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.gamesScrollArea.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
self.gamesScrollArea.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
theme_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
self.full_library_open = theme_mode == "horizontal_top" and layout_mode == "grid"
if self.full_library_open and self.libraryBackgroundLabel is not None:
remove_cover_background(self.libraryBackgroundLabel)
@@ -299,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."""
@@ -319,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
@@ -341,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()
@@ -382,10 +434,17 @@ class GameLibraryManager:
"""Render the configured library background from the active cover."""
if self.full_library_open or self.libraryBackgroundLabel is None:
return
self.render_library_background(self.libraryBackgroundLabel, card)
def render_library_background(self, label: QLabel, card: GameCard) -> None:
"""Render the configured library background on a target label."""
pixmap = card.coverLabel.pixmap()
if pixmap is None or pixmap.isNull():
return
library_config = self.theme.LIBRARY_BACKGROUND
library_config = getattr(self.theme, "LIBRARY_BACKGROUND", None)
if not isinstance(library_config, dict):
remove_cover_background(label)
return
backgrounds = dict(getattr(self.theme, "DETAIL_PAGE_BACKGROUNDS", {}))
backgrounds.update(library_config.get("backgrounds", {}))
background_theme = SimpleNamespace(
@@ -393,7 +452,7 @@ class GameLibraryManager:
DETAIL_PAGE_BACKGROUNDS=backgrounds,
)
setup_cover_background(
self.libraryBackgroundLabel, pixmap, self.main_window, background_theme
label, pixmap, self.main_window, background_theme
)
def _collapse_library_filter_controls(self) -> None:
@@ -458,7 +517,9 @@ class GameLibraryManager:
"""Schedules a game grid update with debouncing."""
if focus_first_card is not None:
self._focus_first_card_after_update = focus_first_card
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
theme_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
if not self.full_library_open:
self.layout_mode = theme_mode
if self.sizeSlider is not None:
@@ -499,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,
@@ -521,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()
@@ -563,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."""
@@ -603,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
@@ -639,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)
@@ -655,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
@@ -719,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()
@@ -794,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
@@ -811,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."""
@@ -894,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)
@@ -939,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)
@@ -982,7 +1025,9 @@ 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()
self._update_missing_exe_button()
@@ -1009,6 +1054,17 @@ class GameLibraryManager:
self.update_game_grid()
self._update_missing_exe_button()
detail_manager = getattr(self.main_window, "detail_page_manager", None)
source = getattr(detail_manager, "_current_detail_source", None)
if detail_manager and source and source[0] == "game" and detail_manager._detail_page_active:
detail_data = source[1]
if (detail_data.get("name"), detail_data.get("exec_line")) == old_key:
detail_data.update(
name=game_data[0], description=game_data[1],
cover_path=game_data[2], exec_line=game_data[5],
)
detail_manager._reopen_current_detail_page()
def remove_game_incremental(self, game_name: str, exec_line: str):
"""Remove a single game without full reload."""
key = (game_name, exec_line)
+86 -16
View File
@@ -4,9 +4,9 @@ import hashlib
import os
import platform
import re
import shutil
import subprocess
import time
import zlib
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
@@ -24,13 +24,17 @@ GOG_LOGIN_URL = (
"redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&"
"response_type=code&layout=galaxy"
)
GOGDL_RELEASE_URL = "https://api.github.com/repos/Heroic-Games-Launcher/heroic-gogdl/releases/latest"
GOGDL_RELEASE_SOURCE = "linux-gaming-ru/gogdl"
GOGDL_RELEASE_URL = f"https://api.github.com/repos/{GOGDL_RELEASE_SOURCE}/releases/latest"
GOGDL_AUTH_TIMEOUT = 60
GOGDL_UPDATE_INTERVAL = 30 * 24 * 60 * 60
GOG_USER_TIMEOUT = 15
GOG_METADATA_WORKERS = 4
GOG_SETUP_MARKER_VERSION = 1
GOG_SETUP_TIMEOUT = 600
GOG_REPAIR_METADATA_TIMEOUT = 30
GOG_COMPRESSED_MANIFEST_VERSION = 2
GOG_BUILDS_API_VERSION = 2
GOG_PRODUCT_LOCALES = {
"bg": "bg-BG", "cs": "cs-CZ", "da": "da-DK", "de": "de-DE",
"el": "el-GR", "en": "en-US", "es": "es-ES", "fi": "fi-FI",
@@ -47,22 +51,37 @@ class GOGAPI:
def __init__(self) -> None:
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
self.data_dir = data_home / "PortProtonQt" / "gog"
self.data_dir = data_home / "PortProtonQt" / "launcher" / "gog"
self.bin_dir = data_home / "PortProtonQt" / "bin"
# Remove after the legacy launcher storage migration period.
legacy_dir = data_home / "PortProtonQt" / "gog"
if legacy_dir.exists() and not self.data_dir.exists():
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
legacy_dir.rename(self.data_dir)
legacy_bin = self.data_dir / "bin"
if legacy_bin.is_dir():
self.bin_dir.mkdir(parents=True, exist_ok=True)
for legacy_binary in legacy_bin.iterdir():
target = self.bin_dir / legacy_binary.name
if not target.exists():
legacy_binary.rename(target)
if not any(legacy_bin.iterdir()):
legacy_bin.rmdir()
self.auth_path = self.data_dir / "auth.json"
self.account_path = self.data_dir / "account.json"
self.config_dir = self.data_dir / "gogdl"
self.gogdl_version_path = self.data_dir / "bin/gogdl.version"
self.gogdl_version_path = self.bin_dir / "gogdl.version"
self.library_path = self.data_dir / "library.json"
self.installed_path = self.data_dir / "installed.json"
self.sizes_path = self.data_dir / "sizes.json"
self.games_dir = Path.home() / "Games"
def get_gogdl_path(self) -> str | None:
"""Return an available gogdl executable."""
bundled = self.data_dir / "bin/gogdl"
if bundled.is_file() and os.access(bundled, os.X_OK):
"""Return the managed gogdl fork executable when installed."""
bundled = self.bin_dir / "gogdl"
if bundled.is_file() and os.access(bundled, os.X_OK) and self._get_gogdl_release_tag():
return str(bundled)
return shutil.which("gogdl")
return None
def build_command(self, arguments: list[str]) -> list[str]:
"""Build a gogdl command with private configuration paths."""
@@ -75,7 +94,7 @@ class GOGAPI:
return [gogdl, "--auth-config-path", str(self.auth_path), *arguments]
def ensure_gogdl(self) -> str:
"""Download and verify the latest official Heroic gogdl binary."""
"""Download and verify the latest linux-gaming-ru gogdl binary."""
existing = self.get_gogdl_path()
if existing:
return existing
@@ -83,9 +102,9 @@ class GOGAPI:
return self._install_gogdl_release(asset, release_tag)
def update_gogdl(self) -> str:
"""Update the bundled gogdl binary to the latest official release."""
bundled = self.data_dir / "bin/gogdl"
if bundled.is_file() and os.access(bundled, os.X_OK):
"""Replace other gogdl binaries and update the managed fork."""
bundled = self.bin_dir / "gogdl"
if self.get_gogdl_path():
try:
last_check = self.gogdl_version_path.stat().st_mtime
except OSError:
@@ -124,17 +143,21 @@ class GOGAPI:
def _get_gogdl_release_tag(self) -> str:
try:
return self.gogdl_version_path.read_text(encoding="utf-8").strip()
version = self.gogdl_version_path.read_text(encoding="utf-8").strip()
except OSError:
return ""
source, _, tag = version.partition(":")
return tag if source == GOGDL_RELEASE_SOURCE else ""
def _install_gogdl_release(self, asset: dict, release_tag: str) -> str:
path = self._download_gogdl_asset(asset)
self.gogdl_version_path.write_text(release_tag, encoding="utf-8")
self.gogdl_version_path.write_text(
f"{GOGDL_RELEASE_SOURCE}:{release_tag}", encoding="utf-8"
)
return path
def _download_gogdl_asset(self, asset: dict) -> str:
target = self.data_dir / "bin/gogdl"
target = self.bin_dir / "gogdl"
temporary = target.with_suffix(".part")
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
@@ -375,6 +398,53 @@ class GOGAPI:
logger.warning("Failed to inspect GOG installation path %s: %s", parent, error)
return None
def prepare_repair_manifest(self, app_id: str, install_path: Path) -> None:
"""Restore gogdl metadata before verifying an imported Windows game."""
manifest_path = self.config_dir / "heroic_gogdl" / "manifests" / app_id
if manifest_path.is_file():
return
info = self._load_json(install_path / f"goggame-{app_id}.info", {})
build_info = self._load_json(install_path / f"goggame-{app_id}.id", {})
build_id = build_info.get("buildId") or info.get("buildId")
credentials = self.get_credentials()
response = requests.get(
f"https://content-system.gog.com/products/{app_id}/os/windows/builds",
params={"generation": GOG_COMPRESSED_MANIFEST_VERSION, "_version": GOG_BUILDS_API_VERSION},
headers={"Authorization": f"Bearer {credentials.get('access_token', '')}"},
timeout=GOG_REPAIR_METADATA_TIMEOUT,
)
response.raise_for_status()
builds = response.json().get("items", [])
build = next((item for item in builds if (
str(item.get("build_id")) == str(build_id) if build_id
else not item.get("branch")
)), None)
if build is None and build_id:
build = next((item for item in builds if not item.get("branch") and item.get("urls")), None)
if build:
logger.warning(
"GOG build %s for %s is unavailable; verifying against build %s",
build_id, app_id, build.get("build_id"),
)
if not build or not build.get("urls"):
raise OSError(f"GOG build not found for installed game {app_id}")
response = requests.get(build["urls"][0]["url"], timeout=GOG_REPAIR_METADATA_TIMEOUT)
response.raise_for_status()
data = response.content
if build.get("generation") == GOG_COMPRESSED_MANIFEST_VERSION:
data = zlib.decompress(data)
manifest = orjson.loads(data)
manifest["HGLPlatform"] = "windows"
manifest["HGLInstallLanguage"] = (
info.get("language") or next(iter(info.get("languages", [])), "en-US")
)
manifest["HGLdlcs"] = [
{"id": path.stem.removeprefix("goggame-")}
for path in install_path.glob("goggame-*.info")
if path.stem != f"goggame-{app_id}"
]
self._save_json(manifest_path, manifest)
def get_launch_target(self, app_id: str) -> str | None:
"""Return the primary executable from gogdl installation metadata."""
primary, install_path = self._get_primary_task(app_id)
@@ -615,7 +685,7 @@ class GOGAPI:
response.raise_for_status()
data = response.json()
game = data.get("game", {})
if not game.get("visible_in_library", True):
if data.get("type") not in {"game", "mod"} or not game.get("visible_in_library", True):
return {}
steam_release = next(
(
+2 -6
View File
@@ -7,6 +7,7 @@ import struct
import io
import pefile
from PIL import Image
from portprotonqt.config.base import CACHE_DIR
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -48,14 +49,9 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
def get_exe_icon_cache_path(exe_path: str) -> str:
"""Return the shared cache path for an executable icon."""
cache_home = os.getenv(
"XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache"),
)
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
return os.path.join(
cache_home,
"PortProtonQt",
CACHE_DIR,
"images",
"exe_icons",
f"{icon_name}.png",
+3 -5
View File
@@ -8,7 +8,7 @@ from PySide6.QtCore import (
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
from PIL import Image, ImageQt, ImageSequence
from portprotonqt.config import ui_config
from portprotonqt.config import CACHE_DIR, ui_config
from portprotonqt.theme_manager import ThemeManager, load_theme
from portprotonqt.downloader import Downloader
from portprotonqt.icon_extractor import generate_thumbnail
@@ -367,8 +367,7 @@ def _get_ppdb_autoinstall_image_name(cover: str) -> str:
def clear_ppdb_autoinstall_image_cache(cover_urls: list[str]) -> None:
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
image_folder = str(CACHE_DIR / "images")
for cover in cover_urls:
filename = _get_ppdb_autoinstall_image_name(cover)
if not filename:
@@ -457,8 +456,7 @@ def load_pixmap_async(
pixmap.load(icon_path)
return pixmap
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
image_folder = str(CACHE_DIR / "images")
os.makedirs(image_folder, exist_ok=True)
if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):
+21 -7
View File
@@ -60,6 +60,7 @@ class MainWindowProtocol(Protocol):
current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
theme: Any
auto_size_slider: QSlider | None
class MouseEmulationThread(QThread):
@@ -289,7 +290,7 @@ class InputManager(
card for card in container.findChildren(AnimatedCard)
if card.isVisible() and card.isEnabled()
]
if self._navigate_horizontal_library(game_cards, code, value):
if self._navigate_horizontal_library(game_cards, tab_index, code, value):
return
focused = QApplication.focusWidget()
if game_cards and focused not in game_cards:
@@ -302,14 +303,21 @@ class InputManager(
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
def _navigate_horizontal_library(
self, cards: list[QWidget], code: int, value: int
self, cards: list[QWidget], tab_index: int, code: int, value: int
) -> bool:
manager = getattr(self._parent, "game_library_manager", None)
if getattr(manager, "layout_mode", "grid") not in {
if tab_index == 1:
mode = str(
getattr(self._parent.theme, "LIBRARY_LAYOUT_MODE", "grid")
).lower()
layout = getattr(self._parent, "autoInstallContainerLayout", None)
else:
manager = getattr(self._parent, "game_library_manager", None)
mode = getattr(manager, "layout_mode", "grid")
layout = getattr(manager, "gamesListLayout", None)
if mode not in {
"horizontal", "horizontal_top"
}:
return False
layout = getattr(manager, "gamesListLayout", None)
if code != PAD_DPAD_X or value == 0 or layout is None:
return False
ordered_cards = []
@@ -521,12 +529,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:
+16
View File
@@ -34,6 +34,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)
@@ -82,6 +87,9 @@ class ButtonInputMixin(InputMixin):
if self._handle_system_table_button(focused, button_code):
return
if self._handle_table_button(focused, button_code):
return
if self._handle_system_quick_button(button_code):
return
@@ -89,6 +97,14 @@ class ButtonInputMixin(InputMixin):
except Exception as e:
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
def _handle_table_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QTableWidget) or button_code not in BUTTONS['confirm']:
return False
if not self.handle_table_confirm(focused):
return False
SoundManager().play("click")
return True
def _open_focused_context_menu(self, focused: QWidget | None, button_code: int) -> bool:
if button_code not in BUTTONS['context_menu'] or focused is None:
return False
+63 -4
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
@@ -257,6 +306,10 @@ class DialogInputModesMixin(InputMixin):
if isinstance(cell_widget, QCheckBox) and cell_widget.isEnabled():
cell_widget.setChecked(not cell_widget.isChecked())
return True
if isinstance(cell_widget, QComboBox) and cell_widget.isEnabled():
cell_widget.showPopup()
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
@@ -265,7 +318,8 @@ class DialogInputModesMixin(InputMixin):
# Check if the cell contains a checkbox
item = table.item(current_row, current_col)
if item and (item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
if (item and item.data(Qt.ItemDataRole.CheckStateRole) is not None
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
# Toggle the checkbox state
new_state = Qt.CheckState.Checked if item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
item.setCheckState(new_state)
@@ -493,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:
@@ -550,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
+8 -10
View File
@@ -93,10 +93,6 @@ class KeyboardInputMixin(InputMixin):
keyboard.stop_backspace_repeat()
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
app = QApplication.instance()
if not app:
return QObject.eventFilter(cast(QObject, self), obj, event)
if event.type() == QEvent.Type.Show:
if isinstance(obj, QMenu):
SoundManager().play("open")
@@ -162,7 +158,7 @@ class KeyboardInputMixin(InputMixin):
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
return False
return QObject.eventFilter(cast(QObject, self), obj, event)
return False
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
native_key = self._native_scan_to_input_key(event)
@@ -448,8 +444,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():
@@ -515,13 +512,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):
+24 -5
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:
@@ -175,7 +175,7 @@ class GamepadRuntimeMixin(InputMixin):
keyboard.update_keyboard()
def find_gamepad(self) -> SDLGamepad | None:
"""Find the first SDL3 gamepad with a standardized mapping."""
"""Open the SDL3 handle that combines all connected gamepads."""
try:
gamepad = find_gamepad()
self._last_gamepad_error = None
@@ -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()
)
@@ -353,10 +364,18 @@ class GamepadRuntimeMixin(InputMixin):
self._last_gamepad_check_time = current_time
return
try:
active_gamepad.update()
active_changed = active_gamepad.update()
if not active_gamepad.connected():
self.gamepad_hotplug.emit('remove')
return
if active_changed:
self._reset_input_state()
self.gamepad_type = self._get_effective_gamepad_type(active_gamepad)
self._refresh_gamepad_ui()
logger.info(
"Active gamepad changed to SDL instance %s",
active_gamepad.active_instance_id,
)
self._poll_button_events(active_gamepad, current_time)
self._handle_pending_menu_fullscreen(current_time)
self._poll_hat_events(active_gamepad, current_time)
+24 -5
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
@@ -269,6 +270,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 +319,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
@@ -327,19 +334,28 @@ class SettingsInputMixin(InputMixin):
self._move_mangohud_vertical(focused, normalized_value, sections)
return
# 2. Combo Box Navigation (within Advanced Table)
# 2. Combo Box Navigation (within Advanced and Main Tables)
table = self._get_current_settings_table()
if not table or table.rowCount() == 0:
return
if self.settings_dialog and table == self.settings_dialog.advanced_table and table.currentRow() >= 0:
combo_tables = (
self.settings_dialog.advanced_table,
self.settings_dialog.settings_table,
)
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)
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)
cell_widget.setCurrentIndex(new_idx)
return # Consume event
# 3. Standard Table Navigation
@@ -433,6 +449,9 @@ class SettingsInputMixin(InputMixin):
if table == self.settings_dialog.settings_table:
cell_widget = table.cellWidget(row, 1)
if isinstance(cell_widget, QComboBox) and cell_widget.isEnabled():
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
@@ -169,6 +169,9 @@ msgstr ""
msgid "Import"
msgstr ""
msgid "Edit Shortcut"
msgstr ""
msgid "Update"
msgstr ""
@@ -181,9 +184,6 @@ msgstr ""
msgid "Open Game Folder"
msgstr ""
msgid "Edit Shortcut"
msgstr ""
msgid "Remove from Steam"
msgstr ""
@@ -249,12 +249,26 @@ msgstr ""
msgid "Failed to save custom data: {error}"
msgstr ""
msgid "Executable not found"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "Select another executable"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
msgid "Failed to generate .desktop file data"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr ""
@@ -263,10 +277,6 @@ msgstr ""
msgid "Removed old .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr ""
@@ -343,15 +353,24 @@ msgstr ""
msgid "Denied"
msgstr ""
msgid "Game Title"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "TIME SPENT"
msgstr ""
msgid "Library"
msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Downloading Wine…"
msgstr ""
msgid "Library"
msgstr ""
msgid "Auto Install"
msgstr ""
@@ -415,10 +434,6 @@ msgstr ""
msgid "Unknown Application"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
#, python-brace-format
msgid "File not found: {0}"
msgstr ""
@@ -443,6 +458,10 @@ msgstr ""
msgid "32-bit dependencies are missing. Install them now?"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
msgid "Select Epic game folder"
msgstr ""
@@ -747,6 +766,9 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Exit"
msgstr ""
@@ -759,6 +781,12 @@ msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Resume Game"
msgstr ""
msgid "Failed to pause game"
msgstr ""
msgid "No recent games"
msgstr ""
@@ -781,12 +809,6 @@ msgstr ""
msgid "Size"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "TIME SPENT"
msgstr ""
msgid "MAIN STORY"
msgstr ""
@@ -811,15 +833,15 @@ msgstr ""
msgid "Create Log"
msgstr ""
msgid "Stop Log"
msgstr ""
msgid "Open Card"
msgstr ""
msgid "Reinstall"
msgstr ""
msgid "Stop Log"
msgstr ""
msgid "Failed to start debug session"
msgstr ""
@@ -1116,9 +1138,15 @@ msgstr ""
msgid "System WINE"
msgstr ""
msgid "Global Game Settings"
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Edit user.conf"
msgstr ""
msgid "Search settings…"
msgstr ""
@@ -1143,6 +1171,9 @@ msgstr ""
msgid "Clear PPDB"
msgstr ""
msgid "Failed to load global game settings."
msgstr ""
msgid "Executable path is not available."
msgstr ""
@@ -1152,6 +1183,9 @@ msgstr ""
msgid "Failed to open PPDB file:\n"
msgstr ""
msgid "Failed to open file:\n"
msgstr ""
msgid "Information"
msgstr ""
@@ -1164,18 +1198,24 @@ msgstr ""
msgid "Failed to remove PPDB file:\n"
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Default"
msgstr ""
msgid "disabled"
msgstr ""
msgid "Download Wine/Proton..."
msgstr ""
msgid "Enter prefix name"
msgstr ""
msgid "Default value"
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Borderless window"
msgstr ""
@@ -1369,9 +1409,6 @@ msgstr ""
msgid "Enable {0}"
msgstr ""
msgid "Default"
msgstr ""
msgid "FSR upscaling"
msgstr ""
@@ -1448,6 +1485,15 @@ msgstr ""
msgid "Round corners (px)"
msgstr ""
msgid "Font scale"
msgstr ""
msgid "Font size"
msgstr ""
msgid "Font"
msgstr ""
msgid "Top-left"
msgstr ""
@@ -1757,6 +1803,9 @@ msgstr ""
msgid "Layout and limiter"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Presets"
msgstr ""
@@ -1842,9 +1891,6 @@ msgstr ""
msgid "Libraries"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -1960,9 +2006,6 @@ msgstr ""
msgid "Disk: "
msgstr ""
msgid "Game Title"
msgstr ""
msgid "Started at"
msgstr ""
@@ -2053,10 +2096,6 @@ msgstr ""
msgid "Selected folder is not this GOG game"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
msgid "Starting"
msgstr ""
@@ -2108,7 +2147,7 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgid "Show only installed games"
msgstr ""
msgid "Detailed"
@@ -2120,6 +2159,33 @@ msgstr ""
msgid "Badge View Type:"
msgstr ""
msgid "Theme"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Vertical"
msgstr ""
msgid "Horizontal Blocks"
msgstr ""
msgid "Horizontal Cards"
msgstr ""
msgid "Horizontal endless Blocks"
msgstr ""
msgid "Horizontal endless Cards"
msgstr ""
msgid "Layout"
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2156,9 +2222,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 ""
@@ -2183,12 +2255,18 @@ msgstr ""
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgid "Disable runtime download"
msgstr ""
msgid "Add to Steam compatibility tools"
msgstr ""
msgid "Disable Steam Proton scanning"
msgstr ""
msgid "Download WINE/Proton to Steam"
msgstr ""
@@ -2438,9 +2516,6 @@ msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Use by default"
msgstr ""
msgid "Wine Configuration"
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-02 20:25+0000\n"
"PO-Revision-Date: 2026-10-12 19:09+0000\n"
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/de/>\n"
@@ -18,7 +18,7 @@ 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"
@@ -174,6 +174,9 @@ msgstr "Löschen"
msgid "Import"
msgstr "Import"
msgid "Edit Shortcut"
msgstr "Verknüpfung bearbeiten"
msgid "Update"
msgstr "Aktualisierung"
@@ -186,9 +189,6 @@ msgstr "EOS-Overlay aktivieren"
msgid "Open Game Folder"
msgstr "Spielordner öffnen"
msgid "Edit Shortcut"
msgstr "Verknüpfung bearbeiten"
msgid "Remove from Steam"
msgstr "Von Steam entfernen"
@@ -258,12 +258,27 @@ msgstr "Desktop"
msgid "Failed to save custom data: {error}"
msgstr "Fehler beim Speichern der Benutzerdaten: {error}"
#, python-brace-format
msgid "Executable not found"
msgstr "Ausführbare Datei nicht gefunden"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Ausführbare Datei nicht gefunden: {0}"
msgid "Select another executable"
msgstr "Wähle eine andere ausführbare Datei aus"
msgid "Game name and executable path are required"
msgstr "Spielname und Ausführungspfad werden benötigt"
msgid "Failed to generate .desktop file data"
msgstr "Fehler beim Generieren der .desktop-Datei"
#, python-brace-format
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}"
@@ -272,10 +287,6 @@ msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
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 save .desktop file: {error}"
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
@@ -356,15 +367,24 @@ msgstr "Kaputt"
msgid "Denied"
msgstr "Abgelehnt"
msgid "Game Title"
msgstr "Spieltitel"
msgid "LAST LAUNCH"
msgstr "LETZTER START"
msgid "TIME SPENT"
msgstr "SPIELZEIT"
msgid "Library"
msgstr "Sammlung"
msgid "Unknown Game"
msgstr "Unbekanntes Spiel"
msgid "Downloading Wine…"
msgstr "Wine wird heruntergeladen…"
msgid "Library"
msgstr "Sammlung"
msgid "Auto Install"
msgstr "Automatische Installation"
@@ -425,10 +445,6 @@ msgstr "Niemals"
msgid "Unknown Application"
msgstr "Unbekannte Anwendung"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Ausführbare Datei nicht gefunden: {0}"
#, python-brace-format
msgid "File not found: {0}"
msgstr "Datei nicht gefunden: {0}"
@@ -454,6 +470,10 @@ msgstr "Abhängigkeiten installieren"
msgid "32-bit dependencies are missing. Install them now?"
msgstr "Notwendige 32-Bit-Software fehlen. Jetzt installieren?"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
msgid "Select Epic game folder"
msgstr "Epic-Spieleordner auswählen"
@@ -817,6 +837,9 @@ msgstr "Min."
msgid "sec."
msgstr "Sek."
msgid "Pause Game"
msgstr "Spiel anhalten"
msgid "Exit"
msgstr "Exit"
@@ -829,6 +852,12 @@ msgstr "Favoriten"
msgid "Recent Games"
msgstr "Aktuelle Spiele"
msgid "Resume Game"
msgstr "Spiel fortsetzen"
msgid "Failed to pause game"
msgstr "Spiel konnte nicht pausiert werden"
msgid "No recent games"
msgstr "Keine aktuellen Spiele"
@@ -852,12 +881,6 @@ msgstr "Herunterladen"
msgid "Size"
msgstr "Größe"
msgid "LAST LAUNCH"
msgstr "LETZTER START"
msgid "TIME SPENT"
msgstr "SPIELZEIT"
msgid "MAIN STORY"
msgstr "HAUPTSPIEL"
@@ -882,15 +905,15 @@ msgstr "Einstellungen"
msgid "Create Log"
msgstr "Protokoll erstellen"
msgid "Stop Log"
msgstr "Protokollierung beenden"
msgid "Open Card"
msgstr "Ansicht öffnen"
msgid "Reinstall"
msgstr "Neuinstallation"
msgid "Stop Log"
msgstr "Protokollierung beenden"
msgid "Failed to start debug session"
msgstr "Protokollierung konnte nicht gestartet werden"
@@ -1195,9 +1218,15 @@ msgstr "Der Download oder das Entpacken wurde abgebrochen."
msgid "System WINE"
msgstr "System WINE"
msgid "Global Game Settings"
msgstr "Globale Spieleinstellungen"
msgid "Exe Settings"
msgstr "Einstellungen für die exe-Datei"
msgid "Edit user.conf"
msgstr "user.conf bearbeiten"
msgid "Search settings…"
msgstr "Einstellungen suchen…"
@@ -1222,6 +1251,9 @@ msgstr "PPDB bearbeiten"
msgid "Clear PPDB"
msgstr "PPDB zurücksetzen"
msgid "Failed to load global game settings."
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."
@@ -1231,6 +1263,9 @@ msgstr "PPDB-Datei ist nicht vorhanden unter: "
msgid "Failed to open PPDB file:\n"
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
msgid "Failed to open file:\n"
msgstr "Datei konnte nicht geöffnet werden:\n"
msgid "Information"
msgstr "Information"
@@ -1245,18 +1280,24 @@ msgstr ""
msgid "Failed to remove PPDB file:\n"
msgstr "Fehler beim Entfernen der PPDB-Datei:\n"
msgid "Failed to apply changes. Check logs."
msgstr "Anwenden der Änderungen fehlgeschlagen. Überprüfe die Protokolle."
msgid "Default"
msgstr "Standard"
msgid "disabled"
msgstr "deaktiviert"
msgid "Download Wine/Proton..."
msgstr "Lade Wine/Proton herunter..."
msgid "Enter prefix name"
msgstr "Prefix-Name eingeben"
msgid "Default value"
msgstr "Standardwert"
msgid "Failed to apply changes. Check logs."
msgstr "Anwenden der Änderungen fehlgeschlagen. Überprüfe die Protokolle."
msgid "Borderless window"
msgstr "Rahmenloses Fenster"
@@ -1460,9 +1501,6 @@ msgstr "Voreingestellte Einstellungen"
msgid "Enable {0}"
msgstr "Einschalten {0}"
msgid "Default"
msgstr "Standard"
msgid "FSR upscaling"
msgstr "FSR-Upscaling"
@@ -1541,6 +1579,15 @@ msgstr "Hintergrund-Deckkraft"
msgid "Round corners (px)"
msgstr "Abgerundete Ecken (px)"
msgid "Font scale"
msgstr "Schriftgröße"
msgid "Font size"
msgstr "Schriftgröße"
msgid "Font"
msgstr "Schrift"
msgid "Top-left"
msgstr "Oben links"
@@ -1850,6 +1897,9 @@ msgstr "VRAM-Nutzung"
msgid "Layout and limiter"
msgstr "Layout und Limiter"
msgid "Fonts"
msgstr "Schriftarten"
msgid "Presets"
msgstr "Voreinstellungen"
@@ -1939,9 +1989,6 @@ msgstr "Auswahl"
msgid "Libraries"
msgstr "Bibliotheken"
msgid "Fonts"
msgstr "Schriftarten"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks wurde nicht gefunden. Bitte versuche es erneut."
@@ -2057,9 +2104,6 @@ msgstr "Warten…"
msgid "Disk: "
msgstr "Datenträger: "
msgid "Game Title"
msgstr "Spieltitel"
msgid "Started at"
msgstr "Begonnen"
@@ -2150,10 +2194,6 @@ msgstr "GOG-Spielordner auswählen"
msgid "Selected folder is not this GOG game"
msgstr "Der ausgewählte Ordner ist nicht dieses GOG-Spiel"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
msgid "Starting"
msgstr "Starten"
@@ -2205,8 +2245,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"
@@ -2217,6 +2257,33 @@ msgstr "Versteckt"
msgid "Badge View Type:"
msgstr "Anzeigetyp von Titelbildern:"
msgid "Theme"
msgstr "Thema"
msgid "Grid"
msgstr "Gitter"
msgid "List"
msgstr "Liste"
msgid "Vertical"
msgstr "Vertikal"
msgid "Horizontal Blocks"
msgstr "Horizontale Blöcke"
msgid "Horizontal Cards"
msgstr "Horizontale Karten"
msgid "Horizontal endless Blocks"
msgstr "Endlose horizontale Blöcke"
msgid "Horizontal endless Cards"
msgstr "Endlose horizontale Karten"
msgid "Layout"
msgstr "Layout"
#, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2255,9 +2322,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"
@@ -2282,12 +2355,18 @@ msgstr "Benutzeroberflächen-Sounds aktivieren"
msgid "Show compatibility report after game crash"
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
msgid "Disable ALT Linux i586 dependency check"
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
msgid "Disable runtime download"
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"
@@ -2540,9 +2619,6 @@ msgstr "Das Theme konnte nicht gelöscht werden."
msgid "Prefix:"
msgstr "Prefix:"
msgid "Use by default"
msgstr "Standardmäßig verwenden"
msgid "Wine Configuration"
msgstr "Wine-Konfiguration"
@@ -7,9 +7,9 @@
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
"PO-Revision-Date: 2026-08-11 19:26+0000\n"
"PO-Revision-Date: 2026-09-10 15:44+0000\n"
"Last-Translator: gallegonovato <gallegonovato@noreply.codeberg.org>\n"
"Language-Team: Spanish <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/es/>\n"
@@ -176,6 +176,9 @@ msgstr "Borrar"
msgid "Import"
msgstr "Importar"
msgid "Edit Shortcut"
msgstr "Editar acceso directo"
msgid "Update"
msgstr "Actualizar"
@@ -188,9 +191,6 @@ msgstr ""
msgid "Open Game Folder"
msgstr "Abrir la carpeta del juego"
msgid "Edit Shortcut"
msgstr "Editar acceso directo"
msgid "Remove from Steam"
msgstr "Eliminar de Steam"
@@ -258,6 +258,18 @@ msgstr "Escritorio"
msgid "Failed to save custom data: {error}"
msgstr "No se han podido guardar los datos personalizados: {error}"
#, fuzzy, python-brace-format
msgid "Executable not found"
msgstr "No se ha encontrado el archivo ejecutable: {0}"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "No se ha encontrado el archivo ejecutable: {0}"
#, fuzzy
msgid "Select another executable"
msgstr "Ruta al ejecutable:"
msgid "Game name and executable path are required"
msgstr ""
"Es necesario indicar el nombre del juego y la ruta del archivo ejecutable"
@@ -265,6 +277,10 @@ msgstr ""
msgid "Failed to generate .desktop file data"
msgstr "No se han podido generar los datos del archivo .desktop"
#, python-brace-format
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}"
@@ -273,10 +289,6 @@ msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
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 save .desktop file: {error}"
msgstr "No se ha podido guardar el archivo .desktop: {error}"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "No se ha podido copiar la imagen de portada: {error}"
@@ -357,15 +369,24 @@ msgstr "Falló"
msgid "Denied"
msgstr "Denegado"
msgid "Game Title"
msgstr "Título del juego"
msgid "LAST LAUNCH"
msgstr "ÚLTIMO LANZAMIENTO"
msgid "TIME SPENT"
msgstr "TIEMPO EMPLEADO"
msgid "Library"
msgstr "Biblioteca"
msgid "Unknown Game"
msgstr "Juego desconocido"
msgid "Downloading Wine…"
msgstr "Descargando Wine…"
msgid "Library"
msgstr "Biblioteca"
msgid "Auto Install"
msgstr "Instalación automática"
@@ -426,10 +447,6 @@ msgstr "Nunca"
msgid "Unknown Application"
msgstr "Aplicación desconocida"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "No se ha encontrado el archivo ejecutable: {0}"
#, python-brace-format
msgid "File not found: {0}"
msgstr "Archivo no encontrado: {0}"
@@ -456,6 +473,10 @@ msgstr "Instalar las dependencias"
msgid "32-bit dependencies are missing. Install them now?"
msgstr "Faltan algunas dependencias de 32 bits. ¿Las instalas ahora?"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
#, fuzzy
msgid "Select Epic game folder"
msgstr "Borrar la carpeta"
@@ -836,6 +857,9 @@ msgstr "min"
msgid "sec."
msgstr "s"
msgid "Pause Game"
msgstr "Pausar el juego"
msgid "Exit"
msgstr "Salir"
@@ -848,6 +872,12 @@ msgstr "Favoritos"
msgid "Recent Games"
msgstr "Juegos recientes"
msgid "Resume Game"
msgstr "Reanudar el juego"
msgid "Failed to pause game"
msgstr "Error al pausar el juego"
msgid "No recent games"
msgstr "Sin juegos recientes"
@@ -870,12 +900,6 @@ msgstr "Descargar"
msgid "Size"
msgstr "Tamaño"
msgid "LAST LAUNCH"
msgstr "ÚLTIMO LANZAMIENTO"
msgid "TIME SPENT"
msgstr "TIEMPO EMPLEADO"
msgid "MAIN STORY"
msgstr "HISTORIA PRINCIPAL"
@@ -900,15 +924,15 @@ msgstr "Ajustes"
msgid "Create Log"
msgstr "Crear registro"
msgid "Stop Log"
msgstr "Detener el registro"
msgid "Open Card"
msgstr "Carta abierta"
msgid "Reinstall"
msgstr "Reinstalar"
msgid "Stop Log"
msgstr "Detener el registro"
msgid "Failed to start debug session"
msgstr "No se pudo iniciar la sesión de depuración"
@@ -1209,9 +1233,16 @@ msgstr ""
msgid "System WINE"
msgstr ""
#, fuzzy
msgid "Global Game Settings"
msgstr "Ajustes de Wine"
msgid "Exe Settings"
msgstr ""
msgid "Edit user.conf"
msgstr ""
msgid "Search settings…"
msgstr "Buscar ajustes…"
@@ -1236,6 +1267,10 @@ msgstr ""
msgid "Clear PPDB"
msgstr ""
#, fuzzy
msgid "Failed to load global game settings."
msgstr "Error al cargar los temas"
msgid "Executable path is not available."
msgstr ""
@@ -1245,6 +1280,10 @@ msgstr ""
msgid "Failed to open PPDB file:\n"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to open file:\n"
msgstr "No se ha podido abrir la carpeta: {error}"
msgid "Information"
msgstr ""
@@ -1262,18 +1301,25 @@ msgstr ""
msgid "Failed to remove PPDB file:\n"
msgstr "No se ha podido eliminar el archivo PPDB:\n"
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Default"
msgstr ""
msgid "disabled"
msgstr ""
#, fuzzy
msgid "Download Wine/Proton..."
msgstr "Versión de WINE/Proton"
msgid "Enter prefix name"
msgstr ""
msgid "Default value"
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Borderless window"
msgstr ""
@@ -1466,9 +1512,6 @@ msgstr ""
msgid "Enable {0}"
msgstr ""
msgid "Default"
msgstr ""
msgid "FSR upscaling"
msgstr ""
@@ -1545,6 +1588,17 @@ msgstr ""
msgid "Round corners (px)"
msgstr ""
#, fuzzy
msgid "Font scale"
msgstr "Local"
#, fuzzy
msgid "Font size"
msgstr "Tamaño"
msgid "Font"
msgstr ""
msgid "Top-left"
msgstr ""
@@ -1854,6 +1908,9 @@ msgstr ""
msgid "Layout and limiter"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Presets"
msgstr ""
@@ -1939,9 +1996,6 @@ msgstr ""
msgid "Libraries"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -2057,9 +2111,6 @@ msgstr "Esperando…"
msgid "Disk: "
msgstr "Disco: "
msgid "Game Title"
msgstr "Título del juego"
msgid "Started at"
msgstr "Comenzó en"
@@ -2153,10 +2204,6 @@ msgstr "Borrar la carpeta"
msgid "Selected folder is not this GOG game"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
#, fuzzy
msgid "Starting"
msgstr "Iniciar"
@@ -2211,8 +2258,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 ""
@@ -2223,6 +2270,35 @@ msgstr ""
msgid "Badge View Type:"
msgstr ""
#, fuzzy
msgid "Theme"
msgstr "Temas"
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
#, fuzzy
msgid "Vertical"
msgstr "parcial"
msgid "Horizontal Blocks"
msgstr ""
msgid "Horizontal Cards"
msgstr ""
msgid "Horizontal endless Blocks"
msgstr ""
msgid "Horizontal endless Cards"
msgstr ""
msgid "Layout"
msgstr ""
#, fuzzy, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2261,9 +2337,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 ""
@@ -2288,12 +2370,18 @@ msgstr ""
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgid "Disable runtime download"
msgstr ""
msgid "Add to Steam compatibility tools"
msgstr ""
msgid "Disable Steam Proton scanning"
msgstr ""
msgid "Download WINE/Proton to Steam"
msgstr ""
@@ -2546,9 +2634,6 @@ msgstr "Error al cargar los temas"
msgid "Prefix:"
msgstr "Prefijo:"
msgid "Use by default"
msgstr "Usar por defecto"
msgid "Wine Configuration"
msgstr "Configuración de Wine"
+119 -44
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-01 17:22+0500\n"
"POT-Creation-Date: 2026-10-11 01:04+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"
@@ -165,6 +165,9 @@ msgstr ""
msgid "Import"
msgstr ""
msgid "Edit Shortcut"
msgstr ""
msgid "Update"
msgstr ""
@@ -177,9 +180,6 @@ msgstr ""
msgid "Open Game Folder"
msgstr ""
msgid "Edit Shortcut"
msgstr ""
msgid "Remove from Steam"
msgstr ""
@@ -245,12 +245,26 @@ msgstr ""
msgid "Failed to save custom data: {error}"
msgstr ""
msgid "Executable not found"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "Select another executable"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
msgid "Failed to generate .desktop file data"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr ""
@@ -259,10 +273,6 @@ msgstr ""
msgid "Removed old .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr ""
@@ -339,15 +349,24 @@ msgstr ""
msgid "Denied"
msgstr ""
msgid "Game Title"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "TIME SPENT"
msgstr ""
msgid "Library"
msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Downloading Wine…"
msgstr ""
msgid "Library"
msgstr ""
msgid "Auto Install"
msgstr ""
@@ -411,10 +430,6 @@ msgstr ""
msgid "Unknown Application"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
#, python-brace-format
msgid "File not found: {0}"
msgstr ""
@@ -439,6 +454,10 @@ msgstr ""
msgid "32-bit dependencies are missing. Install them now?"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
msgid "Select Epic game folder"
msgstr ""
@@ -743,6 +762,9 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Exit"
msgstr ""
@@ -755,6 +777,12 @@ msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Resume Game"
msgstr ""
msgid "Failed to pause game"
msgstr ""
msgid "No recent games"
msgstr ""
@@ -777,12 +805,6 @@ msgstr ""
msgid "Size"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "TIME SPENT"
msgstr ""
msgid "MAIN STORY"
msgstr ""
@@ -807,15 +829,15 @@ msgstr ""
msgid "Create Log"
msgstr ""
msgid "Stop Log"
msgstr ""
msgid "Open Card"
msgstr ""
msgid "Reinstall"
msgstr ""
msgid "Stop Log"
msgstr ""
msgid "Failed to start debug session"
msgstr ""
@@ -1112,9 +1134,15 @@ msgstr ""
msgid "System WINE"
msgstr ""
msgid "Global Game Settings"
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Edit user.conf"
msgstr ""
msgid "Search settings…"
msgstr ""
@@ -1139,6 +1167,9 @@ msgstr ""
msgid "Clear PPDB"
msgstr ""
msgid "Failed to load global game settings."
msgstr ""
msgid "Executable path is not available."
msgstr ""
@@ -1148,6 +1179,9 @@ msgstr ""
msgid "Failed to open PPDB file:\n"
msgstr ""
msgid "Failed to open file:\n"
msgstr ""
msgid "Information"
msgstr ""
@@ -1160,18 +1194,24 @@ msgstr ""
msgid "Failed to remove PPDB file:\n"
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Default"
msgstr ""
msgid "disabled"
msgstr ""
msgid "Download Wine/Proton..."
msgstr ""
msgid "Enter prefix name"
msgstr ""
msgid "Default value"
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Borderless window"
msgstr ""
@@ -1365,9 +1405,6 @@ msgstr ""
msgid "Enable {0}"
msgstr ""
msgid "Default"
msgstr ""
msgid "FSR upscaling"
msgstr ""
@@ -1444,6 +1481,15 @@ msgstr ""
msgid "Round corners (px)"
msgstr ""
msgid "Font scale"
msgstr ""
msgid "Font size"
msgstr ""
msgid "Font"
msgstr ""
msgid "Top-left"
msgstr ""
@@ -1753,6 +1799,9 @@ msgstr ""
msgid "Layout and limiter"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Presets"
msgstr ""
@@ -1838,9 +1887,6 @@ msgstr ""
msgid "Libraries"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -1956,9 +2002,6 @@ msgstr ""
msgid "Disk: "
msgstr ""
msgid "Game Title"
msgstr ""
msgid "Started at"
msgstr ""
@@ -2049,10 +2092,6 @@ msgstr ""
msgid "Selected folder is not this GOG game"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
msgid "Starting"
msgstr ""
@@ -2104,7 +2143,7 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgid "Show only installed games"
msgstr ""
msgid "Detailed"
@@ -2116,6 +2155,33 @@ msgstr ""
msgid "Badge View Type:"
msgstr ""
msgid "Theme"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Vertical"
msgstr ""
msgid "Horizontal Blocks"
msgstr ""
msgid "Horizontal Cards"
msgstr ""
msgid "Horizontal endless Blocks"
msgstr ""
msgid "Horizontal endless Cards"
msgstr ""
msgid "Layout"
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2152,9 +2218,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 ""
@@ -2179,12 +2251,18 @@ msgstr ""
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgid "Disable runtime download"
msgstr ""
msgid "Add to Steam compatibility tools"
msgstr ""
msgid "Disable Steam Proton scanning"
msgstr ""
msgid "Download WINE/Proton to Steam"
msgstr ""
@@ -2435,9 +2513,6 @@ msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Use by default"
msgstr ""
msgid "Wine Configuration"
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-08-28 22:26+0000\n"
"PO-Revision-Date: 2026-09-24 06:40+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,7 +18,7 @@ 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.9.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
@@ -174,23 +174,21 @@ msgstr "Excluir"
msgid "Import"
msgstr "Importar"
msgid "Edit Shortcut"
msgstr "Editar Atalho"
msgid "Update"
msgstr "Atualizar"
#, fuzzy
msgid "Disable EOS Overlay"
msgstr "Ativar overlay mangoapp"
msgstr "Desativar sobreposição do EOS"
#, fuzzy
msgid "Enable EOS Overlay"
msgstr "Ativar overlay mangoapp"
msgstr "Ativar sobreposição do EOS"
msgid "Open Game Folder"
msgstr "Abrir Pasta do Jogo"
msgid "Edit Shortcut"
msgstr "Editar Atalho"
msgid "Remove from Steam"
msgstr "Remover da Steam"
@@ -260,12 +258,27 @@ msgstr "Área de Trabalho"
msgid "Failed to save custom data: {error}"
msgstr "Falha ao salvar os dados personalizados: {error}"
#, python-brace-format
msgid "Executable not found"
msgstr "Executável não encontrado"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Executável não encontrado: {0}"
msgid "Select another executable"
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"
msgid "Failed to generate .desktop file data"
msgstr "Falha ao gerar os dados do arquivo .desktop"
#, python-brace-format
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}"
@@ -274,10 +287,6 @@ msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Arquivo .desktop antigo de '{game_name}' removido"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "Falha ao salvar o arquivo .desktop: {error}"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Falha ao copiar a imagem de capa: {error}"
@@ -358,15 +367,24 @@ msgstr "Quebrado"
msgid "Denied"
msgstr "Negado"
msgid "Game Title"
msgstr "Título do jogo"
msgid "LAST LAUNCH"
msgstr "ÚLTIMO LANÇAMENTO"
msgid "TIME SPENT"
msgstr "TEMPO JOGADO"
msgid "Library"
msgstr "Biblioteca"
msgid "Unknown Game"
msgstr "Jogo Desconhecido"
msgid "Downloading Wine…"
msgstr "Baixando Wine…"
msgid "Library"
msgstr "Biblioteca"
msgid "Auto Install"
msgstr "Instalação Automática"
@@ -427,10 +445,6 @@ msgstr "Nunca"
msgid "Unknown Application"
msgstr "Aplicativo Desconhecido"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Executável não encontrado: {0}"
#, python-brace-format
msgid "File not found: {0}"
msgstr "Arquivo não encontrado: {0}"
@@ -455,17 +469,18 @@ msgstr "Instalar dependências"
msgid "32-bit dependencies are missing. Install them now?"
msgstr "As dependências de 32 bits estão faltando. Instalá-las agora?"
#, fuzzy
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "Excluir '{0}' e todos os arquivos da sua pasta de instalação?"
msgid "Select Epic game folder"
msgstr "Selecionar pasta do jogo da GOG"
msgstr "elecionar pasta do jogo da Epic"
#, fuzzy
msgid "Another Epic operation is running"
msgstr "Outra operação da GOG já está em execução"
msgstr "Outra operação da Epic está em andamento"
#, fuzzy
msgid "Wine prefix not found"
msgstr "Prefixo não encontrado"
msgstr "Prefixo do Wine não encontrado"
msgid "Game not found."
msgstr "Jogo não encontrado."
@@ -824,6 +839,9 @@ msgstr "min."
msgid "sec."
msgstr "s."
msgid "Pause Game"
msgstr "Pausar jogo"
msgid "Exit"
msgstr "Sair"
@@ -836,6 +854,12 @@ msgstr "Favoritos"
msgid "Recent Games"
msgstr "Jogos Recentes"
msgid "Resume Game"
msgstr "Retomar jogo"
msgid "Failed to pause game"
msgstr "Falha ao pausar o jogo"
msgid "No recent games"
msgstr "Nenhum jogo recente"
@@ -858,12 +882,6 @@ msgstr "Baixar"
msgid "Size"
msgstr "Tamanho"
msgid "LAST LAUNCH"
msgstr "ÚLTIMO LANÇAMENTO"
msgid "TIME SPENT"
msgstr "TEMPO JOGADO"
msgid "MAIN STORY"
msgstr "HISTÓRIA PRINCIPAL"
@@ -888,15 +906,15 @@ msgstr "Configurações"
msgid "Create Log"
msgstr "Criar log"
msgid "Stop Log"
msgstr "Encerrar log"
msgid "Open Card"
msgstr "Abrir cartão"
msgid "Reinstall"
msgstr "Reinstale"
msgid "Stop Log"
msgstr "Encerrar log"
msgid "Failed to start debug session"
msgstr "Falha ao iniciar a sessão de depuração"
@@ -1200,9 +1218,16 @@ 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"
msgid "Exe Settings"
msgstr "Configurações do Exe"
msgid "Edit user.conf"
msgstr ""
msgid "Search settings…"
msgstr "Pesquisar configurações…"
@@ -1227,6 +1252,10 @@ msgstr "Editar PPDB"
msgid "Clear PPDB"
msgstr "Limpar PPDB"
#, fuzzy
msgid "Failed to load global game settings."
msgstr "Falha ao carregar temas"
msgid "Executable path is not available."
msgstr "O caminho do executável não está disponível."
@@ -1236,6 +1265,10 @@ 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"
@@ -1250,18 +1283,25 @@ msgstr ""
msgid "Failed to remove PPDB file:\n"
msgstr "Falha ao remover o arquivo PPDB:\n"
msgid "Failed to apply changes. Check logs."
msgstr "Falha ao aplicar as alterações. Verifique os logs."
msgid "Default"
msgstr "Padrão"
msgid "disabled"
msgstr "desativado"
#, fuzzy
msgid "Download Wine/Proton..."
msgstr "Baixar WINE/Proton para a Steam"
msgid "Enter prefix name"
msgstr "Digite o nome do prefixo"
msgid "Default value"
msgstr "Valor padrão"
msgid "Failed to apply changes. Check logs."
msgstr "Falha ao aplicar as alterações. Verifique os logs."
msgid "Borderless window"
msgstr "Janela sem bordas"
@@ -1465,9 +1505,6 @@ msgstr "Predefinições rápidas"
msgid "Enable {0}"
msgstr "Ativar {0}"
msgid "Default"
msgstr "Padrão"
msgid "FSR upscaling"
msgstr "FSR upscaling"
@@ -1544,6 +1581,15 @@ msgstr "Opacidade do fundo"
msgid "Round corners (px)"
msgstr "Cantos arredondados (px)"
msgid "Font scale"
msgstr "Escala da fonte"
msgid "Font size"
msgstr "Tamanho da fonte"
msgid "Font"
msgstr "Fonte"
msgid "Top-left"
msgstr "Superior esquerdo"
@@ -1853,6 +1899,9 @@ msgstr "Uso de VRAM"
msgid "Layout and limiter"
msgstr "Layout e limitador"
msgid "Fonts"
msgstr "Fontes"
msgid "Presets"
msgstr "Predefinições"
@@ -1942,9 +1991,6 @@ msgstr "Definir"
msgid "Libraries"
msgstr "Bibliotecas"
msgid "Fonts"
msgstr "Fontes"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks não encontrado. Tente novamente."
@@ -2060,9 +2106,6 @@ msgstr "Aguardando…"
msgid "Disk: "
msgstr "Disco: "
msgid "Game Title"
msgstr "Título do jogo"
msgid "Started at"
msgstr "Iniciado em"
@@ -2108,43 +2151,38 @@ msgstr "Atualizando biblioteca da GOG… {0}/{1}"
msgid "Failed to refresh GOG library: {0}"
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
#, fuzzy
msgid "Failed to log out of Epic account"
msgstr "Falha ao sair da conta GOG"
msgstr "Falha ao sair da conta da Epic"
#, fuzzy
msgid "Copy the authorization code to clipboard"
msgstr "Copiar o link para a área de transferência"
msgstr "Copiar o código de autorização para a área de transferência"
msgid "Epic authorization…"
msgstr ""
msgstr "Autorização da Epic…"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Epic login failed: {0}"
msgstr "Falha no login da GOG: {0}"
msgstr "Falha no login da Epic: {0}"
#, fuzzy
msgid "Epic account connected"
msgstr "Conta GOG conectada"
msgstr "Conta da Epic conectada"
#, fuzzy
msgid "Epic account not connected"
msgstr "Conta GOG não conectada"
msgstr "Conta Epic não conectada"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Refreshing Epic library… {0}/{1}"
msgstr "Atualizando biblioteca da GOG… {0}/{1}"
msgstr "Atualizando biblioteca da Epic… {0}/{1}"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Failed to refresh Epic library: {0}"
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
msgstr "Falha ao atualizar a biblioteca da Epic: {0}"
msgid "Select installation folder"
msgstr "Selecionar pasta de instalação"
#, fuzzy
msgid "Another download is running"
msgstr "Baixando agora"
msgstr "Outro download está em andamento"
msgid "Another GOG operation is already running"
msgstr "Outra operação da GOG já está em execução"
@@ -2158,10 +2196,6 @@ msgstr "Selecionar pasta do jogo da GOG"
msgid "Selected folder is not this GOG game"
msgstr "A pasta selecionada não corresponde a este jogo da GOG"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "Excluir '{0}' e todos os arquivos da sua pasta de instalação?"
msgid "Starting"
msgstr "Iniciando"
@@ -2213,8 +2247,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"
@@ -2225,6 +2259,35 @@ msgstr "Oculto"
msgid "Badge View Type:"
msgstr "Modo de Exibição do Selo:"
#, fuzzy
msgid "Theme"
msgstr "Temas"
msgid "Grid"
msgstr ""
#, fuzzy
msgid "List"
msgstr "Claro"
msgid "Vertical"
msgstr "Vertical"
msgid "Horizontal Blocks"
msgstr "Blocos horizontais"
msgid "Horizontal Cards"
msgstr "Cartões horizontais"
msgid "Horizontal endless Blocks"
msgstr "Blocos horizontais infinitos"
msgid "Horizontal endless Cards"
msgstr "Cartões horizontais infinitos"
msgid "Layout"
msgstr "Layout"
#, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2263,9 +2326,16 @@ msgstr "Tipo de Controle:"
msgid "Launch Application in Fullscreen"
msgstr "Iniciar Aplicativo em Tela Cheia"
#, fuzzy
msgid "Hide Cursor in Fullscreen"
msgstr "Forçar janelas em tela cheia"
msgid "Auto Fullscreen on Gamepad connected"
msgstr "Tela Cheia Automática ao Conectar o Controle"
msgid "Disable Boot Animation"
msgstr ""
msgid "Minimize to tray on close"
msgstr "Minimizar para a bandeja ao fechar"
@@ -2282,7 +2352,7 @@ msgid "Hide Control Hints"
msgstr "Ocultar dicas de controle"
msgid "Force English language"
msgstr ""
msgstr "Forçar idioma inglês"
msgid "Enable UI Sounds"
msgstr "Ativar sons da interface"
@@ -2290,12 +2360,18 @@ 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"
msgstr ""
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 ""
msgid "Download WINE/Proton to Steam"
msgstr "Baixar WINE/Proton para a Steam"
@@ -2451,9 +2527,8 @@ msgstr "Dispositivos de reprodução"
msgid "Output"
msgstr "Saída"
#, fuzzy
msgid "Return to desktop"
msgstr "Adicionar à Área de Trabalho"
msgstr "Voltar para a área de trabalho"
msgid "Logout"
msgstr "Encerrar sessão"
@@ -2547,9 +2622,6 @@ msgstr "Não foi possível excluir o tema."
msgid "Prefix:"
msgstr "Prefixo:"
msgid "Use by default"
msgstr "Usar por padrão"
msgid "Wine Configuration"
msgstr "Configuração do Wine"
@@ -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-05 17:10+0000\n"
"Last-Translator: adem4ik <adem4ik@noreply.codeberg.org>\n"
"PO-Revision-Date: 2026-10-09 11:06+0000\n"
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/ru/>\n"
"Language: ru\n"
@@ -19,7 +19,7 @@ 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.8.1\n"
"X-Generator: Weblate 2026.9.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
@@ -112,8 +112,8 @@ msgid ""
"Are you sure you want to delete folder '{folder_name}'? This action "
"cannot be undone."
msgstr ""
"Вы уверены, что хотите удалить папку '{folder_name}'? Это действие нельзя"
" отменить."
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
"отменить."
msgid "Yes"
msgstr "Да"
@@ -130,8 +130,7 @@ msgid ""
"Are you sure you want to delete file '{file_name}'? This action cannot be"
" undone."
msgstr ""
"Вы уверены, что хотите удалить файл '{file_name}'? Это действие нельзя "
"отменить."
"Уверены, что хотите удалить файл «{file_name}»? Это действие нельзя отменить."
#, python-brace-format
msgid "Failed to delete file: {error}"
@@ -175,6 +174,9 @@ msgstr "Удалить"
msgid "Import"
msgstr "Импорт"
msgid "Edit Shortcut"
msgstr "Редактировать ярлык"
msgid "Update"
msgstr "Обновить"
@@ -187,9 +189,6 @@ msgstr "Включить наложение EOS"
msgid "Open Game Folder"
msgstr "Открыть папку с игрой"
msgid "Edit Shortcut"
msgstr "Редактировать ярлык"
msgid "Remove from Steam"
msgstr "Удалить из Steam"
@@ -232,8 +231,8 @@ msgid ""
"Are you sure you want to delete '{game_name}'? This will remove the "
".desktop file and custom data."
msgstr ""
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению "
"файла .desktop и пользовательских данных."
"Уверены, что хотите удалить «{game_name}»? Это приведёт к удалению файла "
".desktop и пользовательских данных."
#, python-brace-format
msgid "Failed to add '{game_name}' to {location}: {error}"
@@ -257,12 +256,27 @@ msgstr "Рабочий стол"
msgid "Failed to save custom data: {error}"
msgstr "Не удалось сохранить пользовательские данные: {error}"
#, python-brace-format
msgid "Executable not found"
msgstr "Исполняемый файл не найден"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Исполняемый файл не найден: {0}"
msgid "Select another executable"
msgstr "Выбрать другой исполняемый файл"
msgid "Game name and executable path are required"
msgstr "Требуются название игры и путь к исполняемому файлу"
msgid "Failed to generate .desktop file data"
msgstr "Не удалось сгенерировать данные файла .desktop"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "Не удалось сохранить файл .desktop: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "Не удалось удалить старый файл .desktop: {error}"
@@ -271,10 +285,6 @@ msgstr "Не удалось удалить старый файл .desktop: {erro
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Удалён старый файл .desktop для '{game_name}'"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "Не удалось сохранить файл .desktop: {error}"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Не удалось скопировать обложку: {error}"
@@ -355,15 +365,24 @@ msgstr "Сломано"
msgid "Denied"
msgstr "Отказано"
msgid "Game Title"
msgstr "Название игры"
msgid "LAST LAUNCH"
msgstr "ПОСЛЕДНИЙ ЗАПУСК"
msgid "TIME SPENT"
msgstr "ПРОВЕДЁННОЕ ВРЕМЯ"
msgid "Library"
msgstr "Библиотека"
msgid "Unknown Game"
msgstr "Неизвестная игра"
msgid "Downloading Wine…"
msgstr "Скачивание Wine…"
msgid "Library"
msgstr "Библиотека"
msgid "Auto Install"
msgstr "Автоустановка"
@@ -424,10 +443,6 @@ msgstr "Никогда"
msgid "Unknown Application"
msgstr "Неизвестное приложение"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Исполняемый файл не найден: {0}"
#, python-brace-format
msgid "File not found: {0}"
msgstr "Файл не найден: {0}"
@@ -452,6 +467,10 @@ msgstr "Установить зависимости"
msgid "32-bit dependencies are missing. Install them now?"
msgstr "Отсутствуют 32-битные зависимости. Установить их сейчас?"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "Удалить «{0}» и все файлы в папке установки?"
msgid "Select Epic game folder"
msgstr "Выберите папку игры Epic"
@@ -819,6 +838,9 @@ msgstr "мин."
msgid "sec."
msgstr "сек."
msgid "Pause Game"
msgstr "Приостановить игру"
msgid "Exit"
msgstr "Выход"
@@ -831,6 +853,12 @@ msgstr "Избранное"
msgid "Recent Games"
msgstr "Недавние"
msgid "Resume Game"
msgstr "Возобновить игру"
msgid "Failed to pause game"
msgstr "Не удалось приостановить игру"
msgid "No recent games"
msgstr "Нет недавних игр"
@@ -839,7 +867,7 @@ msgstr "Нет избранных"
#, python-brace-format
msgid "Launch \"{name}\" with PortProton"
msgstr "Запустить \"{name}\" с помощью PortProton"
msgstr "Запустить «{name}» с помощью PortProton"
msgid "Debug log mode was launched"
msgstr "Запущен режим логирования"
@@ -853,12 +881,6 @@ msgstr "Скачать"
msgid "Size"
msgstr "Размер"
msgid "LAST LAUNCH"
msgstr "ПОСЛЕДНИЙ ЗАПУСК"
msgid "TIME SPENT"
msgstr "ПРОВЕДЁННОЕ ВРЕМЯ"
msgid "MAIN STORY"
msgstr "СЮЖЕТ"
@@ -883,15 +905,15 @@ msgstr "Настройки"
msgid "Create Log"
msgstr "Создать лог"
msgid "Stop Log"
msgstr "Остановить лог"
msgid "Open Card"
msgstr "Открыть карточку"
msgid "Reinstall"
msgstr "Переустановить"
msgid "Stop Log"
msgstr "Остановить лог"
msgid "Failed to start debug session"
msgstr "Не удалось запустить процесс отладки"
@@ -1143,7 +1165,7 @@ msgid ""
"\n"
"This action cannot be undone."
msgstr ""
"Вы уверены, что хотите удалить {} выбранные WINE/Proton?\n"
"Уверены, что хотите удалить {} выбранных WINE/Proton?\n"
"\n"
"Это действие нельзя отменить."
@@ -1195,9 +1217,15 @@ msgstr "Скачивание или распаковка отменены."
msgid "System WINE"
msgstr "Системный WINE"
msgid "Global Game Settings"
msgstr "Глобальные настройки игры"
msgid "Exe Settings"
msgstr "Настройки EXE"
msgid "Edit user.conf"
msgstr "Изменить user.conf"
msgid "Search settings…"
msgstr "Поиск настроек…"
@@ -1222,6 +1250,9 @@ msgstr "Редактировать PPDB"
msgid "Clear PPDB"
msgstr "Очистить PPDB"
msgid "Failed to load global game settings."
msgstr "Не удалось загрузить глобальные настройки игры."
msgid "Executable path is not available."
msgstr "Путь к исполняемому файлу недоступен."
@@ -1231,6 +1262,9 @@ msgstr "Файл PPDB не существует по адресу: "
msgid "Failed to open PPDB file:\n"
msgstr "Не удалось открыть файл PPDB:\n"
msgid "Failed to open file:\n"
msgstr "Не удалось открыть файл:\n"
msgid "Information"
msgstr "Информация"
@@ -1238,23 +1272,29 @@ msgid "Confirm PPDB Clear"
msgstr "Подтвердите очистку PPDB"
msgid "Are you sure you want to clear settings? This action cannot be undone."
msgstr "Вы уверены, что хотите сбросить настройки? Это действие нельзя отменить."
msgstr "Уверены, что хотите сбросить настройки? Это действие нельзя отменить."
msgid "Failed to remove PPDB file:\n"
msgstr "Не удалось удалить файл PPDB:\n"
msgid "Failed to apply changes. Check logs."
msgstr "Не удалось применить изменения. Проверьте логи."
msgid "Default"
msgstr "Стандартный"
msgid "disabled"
msgstr "отключено"
msgid "Download Wine/Proton..."
msgstr "Скачать Wine/Proton..."
msgid "Enter prefix name"
msgstr "Введите имя префикса"
msgid "Default value"
msgstr "По умолчанию"
msgid "Failed to apply changes. Check logs."
msgstr "Не удалось применить изменения. Проверьте логи."
msgid "Borderless window"
msgstr "Без рамок"
@@ -1443,7 +1483,7 @@ msgid "Display heatmap-style debug view of HDR luminance in nits"
msgstr "Отображение отладочной тепловой карты вида яркости HDR в нитах"
msgid "Launch with the mangoapp (mangohud) performance overlay enabled"
msgstr "Запуск с включенным mangoapp (mangohud)"
msgstr "Запуск с оверлеем производительности mangoapp (mangohud)"
msgid "Enable adaptive sync if available (variable rate refresh)"
msgstr ""
@@ -1460,9 +1500,6 @@ msgstr "Быстрые профили"
msgid "Enable {0}"
msgstr "Включить {0}"
msgid "Default"
msgstr "Стандартный"
msgid "FSR upscaling"
msgstr "FSR масштабирование"
@@ -1539,6 +1576,15 @@ msgstr "Прозрачность фона"
msgid "Round corners (px)"
msgstr "Скругление углов (пикс)"
msgid "Font scale"
msgstr "Масштаб шрифта"
msgid "Font size"
msgstr "Размер шрифта"
msgid "Font"
msgstr "Шрифт"
msgid "Top-left"
msgstr "Слева сверху"
@@ -1819,7 +1865,7 @@ msgid "Local time"
msgstr "Местное время"
msgid "Time without label"
msgstr "Скрывать слово \"Time\" перед временем"
msgstr "Скрывать слово «Time» перед временем"
msgid "Force trilinear filtering"
msgstr "Включить трилинейную фильтрацию"
@@ -1848,6 +1894,9 @@ msgstr "Использование видеопамяти"
msgid "Layout and limiter"
msgstr "Компоновка и ограничения"
msgid "Fonts"
msgstr "Шрифты"
msgid "Presets"
msgstr "Профили"
@@ -1936,9 +1985,6 @@ msgstr "Выбор"
msgid "Libraries"
msgstr "Библиотеки"
msgid "Fonts"
msgstr "Шрифты"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку."
@@ -2054,9 +2100,6 @@ msgstr "Ожидание…"
msgid "Disk: "
msgstr "Диск: "
msgid "Game Title"
msgstr "Название игры"
msgid "Started at"
msgstr "Начато"
@@ -2145,10 +2188,6 @@ msgstr "Выберите папку игры GOG"
msgid "Selected folder is not this GOG game"
msgstr "Выбранная папка не является этой игрой GOG"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "Удалить «{0}» и все файлы в папке установки?"
msgid "Starting"
msgstr "Запуск"
@@ -2198,11 +2237,11 @@ msgstr "Все"
msgid "Display Filter:"
msgstr "Фильтр:"
msgid "Only Installed"
msgstr "Только Установленные"
msgid "Show only installed games"
msgstr "Показывать только установленные игры"
msgid "Detailed"
msgstr "Детальный"
msgstr "Подробный"
msgid "Hidden"
msgstr "Скрытый"
@@ -2210,13 +2249,40 @@ msgstr "Скрытый"
msgid "Badge View Type:"
msgstr "Тип отображения бейджей:"
msgid "Theme"
msgstr "Тема"
msgid "Grid"
msgstr "Сетка"
msgid "List"
msgstr "Список"
msgid "Vertical"
msgstr "Вертикальная"
msgid "Horizontal Blocks"
msgstr "Горизонтальные блоки"
msgid "Horizontal Cards"
msgstr "Горизонтальные карточки"
msgid "Horizontal endless Blocks"
msgstr "Бесконечные горизонтальные блоки"
msgid "Horizontal endless Cards"
msgstr "Бесконечные горизонтальные карточки"
msgid "Layout"
msgstr "Режим отображения"
#, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
"will remove the .desktop files and custom data."
msgstr ""
"Вы уверены, что хотите удалить {count} карточек без исполняемого файла? "
"Это приведёт к удалению файлов .desktop и пользовательских данных."
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
"приведёт к удалению файлов .desktop и пользовательских данных."
msgid "PortProton Settings"
msgstr "Настройки PortProtonQt"
@@ -2248,9 +2314,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 "Сворачивать в область уведомления при закрытии"
@@ -2261,7 +2333,7 @@ msgid "Start in tray"
msgstr "Запускать свёрнутым"
msgid "Hide Auto-Install Tab"
msgstr "Скрывать вкладку \"Автоустановка\""
msgstr "Скрывать вкладку «Автоустановка»"
msgid "Hide Control Hints"
msgstr "Скрывать подсказки управления"
@@ -2275,12 +2347,18 @@ msgstr "Включить звуки интерфейса"
msgid "Show compatibility report after game crash"
msgstr "Показывать отчёт о совместимости после сбоя игры"
msgid "Disable ALT Linux i586 dependency check"
msgstr "Отключить проверку зависимостей ALT Linux i586"
msgid "Disable runtime download"
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"
@@ -2355,8 +2433,7 @@ msgstr "Подтверждение сброса"
msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr ""
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
"отменить."
"Уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
msgid "Migrate old PortProton shortcuts to PortProtonQt format?"
msgstr "Обновить старые ярлыки PortProton до формата PortProtonQt?"
@@ -2365,7 +2442,7 @@ msgid "Confirm Clear Cache"
msgstr "Подтверждение очистки кэша"
msgid "Are you sure you want to clear the cache? This action cannot be undone."
msgstr "Вы уверены, что хотите очистить кэш? Это действие нельзя отменить."
msgstr "Уверены, что хотите очистить кэш? Это действие нельзя отменить."
#, python-brace-format
msgid "AppImage integrated at: {path}"
@@ -2530,9 +2607,6 @@ msgstr "Не удалось удалить тему."
msgid "Prefix:"
msgstr "Префикс:"
msgid "Use by default"
msgstr "Использовать по умолчанию"
msgid "Wine Configuration"
msgstr "Конфигурация Wine"
@@ -2562,7 +2636,7 @@ msgstr "Не удалось запустить процесс."
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
msgstr "Уверены, что хотите очистить префикс «{}»?"
msgid "Failed to start prefix clear process."
msgstr "Не удалось запустить процесс очистки префикса."
@@ -2595,7 +2669,7 @@ msgstr "Восстановление префикса не удалось."
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
msgstr "Уверены, что хотите удалить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' deleted."
@@ -169,6 +169,9 @@ msgstr "删除"
msgid "Import"
msgstr ""
msgid "Edit Shortcut"
msgstr "编辑快捷方式"
msgid "Update"
msgstr "更新"
@@ -183,9 +186,6 @@ msgstr "启用 mangoapp 叠加层"
msgid "Open Game Folder"
msgstr "打开游戏文件夹"
msgid "Edit Shortcut"
msgstr "编辑快捷方式"
msgid "Remove from Steam"
msgstr "从 Steam 移除"
@@ -251,12 +251,28 @@ msgstr "桌面"
msgid "Failed to save custom data: {error}"
msgstr "保存自定义数据失败:{error}"
#, fuzzy, python-brace-format
msgid "Executable not found"
msgstr "找不到可执行文件:{0}"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "找不到可执行文件:{0}"
#, fuzzy
msgid "Select another executable"
msgstr "删除没有可执行文件的卡片"
msgid "Game name and executable path are required"
msgstr "游戏名称和可执行文件路径为必填项"
msgid "Failed to generate .desktop file data"
msgstr "生成 .desktop 文件数据失败"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "保存 .desktop 文件失败:{error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "删除旧的 .desktop 文件失败:{error}"
@@ -265,10 +281,6 @@ msgstr "删除旧的 .desktop 文件失败:{error}"
msgid "Removed old .desktop file for '{game_name}'"
msgstr "已移除\"{game_name}\"的旧版 .desktop 文件"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "保存 .desktop 文件失败:{error}"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "复制封面图像失败:{error}"
@@ -345,15 +357,25 @@ msgstr "损坏"
msgid "Denied"
msgstr "已拒绝"
#, fuzzy
msgid "Game Title"
msgstr "游戏宽度"
msgid "LAST LAUNCH"
msgstr "上次启动"
msgid "TIME SPENT"
msgstr "游戏时长"
msgid "Library"
msgstr "库"
msgid "Unknown Game"
msgstr "未知游戏"
msgid "Downloading Wine…"
msgstr "正在下载 Wine…"
msgid "Library"
msgstr "库"
msgid "Auto Install"
msgstr "自动安装"
@@ -415,10 +437,6 @@ msgstr "从不"
msgid "Unknown Application"
msgstr "未知应用程序"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "找不到可执行文件:{0}"
#, python-brace-format
msgid "File not found: {0}"
msgstr "找不到文件:{0}"
@@ -443,6 +461,10 @@ msgstr "安装依赖"
msgid "32-bit dependencies are missing. Install them now?"
msgstr "缺少 32 位依赖项。现在安装吗?"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
#, fuzzy
msgid "Select Epic game folder"
msgstr "删除文件夹"
@@ -780,6 +802,10 @@ msgstr "分钟。"
msgid "sec."
msgstr "秒。"
#, fuzzy
msgid "Pause Game"
msgstr "启动游戏"
msgid "Exit"
msgstr "退出"
@@ -792,6 +818,14 @@ msgstr "收藏夹"
msgid "Recent Games"
msgstr "最近游戏"
#, fuzzy
msgid "Resume Game"
msgstr "最近游戏"
#, fuzzy
msgid "Failed to pause game"
msgstr "停止游戏失败"
msgid "No recent games"
msgstr "无最近游戏"
@@ -814,12 +848,6 @@ msgstr "下载"
msgid "Size"
msgstr "大小"
msgid "LAST LAUNCH"
msgstr "上次启动"
msgid "TIME SPENT"
msgstr "游戏时长"
msgid "MAIN STORY"
msgstr "主线剧情"
@@ -844,15 +872,15 @@ msgstr "设置"
msgid "Create Log"
msgstr "创建日志"
msgid "Stop Log"
msgstr "停止日志"
msgid "Open Card"
msgstr "打开卡片"
msgid "Reinstall"
msgstr "重新安装"
msgid "Stop Log"
msgstr "停止日志"
msgid "Failed to start debug session"
msgstr "启动调试会话失败"
@@ -1155,9 +1183,16 @@ msgstr "下载或提取已被取消。"
msgid "System WINE"
msgstr "系统 WINE"
#, fuzzy
msgid "Global Game Settings"
msgstr "保存设置"
msgid "Exe Settings"
msgstr "可执行文件设置"
msgid "Edit user.conf"
msgstr ""
msgid "Search settings…"
msgstr "搜索设置…"
@@ -1182,6 +1217,10 @@ msgstr "编辑 PPDB"
msgid "Clear PPDB"
msgstr "清除 PPDB"
#, fuzzy
msgid "Failed to load global game settings."
msgstr "加载主题失败"
msgid "Executable path is not available."
msgstr "可执行文件路径不可用。"
@@ -1191,6 +1230,10 @@ 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 "信息"
@@ -1203,18 +1246,25 @@ msgstr "确定要清除设置吗?此操作无法撤销。"
msgid "Failed to remove PPDB file:\n"
msgstr "移除 PPDB 文件失败:\n"
msgid "Failed to apply changes. Check logs."
msgstr "应用更改失败。请检查日志。"
msgid "Default"
msgstr "默认"
msgid "disabled"
msgstr "已禁用"
#, fuzzy
msgid "Download Wine/Proton..."
msgstr "将 WINE/Proton 下载到 Steam"
msgid "Enter prefix name"
msgstr "输入前缀名称"
msgid "Default value"
msgstr "默认值"
msgid "Failed to apply changes. Check logs."
msgstr "应用更改失败。请检查日志。"
msgid "Borderless window"
msgstr "无边框窗口"
@@ -1408,9 +1458,6 @@ msgstr "快速预设"
msgid "Enable {0}"
msgstr "启用 {0}"
msgid "Default"
msgstr "默认"
msgid "FSR upscaling"
msgstr "FSR 升频"
@@ -1487,6 +1534,18 @@ msgstr "背景不透明度"
msgid "Round corners (px)"
msgstr "圆角(像素)"
#, fuzzy
msgid "Font scale"
msgstr "字体"
#, fuzzy
msgid "Font size"
msgstr "字体"
#, fuzzy
msgid "Font"
msgstr "字体"
msgid "Top-left"
msgstr "左上角"
@@ -1796,6 +1855,9 @@ msgstr "VRAM 使用量"
msgid "Layout and limiter"
msgstr "布局和限制器"
msgid "Fonts"
msgstr "字体"
msgid "Presets"
msgstr "预设"
@@ -1881,9 +1943,6 @@ msgstr "设置"
msgid "Libraries"
msgstr "库"
msgid "Fonts"
msgstr "字体"
msgid "Winetricks not found. Please try again."
msgstr "未找到 Winetricks。请重试。"
@@ -2005,10 +2064,6 @@ msgstr "警告"
msgid "Disk: "
msgstr "链接:"
#, fuzzy
msgid "Game Title"
msgstr "游戏宽度"
#, fuzzy
msgid "Started at"
msgstr "状态"
@@ -2108,10 +2163,6 @@ msgstr "删除文件夹"
msgid "Selected folder is not this GOG game"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
#, fuzzy
msgid "Starting"
msgstr "设置项"
@@ -2167,9 +2218,8 @@ msgstr "全部"
msgid "Display Filter:"
msgstr "显示筛选:"
#, fuzzy
msgid "Only Installed"
msgstr "已安装"
msgid "Show only installed games"
msgstr "仅显示已安装的游戏"
msgid "Detailed"
msgstr "详细"
@@ -2180,6 +2230,41 @@ msgstr "隐藏"
msgid "Badge View Type:"
msgstr "徽章视图类型:"
#, fuzzy
msgid "Theme"
msgstr "主题"
msgid "Grid"
msgstr ""
#, fuzzy
msgid "List"
msgstr "浅色"
#, fuzzy
msgid "Vertical"
msgstr "部分"
#, fuzzy
msgid "Horizontal Blocks"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Horizontal Cards"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Horizontal endless Blocks"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Horizontal endless Cards"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Layout"
msgstr "注销"
#, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2219,9 +2304,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 "关闭时最小化到系统托盘"
@@ -2246,12 +2338,18 @@ msgstr "启用界面音效"
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgid "Disable runtime download"
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"
@@ -2504,9 +2602,6 @@ msgstr "加载主题失败"
msgid "Prefix:"
msgstr "前缀:"
msgid "Use by default"
msgstr "默认使用"
msgid "Wine Configuration"
msgstr "Wine 配置"
+18 -14
View File
@@ -42,20 +42,24 @@ STORE_CONTENT_LOCALES = {'es': 'es-ES', 'pt': 'pt-BR', 'zh': 'zh-CN'}
# Try local locale directory first, fallback to system for development
_local_localedir = Path(__file__).parent / "locales"
_system_prefix = "/app" if os.getenv("FLATPAK_ID") else os.getenv("SHARUN_DIR", "/usr")
_system_localedir = Path(_system_prefix) / "share" / "locale"
_locale_dirs = [_local_localedir]
if os.getenv("SHARUN_DIR"):
_locale_dirs.append(Path(os.environ["SHARUN_DIR"]) / "share" / "locale")
_locale_dirs.extend(
Path(data_dir) / "locale"
for data_dir in os.getenv(
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
).split(os.pathsep)
if data_dir
)
try:
translate = gettext.translation(
domain="portprotonqt",
localedir=_local_localedir,
)
except FileNotFoundError:
translate = gettext.translation(
domain="portprotonqt",
localedir=_system_localedir,
fallback=True,
)
translate = gettext.NullTranslations()
for _locale_dir in _locale_dirs:
try:
translate = gettext.translation("portprotonqt", localedir=_locale_dir)
break
except FileNotFoundError:
continue
def _(message: str) -> str:
@@ -81,7 +85,7 @@ def get_system_locale():
if ui_config.get_force_english():
return 'en'
loc = locale.getdefaultlocale()[0]
loc = locale.getlocale()[0]
return loc if loc else 'en'
def get_steam_language():
+307 -163
View File
@@ -5,7 +5,6 @@ import sys
import signal
import shlex
import subprocess
import tempfile
import re
import time
from datetime import datetime
@@ -67,6 +66,7 @@ from portprotonqt.disc_image_utils import DiscImageManager
from portprotonqt.compatibility_report import (
CompatibilityLaunch,
analyze_launch,
get_running_game_processes,
has_dxvk_vulkan_incompatibility,
is_suspected_crash,
)
@@ -93,61 +93,14 @@ STORE_LAUNCH_GRACE_SECONDS = 10
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")
ALT_BIARCH_REPO = "x86_64-i586"
ALT_BIARCH_URL = "https://www.altlinux.org/Biarch"
GAME_LAUNCH_MARKER = "PORTPROTONQT_GAME_LAUNCH_STARTED"
ALT_I586_BASE_PACKAGES = (
"glibc-nss",
"glibc-gconv-modules",
"libnm",
"libvulkan1",
"libd3d",
"libfreetype",
)
ALT_I586_PACKAGE_PREFIXES = ("libnss-", "xorg-dri-")
ALT_I586_EXCLUDED_PACKAGES = ("libnss-fallback",)
ALT_INSTALL_SCRIPT_MODE = 0o700
ALT_I586_INSTALL_SCRIPT = """#!/bin/sh
set -u
trap 'rm -f "$0"' EXIT
LIST=""
add_if_needed() {
package="$1"
i586_package="i586-$package"
if rpm -q "$package" >/dev/null 2>&1 && ! rpm -q "$i586_package" >/dev/null 2>&1; then
LIST="$LIST $i586_package"
fi
}
for package in glibc-nss glibc-gconv-modules libnm libvulkan1 libd3d libfreetype; do
add_if_needed "$package"
done
for package in $(rpm -qa --qf '%{NAME}\\n' \
| grep '^libnss-' \
| grep -v '^libnss-fallback$' || true); do
add_if_needed "$package"
done
for package in $(rpm -qa --qf '%{NAME}\\n' | grep '^xorg-dri-' || true); do
add_if_needed "$package"
done
if ! apt-get update; then
exit 1
fi
if [ -n "$LIST" ]; then
apt-get install -y $LIST
fi
"""
GAME_EXIT_MARKER = "PORTPROTONQT_GAME_EXIT_CODE"
class MainWindow(
MainWindowControlHintsMixin,
@@ -182,19 +135,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
@@ -266,6 +249,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)
@@ -308,7 +300,8 @@ class MainWindow(
self.current_theme_name = selected_theme
# Apply theme but defer heavy font loading
self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.tray_manager = None
QTimer.singleShot(0, lambda: self._initialize_tray(app_name))
self.card_width = ui_config.get_card_width()
self.auto_card_width = ui_config.get_auto_card_width()
self.setWindowTitle(f"{app_name} {version}")
@@ -333,6 +326,7 @@ class MainWindow(
self._pending_log_exe: str | None = None
self.appimageUpdateWorker: AppImageUpdateWorker | None = None
self.gogdlUpdateWorker: QThread | None = None
self.initialCommandWorker: QThread | None = None
self.compatibility_report_ready.connect(self._show_compatibility_report)
self.game_library_manager = GameLibraryManager(self, self.theme, None)
@@ -353,6 +347,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
@@ -413,7 +416,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)
@@ -550,31 +557,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."""
@@ -771,7 +806,7 @@ class MainWindow(
if state is None:
continue
status, percent, launch_started = state
if launch_started:
if launch_started or status == GAME_EXIT_MARKER:
continue
self._update_install_progress(status, percent)
@@ -873,6 +908,9 @@ class MainWindow(
line_lower = line.lower()
status = percent = None
launch_started = line_text == GAME_LAUNCH_MARKER
exit_match = re.fullmatch(rf"{GAME_EXIT_MARKER}=([0-9]{{1,3}})", line_text)
if exit_match:
return GAME_EXIT_MARKER, int(exit_match.group(1)), False
progress_match = re.fullmatch(r'([0-9]*\.?[0-9]+)%', line_text)
if progress_match:
percent = float(progress_match.group(1))
@@ -936,17 +974,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
@@ -956,6 +990,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
@@ -969,6 +1014,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 = []
@@ -1094,6 +1140,9 @@ class MainWindow(
def on_game_info(game: dict, steam_info: dict) -> None:
nonlocal processed_count
app_id = str(game.get("app_id", ""))
name, custom_cover = MainWindow._get_custom_store_data(
"gog", app_id, str(game.get("title", app_id))
)
is_installed = api.is_game_installed(app_id, installed)
if not only_installed or is_installed:
action = "launch" if is_installed else "install"
@@ -1103,8 +1152,8 @@ class MainWindow(
) if target else 0
playtime_seconds = playtime_seconds or 0
games.append((
str(game.get("title", app_id)),
str(game.get("description", "")), str(game.get("cover", "")),
name, str(game.get("description", "")),
custom_cover or str(game.get("cover", "")),
app_id,
steam_info.get("controller_support", ""),
f"gog://{action}/{app_id}",
@@ -1153,6 +1202,9 @@ class MainWindow(
def on_game_info(game: dict, steam_info: dict) -> None:
nonlocal processed_count
app_id = str(game["app_id"])
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)
if not only_installed or installed:
target = self.egs_api.get_launch_target(app_id) if installed else None
@@ -1164,8 +1216,8 @@ class MainWindow(
) if target else 0
playtime = playtime or 0
games.append((
str(game.get("title", app_id)), description,
str(game.get("cover", "")), app_id,
name, description,
custom_cover or str(game.get("cover", "")), app_id,
steam_info.get("controller_support", ""),
f"egs://{'launch' if installed else 'install'}/{app_id}",
get_last_launch(f"egs-{app_id}") if target else _("Never"),
@@ -1263,12 +1315,30 @@ class MainWindow(
appid: int | str, fallback_name: str
) -> tuple[str, str]:
"""Return custom Steam name and cover stored by AppID."""
return MainWindow._get_custom_store_data("steam", appid, fallback_name)
@staticmethod
def _get_custom_store_data(
source: str, appid: int | str, fallback_name: str
) -> tuple[str, str]:
"""Return custom store name and cover stored by game ID."""
appid_str = str(appid)
if appid_str in {"", ".", ".."} or os.path.basename(appid_str) != appid_str:
return fallback_name, ""
xdg_data_home = os.getenv(
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
)
storage_id = f"{source}-{appid_str}"
game_dir = os.path.join(
xdg_data_home, "PortProtonQt", "custom_data", str(appid)
xdg_data_home, "PortProtonQt", "custom_data", storage_id
)
if source == "steam" and not os.path.isdir(game_dir):
# Remove the legacy AppID-only fallback after the migration period.
legacy_dir = os.path.join(
xdg_data_home, "PortProtonQt", "custom_data", appid_str
)
if os.path.isdir(legacy_dir):
game_dir = legacy_dir
metadata_path = os.path.join(game_dir, "metadata.txt")
custom_name = fallback_name
if os.path.isfile(metadata_path):
@@ -2014,8 +2084,8 @@ class MainWindow(
def checkTargetExe(self):
"""Update launch state from the marker and PortProton process."""
child_running = self._has_running_game_process()
dependency_active = self._drain_launch_output_progress()
child_running = self._has_running_game_process()
if dependency_active:
# Dependencies are downloading/extracting - update button with progress
@@ -2038,7 +2108,8 @@ class MainWindow(
def _finish_silent_launch(self) -> None:
if not getattr(self, "silent_launch_mode", False):
return
self.tray_manager.tray_icon.hide()
if self.tray_manager is not None:
self.tray_manager.tray_icon.hide()
QApplication.quit()
def _set_running_button_stop(self) -> None:
@@ -2073,6 +2144,9 @@ class MainWindow(
status, percent, launch_started = self.launch_output_queue.get_nowait()
except Empty:
break
if status == GAME_EXIT_MARKER:
self.game_command_exit_code = int(percent) if percent is not None else None
continue
if launch_started:
if not self.game_launch_started:
self.game_launch_monotonic = time.monotonic()
@@ -2126,6 +2200,9 @@ class MainWindow(
self.wine_download_percent = 0.0
self.wine_download_status = _("Downloading Wine…")
self.game_launch_started = False
self.game_observed_processes = {}
self.game_observed_started = None
self.game_command_exit_code = None
launcher_only = getattr(self, "launcher_process_only", False)
self.launcher_process_only = False
self.game_processes = [] if launcher_only else [
@@ -2234,22 +2311,33 @@ class MainWindow(
return extract_exec_target_path(exec_line) or ""
def _has_running_game_process(self) -> bool:
if self._observe_game_processes():
return True
process_alive = any(proc.poll() is None for proc in self.game_processes)
if process_alive and not (
getattr(self, "launcher_process_only", False)
and self.game_launch_started
and (
self.game_launch_started
or getattr(self, "game_stopped_by_user", False)
)
):
self.game_process_exit_monotonic = None
if getattr(self, "game_observed_started", None) is None:
self.game_process_exit_monotonic = None
return True
target = str(self.target_exe or "").lower()
if target:
for process in psutil.process_iter(attrs=["name"]):
if target and not getattr(self, "game_start_exe", None):
for process in psutil.process_iter(attrs=["name", "status"]):
try:
if str(process.info.get("name") or "").lower() == target:
if (
str(process.info.get("name") or "").lower() == target
and process.info.get("status") != psutil.STATUS_ZOMBIE
):
self.game_process_exit_monotonic = None
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if getattr(self, "game_stopped_by_user", False):
return False
launch_started = getattr(self, "game_launch_monotonic", None)
if launch_started is not None:
now = time.monotonic()
@@ -2261,6 +2349,26 @@ class MainWindow(
return False
return (datetime.now() - started).total_seconds() < STORE_LAUNCH_GRACE_SECONDS
def _observe_game_processes(self) -> bool:
executable = getattr(self, "game_start_exe", None)
started = getattr(self, "game_start_time", None)
if not executable or started is None:
return False
running = get_running_game_processes(executable, started.timestamp())
previous = getattr(self, "game_observed_processes", {})
for identity in running.keys() - previous.keys():
logger.info("Game process appeared: pid=%s executable=%s", identity[0], running[identity])
for identity in previous.keys() - running.keys():
logger.info("Game process disappeared: pid=%s executable=%s", identity[0], previous[identity])
self.game_observed_processes = running
if running:
if getattr(self, "game_observed_started", None) is None:
self.game_observed_started = time.monotonic()
self.game_process_exit_monotonic = None
elif previous:
self.game_process_exit_monotonic = time.monotonic()
return bool(running)
def _start_launch_output_reader(self, process: subprocess.Popen[str]) -> None:
"""Start background reader for PortProton launch output."""
self.launch_output_queue = Queue()
@@ -2325,28 +2433,43 @@ class MainWindow(
def _analyze_short_launch(self) -> None:
if not ui_config.get_crash_reports_enabled():
logger.info("Compatibility report skipped: crash reports disabled")
return
executable = getattr(self, "game_start_exe", None) or ""
started = getattr(self, "game_launch_monotonic", None)
started = getattr(self, "game_observed_started", None)
if started is None:
started = getattr(self, "game_launch_monotonic", None)
if started is None:
logger.info("Compatibility report skipped: no launch marker for %s", executable)
return
ended = getattr(self, "game_process_exit_monotonic", None)
duration = (ended or time.monotonic()) - started
stopped_by_user = getattr(self, "game_stopped_by_user", False)
exit_code = next(
launcher_exit_code = next(
(process.poll() for process in self.game_processes if process.poll() is not None),
None,
)
exit_code = getattr(self, "game_command_exit_code", None)
launch = CompatibilityLaunch(
executable=executable,
exit_code=exit_code,
duration=duration,
)
logger.info(
"Compatibility launch: executable=%s duration=%.3fs wine_exit_code=%s "
"launcher_exit_code=%s stopped_by_user=%s process_observed=%s",
executable, duration, exit_code, launcher_exit_code, stopped_by_user,
getattr(self, "game_observed_started", None) is not None,
)
worker = Thread(
target=self._build_compatibility_report,
args=(launch, stopped_by_user),
daemon=True,
)
self.compatibility_workers = [
active for active in getattr(self, "compatibility_workers", []) if active.is_alive()
]
self.compatibility_workers.append(worker)
worker.start()
def _build_compatibility_report(
@@ -2355,9 +2478,17 @@ class MainWindow(
try:
incompatible_dxvk = has_dxvk_vulkan_incompatibility(launch.executable)
if not incompatible_dxvk and not is_suspected_crash(
launch.duration, stopped_by_user, launch.executable
launch.duration, stopped_by_user, launch.executable, launch.exit_code
):
logger.info(
"Compatibility report skipped: crash criteria not met "
"(duration=%.3fs stopped_by_user=%s windows_executable=%s exists=%s)",
launch.duration, stopped_by_user,
launch.executable.lower().endswith((".exe", ".msi")),
os.path.isfile(launch.executable),
)
return
logger.info("Compatibility analysis started: %s", launch.executable)
report = analyze_launch(launch, self.portproton_location or "")
except (OSError, ValueError, TypeError) as error:
logger.error("Compatibility analysis failed for %s: %s", launch.executable, error)
@@ -2365,6 +2496,7 @@ class MainWindow(
self.compatibility_report_ready.emit(report)
def _show_compatibility_report(self, report: str) -> None:
logger.info("Compatibility report dialog opened")
dialog = CompatibilityReportDialog(self, self.theme, report)
dialog.exec()
@@ -2398,6 +2530,10 @@ class MainWindow(
self.current_running_button = button
self.game_stopped_by_user = True
self.egs_launch_cancelled = True
if getattr(self, "launcher_process_only", False):
if self._run_portproton_stop_command():
return True
self._analyze_short_launch()
self._terminate_game_processes()
@@ -2467,23 +2603,14 @@ class MainWindow(
return ALT_BIARCH_REPO in result.stdout
def _get_missing_alt_i586_packages(self) -> list[str]:
try:
installed = set(self._get_installed_alt_package_names())
except (OSError, subprocess.SubprocessError) as e:
logger.warning("Failed to check ALT i586 packages: %s", e)
return [f"i586-{package}" for package in ALT_I586_BASE_PACKAGES]
def _has_missing_alt_i586_packages(self) -> bool:
if not self.start_sh:
return True
return [
f"i586-{package}" for package in self._get_alt_i586_package_names(installed)
if f"i586-{package}" not in installed
]
def _get_installed_alt_package_names(self) -> list[str]:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(
subprocess.run,
["rpm", "-qa", "--qf", "%{NAME}\n"],
self.start_sh + ["cli", "--alt-i586-dependencies"],
capture_output=True,
text=True,
timeout=10,
@@ -2494,46 +2621,44 @@ class MainWindow(
while not future.done():
QApplication.processEvents(event_flags)
time.sleep(0.01)
result = future.result()
if result.returncode != 0:
raise subprocess.SubprocessError(result.stderr.strip())
try:
result = future.result()
except (OSError, subprocess.SubprocessError) as e:
logger.warning("Failed to check ALT i586 packages: %s", e)
return True
return result.stdout.splitlines()
return result.returncode != 0
def _get_alt_i586_package_names(self, installed: set[str]) -> list[str]:
packages = [
package for package in ALT_I586_BASE_PACKAGES
if package in installed
]
packages.extend(
package for package in sorted(installed)
if self._is_alt_i586_prefixed_package(package)
)
return packages
def _is_alt_i586_prefixed_package(self, package: str) -> bool:
if package in ALT_I586_EXCLUDED_PACKAGES:
def _install_alt_i586_dependencies(self) -> bool:
if not self.start_sh:
return False
return any(package.startswith(prefix) for prefix in ALT_I586_PACKAGE_PREFIXES)
def _create_alt_i586_install_script(self) -> str | None:
try:
fd, script_path = tempfile.mkstemp(
prefix="portprotonqt-alt-i586-",
suffix=".sh",
)
with os.fdopen(fd, "w", encoding="utf-8") as script:
script.write(ALT_I586_INSTALL_SCRIPT)
os.chmod(script_path, ALT_INSTALL_SCRIPT_MODE)
except OSError as e:
logger.warning("Failed to create ALT i586 install script: %s", e)
return None
command = [
"pkexec",
*self.start_sh,
"cli",
"--alt-i586-dependencies",
"install",
]
process = QProcess(self)
event_loop = QEventLoop()
process.finished.connect(event_loop.quit)
process.start(
sys.executable,
["-m", "portprotonqt.scripts_utils.easyterm", "-e", shlex.join(command)],
)
if not process.waitForStarted(5000):
logger.warning("Failed to start ALT i586 dependency installer")
return False
return script_path
event_loop.exec()
return not self._has_missing_alt_i586_packages()
def _check_alt_i586_dependencies_before_launch(self) -> bool:
if not self._is_alt_x86_64():
return True
if ui_config.get_disable_alt_i586_dependency_check():
return True
if not self._has_alt_biarch_repo():
QMessageBox.warning(
@@ -2543,7 +2668,7 @@ class MainWindow(
)
return False
if not self._get_missing_alt_i586_packages():
if not self._has_missing_alt_i586_packages():
return True
msg_box = QMessageBox(self)
@@ -2558,19 +2683,7 @@ class MainWindow(
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Ok:
script_path = self._create_alt_i586_install_script()
if not script_path:
return False
QProcess.startDetached(
sys.executable,
[
"-m",
"portprotonqt.scripts_utils.easyterm",
"-e",
f"pkexec /bin/sh {shlex.quote(script_path)}",
],
)
return self._install_alt_i586_dependencies()
return False
def _resolve_iso_launch_parts(self, iso_path: str) -> list[str] | None:
@@ -2619,7 +2732,7 @@ class MainWindow(
None,
)
if game:
self._install_gog_game(game)
self._select_store_dlcs("gog", game)
def _handle_egs_game(self, exec_line: str, button=None) -> None:
action, app_id = exec_line.removeprefix("egs://").split("/", 1)
@@ -2631,7 +2744,12 @@ class MainWindow(
self._install_egs_game(app_id)
def _install_egs_game(self, app_id: str) -> None:
self._install_egs_download(app_id)
game = next(
(item for item in self.egs_api.load_library()
if str(item.get("app_id", "")) == app_id),
{"app_id": app_id, "title": app_id, "cover": ""},
)
self._select_store_dlcs("egs", game)
def _repair_egs_game(self, app_id: str) -> None:
self._start_egs_operation(
@@ -2645,6 +2763,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
)
)
message_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
self._start_egs_operation(
app_id, ["uninstall", app_id, "-y"], _("Delete")
)
@@ -2754,10 +2889,12 @@ class MainWindow(
self, _("Error"), _("Cannot launch game while another game is running")
)
return
if not self._check_alt_i586_dependencies_before_launch():
self._finish_silent_launch()
return
try:
wrapper = shlex.join([self.start_sh[0], "--epic-wrapper"])
command = self.egs_api.build_command([
"launch", app_id, "--json", "--wrapper", wrapper,
"launch", app_id, "--json", "--wrapper", self.start_sh[0],
"--no-wine", "--skip-version-check",
])
process = subprocess.Popen(
@@ -2818,6 +2955,9 @@ class MainWindow(
if self.game_processes:
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
return
if not self._check_alt_i586_dependencies_before_launch():
self._finish_silent_launch()
return
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)
@@ -2921,6 +3061,9 @@ class MainWindow(
if not self.stop_running_game(update_button):
QMessageBox.warning(self, _("Error"), _("Failed to stop game"))
else:
if not self._check_alt_i586_dependencies_before_launch():
return
if update_button:
try:
update_button.setText(_("Stop"))
@@ -2929,12 +3072,6 @@ class MainWindow(
except RuntimeError:
update_button = None
SoundManager().play("game_launch")
if not self._check_alt_i586_dependencies_before_launch():
if update_button:
update_button.setText(_("Start"))
icon = self.theme_manager.get_icon("play", as_path=True)
update_button.setIcon(icon)
return
# Save button reference for reset after game completion
self.current_running_button = update_button
@@ -2983,6 +3120,11 @@ class MainWindow(
logger.error(f"Failed to launch game {exe_name}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
def _initialize_tray(self, app_name: str) -> None:
"""Create the tray item after the Qt event loop starts."""
if self.tray_manager is None:
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
def closeEvent(self, event):
"""Handle window close: check minimize_to_tray setting.
If True - minimize to tray. Otherwise - fully close.
@@ -3004,11 +3146,13 @@ class MainWindow(
watcher.wait()
# Hide and remove tray icon
if hasattr(self, "tray_manager"):
if self.tray_manager is not None:
self.tray_manager.shutdown()
# Save card sizes only for grid layouts.
layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
layout_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
size_slider = getattr(self.game_library_manager, 'sizeSlider', None)
if size_slider is None or layout_mode not in {
"list", "vertical", "horizontal", "horizontal_top"
+1
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',
+15 -4
View File
@@ -28,12 +28,14 @@ _library.portproton_gamepad_close.argtypes = [_handle]
_library.portproton_gamepad_close.restype = None
_library.portproton_gamepad_connected.argtypes = [_handle]
_library.portproton_gamepad_connected.restype = ctypes.c_bool
_library.portproton_gamepad_update.argtypes = []
_library.portproton_gamepad_update.argtypes = [_handle]
_library.portproton_gamepad_update.restype = None
_library.portproton_gamepad_get_button.argtypes = [_handle, ctypes.c_int]
_library.portproton_gamepad_get_button.restype = ctypes.c_int
_library.portproton_gamepad_get_axis.argtypes = [_handle, ctypes.c_int]
_library.portproton_gamepad_get_axis.restype = ctypes.c_int16
_library.portproton_gamepad_get_active_instance_id.argtypes = [_handle]
_library.portproton_gamepad_get_active_instance_id.restype = ctypes.c_uint32
_library.portproton_gamepad_get_name.argtypes = [_handle]
_library.portproton_gamepad_get_name.restype = ctypes.c_char_p
_library.portproton_gamepad_get_instance_id.argtypes = [_handle]
@@ -45,7 +47,7 @@ _library.portproton_gamepad_shutdown.restype = None
class GamepadBackendError(RuntimeError):
"""SDL failed while discovering the active gamepad."""
"""SDL failed while discovering connected gamepads."""
@dataclass
@@ -55,6 +57,7 @@ class SDLGamepad:
path: str
instance_id: int
sdl_type: int
active_instance_id: int = 0
def close(self) -> None:
if self.controller:
@@ -67,8 +70,16 @@ class SDLGamepad:
def connected(self) -> bool:
return bool(_library.portproton_gamepad_connected(self.controller))
def update(self) -> None:
_library.portproton_gamepad_update()
def update(self) -> bool:
_library.portproton_gamepad_update(self.controller)
self.sdl_type = int(_library.portproton_gamepad_get_type(self.controller))
active_instance_id = int(
_library.portproton_gamepad_get_active_instance_id(self.controller)
)
if active_instance_id == self.active_instance_id:
return False
self.active_instance_id = active_instance_id
return True
def get_button(self, button: int) -> int:
return int(_library.portproton_gamepad_get_button(self.controller, button))
+16 -2
View File
@@ -340,17 +340,31 @@ class PortProtonAPI:
def _get_autoinstall_exe_name(self, script_path: str) -> str:
install_exe = ""
shortcut_name = ""
try:
with open(script_path, encoding="utf-8") as script_file:
for line in script_file:
if line.lstrip().startswith("#"):
continue
if "PORTWINE_CREATE_SHORTCUT_NAME=" in line:
match = re.search(
r'PORTWINE_CREATE_SHORTCUT_NAME=["\']([^"\']+)', line
)
if match:
shortcut_name = match.group(1)
if "PW_EXE_FILE" in line:
exe_name = self._extract_exe_name_from_script_line(line)
if exe_name:
return exe_name
install_exe = exe_name
if line.strip().startswith("pw_create_unique_exe"):
match = re.search(r'pw_create_unique_exe\s+["\']?([^"\' ]+)', line)
unique_name = match.group(1) if match else shortcut_name
if unique_name:
return f"{os.path.splitext(unique_name)[0]}.exe"
if "PW_AUTOINSTALL_EXE" not in line:
continue
exe_name = self._extract_exe_name_from_script_line(line)
if exe_name:
if exe_name and not install_exe:
install_exe = exe_name
except OSError as e:
logger.warning("Failed to read autoinstall script %s: %s", script_path, e)
+83 -4
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import codecs
import errno
import fcntl
import os
import pty
@@ -14,6 +15,7 @@ from typing import Any
from PySide6.QtCore import QMimeData, QSocketNotifier, Qt, QTimer
from PySide6.QtGui import (
QCloseEvent,
QColor,
QFont,
QFontDatabase,
@@ -28,10 +30,13 @@ from PySide6.QtGui import (
)
from PySide6.QtWidgets import QApplication, QMainWindow, QMenu, QPlainTextEdit
from portprotonqt.input_manager.constants import SDL_GAMEPAD_BUTTON_SOUTH
from portprotonqt.logger import get_logger
from portprotonqt.native_gamepad import GamepadBackendError, SDLGamepad, find_gamepad
from portprotonqt.theme_manager import ThemeManager
logger = get_logger(__name__)
APPLICATION_ID = "ru.linux_gaming.PortProtonQt"
CONF_NAME = "PortProtonQt Terminal"
CONF_FALLBACK_SHELL = "/bin/bash"
@@ -39,6 +44,7 @@ CONF_DEF_SIZE = (800, 260)
CONF_FALLBACK_FONT_EXTRA = 6
CONF_SCROLLBACK_LINES = 2000
CONF_RENDER_INTERVAL_MS = 16
CONF_GAMEPAD_POLL_INTERVAL_MS = 50
XDG_DATA_HOME = os.getenv(
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
@@ -878,6 +884,9 @@ class TerminalWidget(QPlainTextEdit):
self.app_theme = app_theme or load_current_theme()
self.cmd_mode = cmd_mode
self.child_pid: int | None = None
self.exit_code: int | None = None
self.gamepad: SDLGamepad | None = None
self.gamepad_a_pressed = False
self.pty_fd: int | None = None
self.notifier: QSocketNotifier | None = None
self.ansi_colors = _theme_ansi_colors(theme)
@@ -906,6 +915,8 @@ class TerminalWidget(QPlainTextEdit):
self.theme_manager = ThemeManager()
self.child_timer = QTimer(self)
self.child_timer.timeout.connect(self._check_child)
self.gamepad_timer = QTimer(self)
self.gamepad_timer.timeout.connect(self._poll_close_gamepad)
self.render_timer = QTimer(self)
self.render_timer.setSingleShot(True)
self.render_timer.timeout.connect(self._render_screen)
@@ -1043,11 +1054,15 @@ class TerminalWidget(QPlainTextEdit):
except BlockingIOError:
return
except OSError as e:
if e.errno == errno.EIO:
if self.notifier is not None:
self.notifier.setEnabled(False)
return
logger.warning("Terminal read failed: %s", e)
QApplication.quit()
return
if not data:
QApplication.quit()
if self.notifier is not None:
self.notifier.setEnabled(False)
return
text = self.output_decoder.decode(data)
if self.cmd_mode:
@@ -1061,15 +1076,65 @@ class TerminalWidget(QPlainTextEdit):
if self.child_pid is None:
return
try:
pid, _status = os.waitpid(self.child_pid, os.WNOHANG)
pid, status = os.waitpid(self.child_pid, os.WNOHANG)
except ChildProcessError:
pid = self.child_pid
status = 0
if pid == 0:
return
self.child_pid = None
self.child_timer.stop()
self.viewport().setCursor(Qt.CursorShape.ArrowCursor)
QApplication.quit()
self._read_remaining_output()
self.exit_code = os.waitstatus_to_exitcode(status)
if self.exit_code == 0:
QApplication.exit(0)
return
self._start_close_gamepad()
def _start_close_gamepad(self) -> None:
try:
self.gamepad = find_gamepad()
except GamepadBackendError as e:
logger.debug("Failed to find terminal gamepad: %s", e)
return
if self.gamepad is None:
return
self.gamepad.update()
self.gamepad_a_pressed = bool(
self.gamepad.get_button(SDL_GAMEPAD_BUTTON_SOUTH)
)
self.gamepad_timer.start(CONF_GAMEPAD_POLL_INTERVAL_MS)
def _poll_close_gamepad(self) -> None:
if self.gamepad is None or not self.gamepad.connected():
self.gamepad_timer.stop()
return
self.gamepad.update()
pressed = bool(self.gamepad.get_button(SDL_GAMEPAD_BUTTON_SOUTH))
if pressed and not self.gamepad_a_pressed:
self.window().close()
self.gamepad_a_pressed = pressed
def _close_gamepad(self) -> None:
self.gamepad_timer.stop()
if self.gamepad is not None:
self.gamepad.close()
self.gamepad = None
def _read_remaining_output(self) -> None:
if self.pty_fd is None:
return
while True:
try:
data = os.read(self.pty_fd, 4096)
except OSError:
break
if not data:
break
self._append_output(self.output_decoder.decode(data))
if self.terminal_screen is not None:
self._render_screen()
def _append_output(self, text: str) -> None:
if self.terminal_screen is not None:
@@ -1196,6 +1261,12 @@ class TerminalWidget(QPlainTextEdit):
self._write_pty(source.text())
def keyPressEvent(self, event: QKeyEvent) -> None:
if self.child_pid is None and event.key() in (
Qt.Key.Key_Return,
Qt.Key.Key_Enter,
):
self.window().close()
return
if self.cmd_mode and self._handle_cmd_key(event):
return
if self._handle_shortcut(event):
@@ -1783,6 +1854,12 @@ class MainWindow(QMainWindow):
if fullscreen:
self.showFullScreen()
def closeEvent(self, event: QCloseEvent) -> None:
exit_code = self.terminal.exit_code or 0
self.terminal._close_gamepad()
event.accept()
QApplication.exit(exit_code)
def parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse xterm-compatible arguments used by PortProton scripts."""
@@ -1862,6 +1939,8 @@ def main(argv: list[str] | None = None) -> int:
command = build_command(args)
cmd_mode = _is_cmd_mode(args)
app = QApplication(sys.argv[:1])
app.setWindowIcon(QIcon.fromTheme(APPLICATION_ID))
app.setDesktopFileName(APPLICATION_ID)
app_theme = load_current_theme()
theme = load_current_terminal_scheme() or load_terminal_scheme("default")
window = MainWindow(
@@ -1,8 +1,11 @@
import os
import re
import sys
from pathlib import Path
from typing import Any
UNREAL_BOOTSTRAPPER_MAX_SIZE = 1_048_576
LAUNCHER_NAME_MARKERS = ("launcher", "setup", "updater", "unins")
DIRECTX_IMPORTS = {
"ddraw.dll": "DirectDraw",
@@ -49,6 +52,8 @@ OPENGL_SIGNATURES = {
"OpenGL Utility Library": (b"glu32.dll", b"gluPerspective", b"gluLookAt"),
}
VULKAN_SIGNATURES = (b"vulkan-1.dll", b"vkCreateInstance", b"vkGetInstanceProcAddr")
def analyze_executable(file_path: str) -> dict[str, Any]:
"""Detect graphics API usage in a Windows executable."""
@@ -59,23 +64,58 @@ def analyze_executable(file_path: str) -> dict[str, Any]:
directx = set()
opengl = set()
vulkan = set()
if analysis_path.suffix.lower() in {".exe", ".dll"}:
_add_pe_imports(analysis_path, directx, opengl)
_add_file_signatures(analysis_path, directx, opengl)
_add_file_signatures(analysis_path, directx, opengl, vulkan)
engine_family = _read_engine_render_family(analysis_path)
if engine_family == "vulkan":
vulkan.add("Vulkan")
elif engine_family == "opengl":
opengl.add("OpenGL")
return {
"highest_directx": _highest_directx(list(directx)),
"uses_opengl": bool(opengl),
"uses_vulkan": bool(vulkan),
"source": str(analysis_path),
}
def _read_engine_render_family(executable: Path) -> str | None:
"""Read a renderer choice from the active Wine prefix engine config."""
wineprefix = os.getenv("WINEPREFIX")
if not wineprefix:
return None
config_root = Path(wineprefix) / "drive_c" / "users"
try:
config_paths = config_root.glob("*/Saved Games/**/kexengine.cfg")
for config_path in config_paths:
game_name = re.sub(r"[^a-z0-9]", "", config_path.parent.name.lower())
executable_name = re.sub(
r"[^a-z0-9]", "", executable.parent.name.lower()
)
executable_stem = re.sub(r"[^a-z0-9]", "", executable.stem.lower())
if game_name not in executable_name and game_name not in executable_stem:
continue
for line in config_path.read_text(errors="ignore").splitlines():
match = re.match(r'\s*seta\s+r_rhirenderfamily\s+"?([^"\s]+)', line)
if match:
return match.group(1).lower()
except OSError:
return None
return None
def resolve_graphics_executable(file_path: str) -> Path:
"""Resolve a launcher to the binary that implements its graphics API."""
path = Path(file_path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"File not found: {path}")
unity_player = path.parent / "UnityPlayer.dll"
if unity_player.is_file():
return unity_player
if path.stat().st_size < UNREAL_BOOTSTRAPPER_MAX_SIZE:
patterns = (
"*/Binaries/Win64/*-Win64-Shipping.exe",
@@ -89,9 +129,28 @@ def resolve_graphics_executable(file_path: str) -> Path:
renpy_libraries = sorted(path.parent.glob("lib/*/librenpython.dll"))
if renpy_libraries:
return renpy_libraries[0]
is_launcher = any(marker in path.stem.lower() for marker in LAUNCHER_NAME_MARKERS)
if is_launcher or not _is_pe_file(path):
pe_candidates = [
candidate
for candidate in path.parent.glob("*.exe")
if candidate != path
and _is_pe_file(candidate)
and not any(marker in candidate.stem.lower() for marker in LAUNCHER_NAME_MARKERS)
]
if pe_candidates:
return max(pe_candidates, key=lambda candidate: candidate.stat().st_size)
return path
def _is_pe_file(path: Path) -> bool:
try:
with path.open("rb") as executable:
return executable.read(2) == b"MZ"
except OSError:
return False
def _add_pe_imports(path: Path, directx: set[str], opengl: set[str]) -> None:
try:
import pefile
@@ -117,7 +176,9 @@ def _add_pe_imports(path: Path, directx: set[str], opengl: set[str]) -> None:
return
def _add_file_signatures(path: Path, directx: set[str], opengl: set[str]) -> None:
def _add_file_signatures(
path: Path, directx: set[str], opengl: set[str], vulkan: set[str]
) -> None:
try:
with path.open("rb") as f:
data = f.read(67108864).lower()
@@ -131,6 +192,8 @@ def _add_file_signatures(path: Path, directx: set[str], opengl: set[str]) -> Non
for name, patterns in OPENGL_SIGNATURES.items():
if any(p.lower() in data for p in patterns):
opengl.add(name)
if any(signature.lower() in data for signature in VULKAN_SIGNATURES):
vulkan.add("Vulkan")
def _highest_directx(directx: list[str]) -> str:
@@ -148,6 +211,8 @@ def _main(argv: list[str]) -> int:
result = analyze_executable(argv[1])
print(f"HIGHEST_DX={result['highest_directx']}")
print(f"USES_OGL={'true' if result['uses_opengl'] else 'false'}")
print(f"USES_VULKAN={'true' if result['uses_vulkan'] else 'false'}")
print(f"SOURCE={result['source']}")
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
@@ -121,7 +121,6 @@ def _archive_entry(archive, source_path: str, archive_path: str) -> bool:
flags=READDISK_NO_XATTR,
pathname=archive_path,
recursive=False,
symlink_mode="physical",
)
return True
+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:
+2 -3
View File
@@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError
from portprotonqt.logger import get_logger
from portprotonqt.downloader import Downloader
from portprotonqt.localization import get_steam_language
from portprotonqt.config import THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
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 (
@@ -501,8 +501,7 @@ def _get_cached_data_and_index(
def _get_cached_steam_cover_path(appid: int) -> str:
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
image_folder = str(CACHE_DIR / "images")
for ext in COVER_IMAGE_EXTENSIONS:
cover_path = os.path.join(image_folder, f"{appid}{ext}")
if os.path.exists(cover_path):
+5 -3
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")
@@ -501,9 +503,9 @@ def _load_steam_app_metadata(
def _is_steam_game(common: dict) -> bool:
"""Return whether Steam metadata describes a game."""
"""Return whether Steam metadata describes a game or related application."""
app_type = str(common.get("type", "")).lower()
return app_type == "game"
return app_type in {"game", "demo", "mod", "application", "beta"}
def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
+220 -11
View File
@@ -1,10 +1,13 @@
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,
QHBoxLayout,
QLabel,
QLineEdit,
QScroller,
QSlider,
@@ -18,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
@@ -32,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
@@ -42,7 +74,18 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
autoInstallPage = QWidget()
autoInstallPage.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
autoInstallLayout = QVBoxLayout(autoInstallPage)
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
stack_layout = QGridLayout(autoInstallPage)
if isinstance(library_background, dict):
stack_layout.setContentsMargins(*library_background["margins"])
else:
stack_layout.setContentsMargins(0, 0, 0, 0)
self.autoInstallBackgroundLabel = QLabel()
stack_layout.addWidget(self.autoInstallBackgroundLabel, 0, 0)
content_widget = QWidget()
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
autoInstallLayout = QVBoxLayout(content_widget)
stack_layout.addWidget(content_widget, 0, 0)
autoInstallLayout.setSpacing(15)
searchWidget = QWidget()
@@ -90,10 +133,36 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
self.autoInstallContainer = QWidget()
self.autoInstallContainer.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
self.autoInstallContainerLayout = FlowLayout(self.autoInstallContainer)
self.autoInstallContainer.setLayout(self.autoInstallContainerLayout)
auto_layout_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
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)
@@ -111,15 +180,18 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
sliderLayout.addWidget(self.auto_size_slider)
autoInstallLayout.addLayout(sliderLayout)
auto_layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
self.auto_size_slider.setVisible(auto_layout_mode != "list")
if auto_layout_mode == "list":
fixed_layout = auto_layout_mode in {
"list", "vertical", "horizontal", "horizontal_top",
}
self.auto_size_slider.setVisible(not fixed_layout)
if fixed_layout:
self.auto_card_width = self.auto_size_slider.maximum()
self.auto_size_slider.setValue(self.auto_card_width)
self._gamepad_tooltip_map[self.auto_size_slider] = f"{self.auto_card_width} px"
# Store cards
self.autoInstallGameCards = {}
self.autoInstallStatusLabels = {}
self.allAutoInstallCards = []
self.autoInstallSearchOptimizer = SearchOptimizer()
self.autoInstallLoaded = False
@@ -129,6 +201,13 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
def on_autoinstall_games_loaded(games: list[tuple]):
self.autoInstallLoaded = True
self.autoInstallLoading = False
auto_layout_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
list_layout = auto_layout_mode in {"list", "vertical"}
self.autoInstallContainer.setProperty(
"library_layout_mode", "vertical" if list_layout else auto_layout_mode
)
# Clear
while self.autoInstallContainerLayout.count():
@@ -139,6 +218,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
widget.deleteLater()
self.autoInstallGameCards.clear()
self.autoInstallStatusLabels.clear()
self.allAutoInstallCards.clear()
if not games:
@@ -169,7 +249,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
exe_name = game_tuple[13]
compact_cover = game_tuple[14] if len(game_tuple) > 14 else ""
full_cover = game_tuple[15] if len(game_tuple) > 15 else cover_path
if auto_layout_mode == "list":
if list_layout:
cover_path = compact_cover or full_cover
else:
cover_path = full_cover or compact_cover
@@ -184,6 +264,26 @@ 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)
# Hide badges and favorite button
if hasattr(card, 'steamLabel'):
@@ -205,11 +305,115 @@ 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
for card in self.allAutoInstallCards:
if card.name != game_name:
continue
self.game_library_manager.render_library_background(
self.autoInstallBackgroundLabel, card
)
return
def _set_autoinstall_container_layout(self, mode: str) -> None:
old_layout = getattr(self, "autoInstallContainerLayout", None)
if old_layout is not None:
while old_layout.count():
item = old_layout.takeAt(0)
if item and item.widget():
item.widget().deleteLater()
QWidget().setLayout(old_layout)
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)))
layout.setSpacing(config.get("layout_spacing", 0))
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
elif mode in {"horizontal", "horizontal_top"}:
config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
layout = QHBoxLayout()
layout.setContentsMargins(
*config.get("layout_margins", (0, 0, 0, 0))
)
layout.setSpacing(config.get("layout_spacing", 0))
layout.setAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
else:
layout = FlowLayout()
self.autoInstallContainerLayout = layout
self.autoInstallContainer.setLayout(layout)
horizontal = mode in {"horizontal", "horizontal_top"}
vertical_policy = (
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
if horizontal else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if mode in {"list", "vertical"} else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
horizontal_policy = (
Qt.ScrollBarPolicy.ScrollBarAsNeeded
if horizontal else Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self.autoInstallScrollArea.setVerticalScrollBarPolicy(vertical_policy)
self.autoInstallScrollArea.setHorizontalScrollBarPolicy(horizontal_policy)
def refresh_autoinstall_layout(self) -> None:
"""Rebuild auto-install cards after a live library layout change."""
if not hasattr(self, "autoInstallContainer"):
return
mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
fixed_layout = mode in {
"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()
self.auto_size_slider.setValue(self.auto_card_width)
if self.autoInstallLoading:
return
self.autoInstallLoaded = False
self._start_autoinstall_load()
def _open_autoinstall_card_after_script_download(
self,
game_data: dict,
@@ -314,12 +518,17 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
def on_auto_slider_released(self):
"""Handles auto-install slider release to update card size."""
auto_layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
auto_layout_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
fixed_layout = auto_layout_mode in {
"list", "vertical", "horizontal", "horizontal_top",
}
if hasattr(self, 'auto_size_slider') and self.auto_size_slider:
if auto_layout_mode != "list":
if not fixed_layout:
self.auto_card_width = self.auto_size_slider.value()
self._gamepad_tooltip_map[self.auto_size_slider] = f"{self.auto_card_width} px"
if auto_layout_mode != "list":
if not fixed_layout:
ui_config.set_auto_card_width(self.auto_card_width)
if not hasattr(self, 'allAutoInstallCards'):
return
+5 -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
@@ -476,6 +476,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 +602,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)
+454 -19
View File
@@ -7,12 +7,18 @@ import subprocess
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import orjson
import requests
from shiboken6 import isValid
from PySide6.QtCore import QProcess, QProcessEnvironment, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QDialog,
QDialogButtonBox,
QFrame,
QHeaderView,
QHBoxLayout,
@@ -20,6 +26,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QProgressBar,
QPushButton,
QScrollArea,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
@@ -27,9 +34,10 @@ from PySide6.QtWidgets import (
)
from portprotonqt.dialogs.file_explorer import FileExplorer
from portprotonqt.config.cache import CacheManager
from portprotonqt.egs_api import EGSAPI, EGS_LOGIN_URL
from portprotonqt.gog_api import GOGAPI, GOG_LOGIN_URL
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
@@ -39,6 +47,9 @@ GOG_CANCEL_KILL_TIMEOUT_MS = 3000
GOG_LOGIN_TIMEOUT_MS = 300000
EGS_LOGIN_TIMEOUT_MS = 300000
MIB_PER_GIB = 1024.0
STORE_INFO_TIMEOUT_SECONDS = 120
STORE_DLC_ART_TIMEOUT_SECONDS = 15
STORE_DLC_CACHE_TTL_SECONDS = 300
def _format_download_size(size_mib: float) -> str:
@@ -172,6 +183,137 @@ class GOGSupportWorker(QThread):
self.failed.emit(self.error)
class GOGRepairWorker(QThread):
"""Restore an imported game's manifest outside the UI thread."""
def __init__(self, api: GOGAPI, game: dict) -> None:
super().__init__()
self.api = api
self.game = game
self.error = ""
def run(self) -> None:
try:
app_id = str(self.game["app_id"])
install_path = self.api.get_installed_path(app_id)
if install_path is None:
raise OSError(f"GOG installation not found: {app_id}")
self.api.prepare_repair_manifest(app_id, install_path)
except Exception as error:
logger.exception("Failed to prepare GOG repair")
self.error = str(error)
class StoreDLCWorker(QThread):
"""Read owned, installable store DLC outside the UI thread."""
loaded = Signal(list)
failed = Signal(str)
def __init__(self, api: GOGAPI | EGSAPI, app_id: str) -> None:
super().__init__()
self.api = api
self.app_id = app_id
def run(self) -> None:
try:
epic = isinstance(self.api, EGSAPI)
arguments = ["info", self.app_id, "--platform", "Windows" if epic else "windows"]
if epic:
arguments.append("--json")
environment = os.environ.copy()
environment["LEGENDARY_CONFIG_PATH" if epic else "GOGDL_CONFIG_PATH"] = str(self.api.config_dir)
cache = CacheManager()
cache_name = f"store-dlc-{'egs' if epic else 'gog'}-{self.app_id}"
data = self._load_epic_dlc_info() if epic else cache.load_json(cache_name) if cache.is_fresh(cache_name, STORE_DLC_CACHE_TTL_SECONDS) else None
if data is None:
result = subprocess.run(
self.api.build_command(arguments), env=environment, capture_output=True,
timeout=STORE_INFO_TIMEOUT_SECONDS, check=True,
)
data = orjson.loads(result.stdout)
cache.save_json(cache_name, data)
entries = data.get("game", {}).get("owned_dlc", []) if epic else data.get("dlcs", [])
installed = set(self.api.load_installed()) if epic else set()
if isinstance(self.api, GOGAPI):
install_path = self.api.get_installed_path(self.app_id)
if install_path is not None:
installed = {path.stem.removeprefix("goggame-") for path in install_path.glob("goggame-*.info")}
installed.discard(self.app_id)
self.api.prepare_repair_manifest(self.app_id, install_path)
dlcs = []
for entry in entries:
if epic and not any("Windows" in release.get("platform", []) for release in entry.get("installable", [])):
continue
app_id = str(entry.get("app_name" if epic else "id", ""))
if not app_id or app_id.startswith("-") or (not epic and not app_id.isdecimal()):
continue
dlcs.append({"app_id": app_id, "title": str(entry.get("title") or app_id), "installed": app_id in installed})
if not epic:
known = {dlc["app_id"] for dlc in dlcs}
dlcs.extend({"app_id": app_id, "title": app_id, "installed": True} for app_id in sorted(installed - known))
covers = self._load_dlc_covers() if dlcs else {}
for dlc in dlcs:
if covers.get(dlc["app_id"]):
dlc["cover"] = covers[dlc["app_id"]]
if not self.isInterruptionRequested():
self.loaded.emit(dlcs)
except Exception as error:
logger.exception("Failed to load store DLC for %s", self.app_id)
if not self.isInterruptionRequested():
self.failed.emit(str(error))
def _load_epic_dlc_info(self) -> dict | None:
assets = self.api._load_json(self.api.config_dir / "assets.json", None)
data = self.api._load_json(self.api.config_dir / "metadata" / f"{self.app_id}.json", None)
if not isinstance(assets, dict) or not isinstance(data, dict):
return None
owned = {asset.get("app_name") or asset.get("appName") for asset in assets.get("Windows", [])}
dlcs = []
for entry in data.get("metadata", {}).get("dlcItemList", []):
for release in entry.get("releaseInfo", []):
app_id = release.get("appId")
if app_id in owned and "Windows" in release.get("platform", []):
dlcs.append({"app_name": app_id, "title": entry.get("title", app_id), "installable": [release]})
owned.remove(app_id)
return {"game": {"owned_dlc": dlcs}}
def _load_dlc_covers(self) -> dict[str, str]:
if isinstance(self.api, EGSAPI):
data = self.api._load_json(self.api.config_dir / "metadata" / f"{self.app_id}.json", {})
covers = {}
for entry in data.get("metadata", {}).get("dlcItemList", []):
images = entry.get("keyImages", [])
cover = next((image.get("url", "") for image in images
if image.get("type") in {"DieselGameBox", "OfferImageWide", "Thumbnail"}), "")
cover = cover or next((image.get("url", "") for image in images), "")
for release in entry.get("releaseInfo", []):
if release.get("appId"):
covers[str(release["appId"])] = str(cover)
return covers
cache = CacheManager()
cache_name = f"store-dlc-art-gog-{self.app_id}"
data = cache.load_json(cache_name)
if data is None:
try:
response = requests.get(
f"https://api.gog.com/products/{self.app_id}?expand=expanded_dlcs",
timeout=STORE_DLC_ART_TIMEOUT_SECONDS,
)
response.raise_for_status()
data = response.json()
cache.save_json(cache_name, data)
except requests.RequestException as error:
logger.warning("Failed to load GOG DLC covers: %s", error)
return {}
covers = {}
for entry in data.get("expanded_dlcs", []):
images = entry.get("images", {})
cover = images.get("logo2x") or images.get("logo") or images.get("icon") or ""
covers[str(entry.get("id", ""))] = f"https:{cover}" if cover.startswith("//") else cover
return covers
class MainWindowDownloadTabMixin(_MainWindowTypingBase):
"""Add account actions and the shared downloads page."""
@@ -181,7 +323,9 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
def createGOGDownloadsTab(self) -> None:
self.gog_process = None
self.egs_process = None
self.gog_download_queue = []
self.gog_repair_worker = None
self.egs_install_importing = False
self.store_download_queue = []
self.gog_download_output = ""
self.egs_download_output = ""
self.egs_download_total = 0.0
@@ -207,6 +351,17 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
completed_header.addStretch()
layout.addLayout(completed_header)
self.downloadCompletedTable = self._create_download_table("", layout)
self.downloadCompletedTable.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows
)
self.downloadCompletedTable.setSelectionMode(
QAbstractItemView.SelectionMode.SingleSelection
)
self.downloadCompletedTable._on_confirm_callback = ( # type: ignore[attr-defined]
lambda _table, row, column: self._open_completed_download(row, column)
)
self.downloadCompletedTable.cellClicked.connect(self._open_completed_download)
self.downloadCompletedTable.viewport().setCursor(Qt.CursorShape.PointingHandCursor)
layout.addStretch()
self.stackedWidget.addWidget(page)
self._update_downloads_tab_visibility()
@@ -311,6 +466,17 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
self._update_download_table_height(self.downloadCompletedTable)
self._update_downloads_tab_visibility()
def _open_completed_download(self, row: int, _column: int) -> None:
item = self.downloadCompletedTable.item(row, 1)
if item is None:
return
app_id, source = item.data(Qt.ItemDataRole.UserRole)
cards = self.game_library_manager.game_card_cache.values()
card = next((card for card in cards if str(card.appid) == app_id
and str(card.game_source).lower() == source), None)
if card is not None:
card.click()
def _start_gog_login(self) -> None:
if getattr(self, "gog_auth_worker", None) is not None:
return
@@ -573,13 +739,30 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
def _on_egs_library_worker_finished(self) -> None:
self.egs_library_worker = None
def _queue_store_download(self, source: str, game: dict) -> None:
app_id = str(game["app_id"])
active_game = (
getattr(self, f"{source}_active_game", {})
if getattr(self, f"{source}_process", None) is not None else {}
)
queued = any(
queued_source == source and str(queued_game["app_id"]) == app_id
for queued_source, queued_game in self.store_download_queue
)
if str(active_game.get("app_id", "")) == app_id or queued:
return
self.store_download_queue.append((source, game))
store = "Epic Games" if source == "egs" else "GOG"
self._append_download_row(self.downloadQueuedTable, game, _("Install"), store)
self.switchTab(6)
def _install_gog_game(self, game: dict) -> None:
if self.gog_process is not None:
self.gog_download_queue.append(game)
self._append_download_row(self.downloadQueuedTable, game, _("Install"))
self.switchTab(6)
if getattr(self, "gog_repair_worker", None) is not None:
return
app_id = str(game["app_id"])
if self.gog_process is not None or self.egs_process is not None:
self._queue_store_download("gog", game)
return
install_path = self.gog_api.get_install_path(app_id, str(game["title"]))
install_path.mkdir(parents=True, exist_ok=True)
selected_paths: list[str] = []
@@ -595,6 +778,13 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
if not selected_paths:
return
install_path = Path(selected_paths[0])
game_path = self.gog_api.find_install_path(app_id, install_path)
if game_path is not None:
self.gog_api.save_installed_game(
app_id, {"install_path": str(game_path), "title": str(game["title"])}
)
self._repair_gog_game(game)
return
manifest_path = (
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
)
@@ -613,6 +803,8 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
[
"download", app_id, "--path", str(install_path),
"--support", str(support_path), "--platform", "windows",
*(["--with-dlcs", "--dlcs", ",".join(dlc["app_id"] for dlc in game["_dlcs"])]
if game.get("_dlcs") else ["--skip-dlcs"] if "_dlcs" in game else []),
]
)
except FileNotFoundError as error:
@@ -620,15 +812,127 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
return
self._start_gog_download(game, install_path, command, _("Install"))
def _install_egs_download(self, app_id: str) -> None:
if self.gog_process is not None or self.egs_process is not None:
def _select_store_dlcs(self, source: str, game: dict) -> None:
if getattr(self, "store_dlc_worker", None) is not None:
QMessageBox.warning(self, _("Error"), _("Another download is running"))
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))
worker.finished.connect(self._on_store_dlc_worker_finished)
self.store_dlc_worker = worker
worker.start()
def _on_store_dlc_worker_finished(self) -> None:
worker = self.store_dlc_worker
self.store_dlc_worker = None
if worker is not None:
worker.deleteLater()
def _on_store_dlcs_loaded(self, source: str, game: dict, dlcs: list[dict]) -> None:
selected = []
if dlcs:
dialog = QDialog(self)
dialog.setWindowTitle(f"{game['title']} — DLC")
dialog.setMinimumWidth(self.theme.storeDlcDialogWidth)
dialog.setStyleSheet(self.theme.MESSAGE_BOX_STYLE)
layout = QVBoxLayout(dialog)
scroll = QScrollArea(dialog)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setWidgetResizable(True)
scroll.setMinimumHeight(min(len(dlcs), self.theme.storeDlcVisibleRows) * self.theme.storeDlcRowHeight)
content = QWidget()
content.setObjectName("storeDlcList")
content.setStyleSheet(self.theme.STORE_DLC_LIST_STYLE)
rows = QVBoxLayout(content)
checkboxes = []
for dlc in dlcs:
row, checkbox = self._create_store_dlc_row(dlc, str(game.get("cover", "")))
rows.addWidget(row)
checkboxes.append((checkbox, dlc))
scroll.setWidget(content)
scroll.setStyleSheet(self.theme.SCROLL_STYLE)
layout.addWidget(scroll)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText(_("Install"))
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(_("Cancel"))
install_all_button = buttons.addButton(
f"{_('Install')} {_('All').lower()}", QDialogButtonBox.ButtonRole.ActionRole
)
install_all_button.setIcon(self.theme_manager.get_icon("select_all"))
buttons.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
def install_all() -> None:
for checkbox, _dlc in checkboxes:
checkbox.setChecked(True)
dialog.accept()
install_all_button.clicked.connect(install_all)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
result = dialog.exec()
dialog.deleteLater()
if result != QDialog.DialogCode.Accepted:
return
selected = [dlc for checkbox, dlc in checkboxes if checkbox.isChecked() and (source == "gog" or not dlc.get("installed"))]
game = {**game, "_dlcs": selected}
if source == "gog":
self._install_gog_game(game)
else:
self.egs_selected_dlc_game = game
self._install_egs_download(str(game["app_id"]))
def _create_store_dlc_row(self, dlc: dict, fallback_cover: str) -> tuple[QWidget, QCheckBox]:
row, details = self._create_download_game_cell({**dlc, "cover": dlc.get("cover") or fallback_cover})
details.hide()
row.setObjectName("storeDlcRow")
row.setStyleSheet(self.theme.STORE_DLC_ROW_STYLE)
row.setMinimumHeight(self.theme.storeDlcRowHeight)
cover = row.findChild(QLabel, "downloadGameCover")
if cover is not None:
cover.setFixedSize(*self.theme.storeDlcCoverSize)
cover.setScaledContents(True)
title = row.findChild(QLabel, "downloadGameTitle")
if title is not None:
title.setWordWrap(True)
checkbox = QCheckBox()
checkbox.setAccessibleName(dlc["title"])
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
checkbox.setFixedWidth(checkbox.sizeHint().width())
checkbox.setChecked(bool(dlc.get("installed")))
checkbox.setEnabled(not dlc.get("installed", False))
layout = row.layout()
assert layout is not None
layout.addWidget(checkbox)
return row, checkbox
def _start_next_egs_dlc(self, game: dict, install_path: Path) -> None:
dlc = game["_dlcs"][0]
command = self.egs_api.build_command([
"install", dlc["app_id"], "--base-path", str(install_path.parent),
"--game-folder", install_path.name, "--platform", "Windows",
"--skip-sdl", "--skip-dlcs", "-y",
])
game = {**game, "_dlcs": game["_dlcs"][1:]}
self._start_egs_download(game, install_path, command)
def _install_egs_download(self, app_id: str) -> None:
game = next(
(item for item in self.egs_api.load_library()
if str(item.get("app_id", "")) == app_id),
{"app_id": app_id, "title": app_id, "cover": ""},
)
selected_game = getattr(self, "egs_selected_dlc_game", None)
if selected_game is not None and str(selected_game["app_id"]) == app_id:
game = selected_game
self.egs_selected_dlc_game = None
if self.gog_process is not None or self.egs_process is not None:
self._queue_store_download("egs", game)
return
selected_paths: list[str] = []
explorer = FileExplorer(
self, theme=self.theme, initial_path=str(self.egs_api.games_dir),
@@ -640,18 +944,39 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
if not selected_paths:
return
install_path = Path(selected_paths[0])
folder_name = Path(str(game.get("folder_name") or app_id)).name
try:
command = self.egs_api.build_command([
game_path = install_path
child_path = install_path / folder_name
if child_path.is_dir() and not any(path.is_file() for path in install_path.iterdir()):
game_path = child_path
existing_game = game_path.is_dir() and any(game_path.iterdir())
arguments = [
"install", app_id, "--base-path", str(install_path),
"--platform", "Windows", "--skip-sdl", "--skip-dlcs", "-y",
])
]
if existing_game:
arguments = [
"import", app_id, str(game_path), "--platform", "Windows",
"--skip-dlcs", "--disable-check", "-y",
]
installed = self.egs_api.load_installed()
if app_id in installed:
if installed[app_id].get("install_path") != str(game_path):
installed[app_id]["install_path"] = str(game_path)
self.egs_api._save_json(self.egs_api.config_dir / "installed.json", installed)
arguments = [
"repair", app_id, "--repair-and-update", "--skip-sdl", "-y",
]
command = self.egs_api.build_command(arguments)
except OSError as error:
self.egsAccountStatus.setText(str(error))
return
self.egs_install_importing = arguments[0] == "import"
self._start_egs_download(game, install_path, command)
def _repair_gog_game(self, game: dict) -> None:
if self.gog_process is not None:
if self.gog_process is not None or getattr(self, "gog_repair_worker", None) is not None:
self.gogAccountStatus.setText(_("Another GOG operation is already running"))
return
app_id = str(game["app_id"])
@@ -659,14 +984,28 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
if install_path is None:
self.gogAccountStatus.setText(_("GOG installation not found"))
return
manifest_path = self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
if not manifest_path.is_file():
worker = GOGRepairWorker(self.gog_api, game)
worker.finished.connect(self._on_gog_repair_prepared)
self.gog_repair_worker = worker
worker.start()
return
support_path = (
self.gog_api.config_dir / "heroic_gogdl" / "gog-support" / app_id
)
if "_dlcs" in game:
selected = {dlc["app_id"] for dlc in game["_dlcs"]}
selected.update(path.stem.removeprefix("goggame-") for path in install_path.glob("goggame-*.info"))
selected.discard(app_id)
game = {**game, "_dlcs": [{"app_id": dlc_id} for dlc_id in sorted(selected)]}
try:
command = self.gog_api.build_command(
[
"repair", app_id, "--path", str(install_path),
"--support", str(support_path), "--platform", "windows",
*(["--with-dlcs", "--dlcs", ",".join(dlc["app_id"] for dlc in game["_dlcs"])]
if game.get("_dlcs") else ["--skip-dlcs"] if "_dlcs" in game else []),
]
)
except FileNotFoundError as error:
@@ -674,6 +1013,16 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
return
self._start_gog_download(game, install_path, command, _("Repair"))
def _on_gog_repair_prepared(self) -> None:
worker = self.gog_repair_worker
self.gog_repair_worker = None
if worker is None or worker.isInterruptionRequested():
return
if worker.error:
self.gogAccountStatus.setText(worker.error)
return
self._repair_gog_game(worker.game)
def _import_gog_game(self, game: dict) -> None:
selected_paths: list[str] = []
file_explorer = FileExplorer(
@@ -840,6 +1189,8 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
process = self.gog_process or self.egs_process
if process is None:
return
if self.egs_process is not None:
self.egs_active_game.pop("_dlcs", None)
self.downloadCancelButton.setEnabled(False)
self.downloadActiveDetails.setText(_("Cancel"))
process.terminate()
@@ -871,9 +1222,17 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
table.insertRow(row)
game_cell, details_label = self._create_download_game_cell(game)
table.setCellWidget(row, 0, game_cell)
if table is self.downloadCompletedTable:
game_cell.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
values = (datetime.now().strftime("%H:%M:%S"), action, store)
for column, value in enumerate(values, 1):
table.setItem(row, column, QTableWidgetItem(value))
source = "egs" if store == "Epic Games" else "gog"
started_item = table.item(row, 1)
assert started_item is not None
started_item.setData(
Qt.ItemDataRole.UserRole, (str(game["app_id"]), source)
)
table.setRowHeight(row, self.theme.downloadsTableRowHeight)
if table is self.downloadQueuedTable:
details_label.setText(_("Queued"))
@@ -883,24 +1242,38 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
self._update_downloads_tab_visibility()
return row, details_label
def _remove_queued_download(self, app_id: str, source: str) -> None:
for row in range(self.downloadQueuedTable.rowCount()):
item = self.downloadQueuedTable.item(row, 1)
if item is not None and item.data(Qt.ItemDataRole.UserRole) == (app_id, source):
self.downloadQueuedTable.removeRow(row)
self._update_download_table_height(self.downloadQueuedTable)
return
def _create_download_game_cell(self, game: dict) -> tuple[QWidget, QLabel]:
widget = QWidget()
layout = QHBoxLayout(widget)
layout.setContentsMargins(*self.theme.downloadsCellMargins)
layout.setSpacing(self.theme.downloadsCellSpacing)
cover = QLabel()
cover.setObjectName("downloadGameCover")
cover_width, cover_height = self.theme.downloadsCoverSize
cover.setFixedSize(cover_width, cover_height)
layout.addWidget(cover)
text_layout = QVBoxLayout()
title = QLabel(str(game["title"]))
title.setObjectName("downloadGameTitle")
details = QLabel(_("Waiting…"))
details.setStyleSheet(self.theme.CONTENT_STYLE)
text_layout.addWidget(title)
text_layout.addWidget(details)
layout.addLayout(text_layout)
def set_cover(pixmap: QPixmap) -> None:
if isValid(cover):
cover.setPixmap(round_corners(pixmap, self.theme.downloadsCoverRadius))
load_pixmap_async(
str(game.get("cover", "")), cover_width, cover_height, cover.setPixmap,
str(game.get("cover", "")), cover_width, cover_height, set_cover,
app_name=f"download-{game['app_id']}",
)
return widget, details
@@ -1011,6 +1384,13 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
def _update_active_download_details(self, details: list[str]) -> None:
self.downloadActiveDetails.setText(" · ".join(details))
def _notify_download_finished(self, game: dict, status: str) -> None:
tray_manager = getattr(self, "tray_manager", None)
if tray_manager is not None:
tray_manager.tray_icon.showMessage(
_("Downloads"), f"{game['title']}: {status}"
)
def _on_gog_download_finished(
self, code: int, _status: QProcess.ExitStatus
) -> None:
@@ -1041,21 +1421,62 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
error = self._get_gog_download_error()
completed_details.setText(error)
logger.error("GOG download failed for %s: %s", app_id, error)
self._notify_download_finished(game, completed_action)
self.gog_process = None
self.downloadOverallProgress.setValue(0)
self.downloadSpeedLabel.setText(_("Downloading: ") + "\u2014")
self.diskSpeedLabel.setText(_("Disk: ") + "\u2014")
if self.gog_download_queue:
next_game = self.gog_download_queue.pop(0)
self.downloadQueuedTable.removeRow(0)
self._update_download_table_height(self.downloadQueuedTable)
self._install_gog_game(next_game)
self._start_next_store_download()
def _on_egs_download_finished(
self, code: int, _status: QProcess.ExitStatus
) -> None:
game = self.egs_active_game
app_id = str(game["app_id"])
if self.egs_install_importing:
self.egs_install_importing = False
imported = app_id in self.egs_api.load_installed()
no_game_files = "No files belonging to" in self.egs_download_output
if code == 0 and (imported or no_game_files):
process = self.egs_process
self.egs_process = None
self._clear_egs_data_lock()
if process is not None:
process.deleteLater()
try:
arguments = [
"repair", app_id, "--repair-and-update", "--skip-sdl", "-y",
]
if not imported:
arguments = [
"install", app_id, "--base-path", str(self.egs_active_install_path),
"--platform", "Windows", "--skip-sdl", "--skip-dlcs", "-y",
]
command = self.egs_api.build_command(arguments)
except OSError as error:
logger.error("Failed to start Epic repair: %s", error)
self.egs_download_output = str(error)
code = 1
else:
self._start_egs_download(game, self.egs_active_install_path, command)
return
else:
code = 1
if code == 0 and game.get("_dlcs"):
process = self.egs_process
self.egs_process = None
self._clear_egs_data_lock()
if process is not None:
process.deleteLater()
try:
install_path = Path(self.egs_api.load_installed()[app_id]["install_path"])
self._start_next_egs_dlc(game, install_path)
except Exception as error:
logger.exception("Failed to start Epic DLC installation")
self.egs_download_output = str(error)
code = 1
else:
return
self.downloadActiveHeading.setVisible(False)
self.downloadActiveCard.setVisible(False)
completed_action = _("Installed") if code == 0 else _("Failed")
@@ -1069,6 +1490,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
error = self._get_egs_download_error()
completed_details.setText(error)
logger.error("Epic download failed for %s: %s", app_id, error)
self._notify_download_finished(game, completed_action)
process = self.egs_process
self.egs_process = None
self._clear_egs_data_lock()
@@ -1077,6 +1499,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
self.downloadOverallProgress.setValue(0)
self.downloadSpeedLabel.setText(_("Downloading: ") + "\u2014")
self.diskSpeedLabel.setText(_("Disk: ") + "\u2014")
self._start_next_store_download()
def _finish_egs_visible_operation(self, action: str, success: bool) -> None:
game = self.egs_active_game
@@ -1095,6 +1518,18 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
self.downloadOverallProgress.setValue(0)
self.downloadSpeedLabel.setText(_("Downloading: ") + "\u2014")
self.diskSpeedLabel.setText(_("Disk: ") + "\u2014")
self._start_next_store_download()
def _start_next_store_download(self) -> None:
if not self.store_download_queue:
return
source, game = self.store_download_queue.pop(0)
self._remove_queued_download(str(game["app_id"]), source)
if source == "gog":
self._install_gog_game(game)
return
self.egs_selected_dlc_game = game
self._install_egs_download(str(game["app_id"]))
def _start_gog_support_setup(self, app_id: str, button=None) -> None:
if getattr(self, "gog_support_workers", []):
+56 -15
View File
@@ -78,7 +78,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)
@@ -101,10 +101,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
display_filter not in ("steam", "portproton")
)
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)
@@ -143,6 +140,24 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
card.update_badge_visibility(display_filter)
card.update_badge_view_mode(badge_view_mode)
def _on_library_layout_changed(self, index: int) -> None:
if index < 0 or index >= len(self.library_layout_keys):
return
layout_mode = self.library_layout_keys[index]
card_orientation = "theme"
if layout_mode in {"horizontal_vertical", "horizontal_top_vertical"}:
layout_mode = layout_mode.removesuffix("_vertical")
card_orientation = "vertical"
elif layout_mode in {"horizontal", "horizontal_top"}:
card_orientation = "horizontal"
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"))
self.game_library_manager.rebuild_library_layout(
ui_config.get_library_layout_mode(theme_mode)
)
self.refresh_autoinstall_layout()
def _toggle_library_controls(self) -> None:
self._position_library_controls_widget()
self.libraryControlsAnimation.toggle(self.libraryControlsButton.isChecked())
@@ -338,15 +353,15 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
self.gamesDisplayCombo.currentIndexChanged.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")
)
@@ -364,7 +379,37 @@ 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_top",
"horizontal_top_vertical", "horizontal", "horizontal_vertical",
]
self.library_layout_labels = [
_("Theme"), _("Grid"), _("List"), _("Vertical"),
_("Horizontal Blocks"), _("Horizontal Cards"),
_("Horizontal endless Blocks"), _("Horizontal endless Cards"),
]
self.gamesLayoutCombo = self._create_library_combo(
self.library_layout_labels, _("Layout")
)
selected_layout = ui_config.get_library_layout_mode("theme")
if (
selected_layout in {"horizontal", "horizontal_top"}
and ui_config.get_horizontal_card_orientation("horizontal")
== "vertical"
):
selected_layout += "_vertical"
self._set_combo_current_key(
self.gamesLayoutCombo, self.library_layout_keys, selected_layout
)
self.gamesLayoutCombo.currentIndexChanged.connect(
self._on_library_layout_changed
)
self.gamesLayoutCombo.activated.connect(
self._delay_library_controls_hover_close
)
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
@@ -652,6 +697,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
entry = parse_desktop_entry(desktop_path)
if not entry:
return
self._known_portproton_desktops.add(desktop_path)
description = entry.get("Comment", "")
exec_line = entry.get("Exec", exe_path)
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
@@ -693,11 +739,6 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
if steam_desc and steam_desc != final_desc:
final_desc = steam_desc
# Use Steam name as fallback if better
steam_name = steam_info.get("name", "")
if steam_name and steam_name != final_name:
final_name = steam_name
# Build full game_data tuple with all Steam data
game_data = (
final_name,
+153 -7
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,35 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
crash_reports_enabled_layout.addStretch()
uiForm.addRow(crash_reports_enabled_layout)
self.disableAltDependencyCheckCheckBox: QCheckBox | None = None
if self._is_alt_x86_64():
self.disableAltDependencyCheckCheckBox = QCheckBox()
self.disableAltDependencyCheckCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.disableAltDependencyCheckCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
disable_alt_dependency_check_title = QLabel(
_("Disable ALT Linux i586 dependency check")
)
disable_alt_dependency_check_title.setSizePolicy(
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
)
disable_alt_dependency_check_title.setStyleSheet(
self.theme.SETTINGS_TITLE_CHECKBOX_STYLE
)
disable_alt_dependency_check_title.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.disableAltDependencyCheckCheckBox.setChecked(
ui_config.get_disable_alt_i586_dependency_check()
)
disable_alt_dependency_check_layout = QHBoxLayout()
disable_alt_dependency_check_layout.setContentsMargins(0, 0, 0, 0)
disable_alt_dependency_check_layout.addWidget(
self.disableAltDependencyCheckCheckBox
)
disable_alt_dependency_check_layout.addWidget(
disable_alt_dependency_check_title
)
disable_alt_dependency_check_layout.addStretch()
uiForm.addRow(disable_alt_dependency_check_layout)
disable_runtime_download_layout = None
if not os.getenv("FLATPAK_ID"):
self.disableRuntimeDownloadCheckBox = QCheckBox()
@@ -474,6 +570,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)
@@ -710,9 +821,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")
@@ -728,26 +842,49 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
self.resetSettingsButton.clicked.connect(self.resetSettings)
buttonsLayout.addWidget(self.resetSettingsButton)
self.globalGameSettingsButton = AutoSizeButton(
_("Global Game Settings"), icon=self.theme_manager.get_icon("settings", as_path=True)
)
self.globalGameSettingsButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
self.globalGameSettingsButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
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:
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
ExeSettingsDialog(self, self.theme, user_conf=True).exec()
def resetSettings(self):
"""Reset settings and restart application."""
msg_box = QMessageBox(self)
@@ -922,9 +1059,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)
@@ -956,6 +1096,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())
@@ -1053,6 +1195,10 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
ui_config.set_crash_reports_enabled(
self.crashReportsEnabledCheckBox.isChecked()
)
if self.disableAltDependencyCheckCheckBox is not None:
ui_config.set_disable_alt_i586_dependency_check(
self.disableAltDependencyCheckCheckBox.isChecked()
)
from portprotonqt.sound_manager import SoundManager
SoundManager().reload_config()
+53 -2
View File
@@ -4,7 +4,7 @@ import time
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from PySide6.QtCore import QTimer, Qt
from PySide6.QtCore import QFileSystemWatcher, QTimer, Qt
from PySide6.QtWidgets import (
QAbstractButton,
QHBoxLayout,
@@ -24,7 +24,7 @@ from portprotonqt.image_utils import ImageCarousel
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
from portprotonqt.tabs.theme_store import THEME_STORE_ITEM, ThemeStoreMixin
from portprotonqt.theme_manager import load_theme_screenshots
from portprotonqt.theme_manager import _find_theme_folder, load_theme_screenshots
from portprotonqt.tray_manager import restart_application_process
logger = get_logger(__name__)
@@ -221,6 +221,50 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
# Add widget to stackedWidget
self.theme_tab_index = self.stackedWidget.addWidget(self.themeTabWidget)
self._setup_theme_file_watcher()
def _setup_theme_file_watcher(self) -> None:
self.themeFileWatcher = QFileSystemWatcher(self)
self.themeFileWatcher.fileChanged.connect(self._schedule_theme_reload)
self.themeFileWatcher.directoryChanged.connect(self._schedule_theme_reload)
self.themeReloadTimer = QTimer(self)
self.themeReloadTimer.setSingleShot(True)
self.themeReloadTimer.setInterval(250)
self.themeReloadTimer.timeout.connect(self._reload_active_custom_theme)
self._watch_active_theme_files()
def _watch_active_theme_files(self) -> None:
watcher = self.themeFileWatcher
watched_paths = [*watcher.files(), *watcher.directories()]
if watched_paths:
watcher.removePaths(watched_paths)
theme_name = self.current_theme_name
if not self.theme_manager.is_custom_theme(theme_name):
return
theme_folder = _find_theme_folder(theme_name)
if theme_folder is None:
return
paths = [theme_folder]
for root, dirs, files in os.walk(theme_folder):
dirs[:] = [name for name in dirs if name != "__pycache__"]
paths.extend(os.path.join(root, name) for name in dirs)
paths.extend(os.path.join(root, name) for name in files)
watcher.addPaths(paths)
def _schedule_theme_reload(self, _path: str) -> None:
self._watch_active_theme_files()
self.themeReloadTimer.start()
def _reload_active_custom_theme(self) -> None:
theme_name = self.current_theme_name
if not self.theme_manager.is_custom_theme(theme_name):
return
self.theme_manager.invalidate_theme(theme_name)
try:
self._apply_theme_live(theme_name)
except Exception as e:
logger.warning("Failed to reload theme '%s': %s", theme_name, e)
self._watch_active_theme_files()
def _get_selected_custom_themes(self) -> list[str]:
base_theme = self.themesCombo.currentText()
@@ -394,6 +438,8 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
),
)
self.updateControlHints("force")
if hasattr(self, "themeFileWatcher"):
self._watch_active_theme_files()
def _apply_deferred_theme_updates(
self,
@@ -433,10 +479,15 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
def _refresh_theme_library_layout(
self, old_theme: object, new_theme: object
) -> None:
if ui_config.get_library_layout_mode("theme") != "theme":
return
old_mode = getattr(old_theme, "LIBRARY_LAYOUT_MODE", "grid")
new_mode = getattr(new_theme, "LIBRARY_LAYOUT_MODE", "grid")
if old_mode == new_mode:
return
refresh_autoinstall = getattr(self, "refresh_autoinstall_layout", None)
if callable(refresh_autoinstall):
refresh_autoinstall()
manager = getattr(self, "game_library_manager", None)
layout = getattr(manager, "gamesListLayout", None)
if manager is None or layout is None:
+10 -24
View File
@@ -24,9 +24,13 @@ from portprotonqt.dialogs.prefix_backup import PrefixBackupDialog, PrefixBackupJ
from portprotonqt.dialogs.proton_manager import show_proton_manager
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
from portprotonqt.portproton_api import get_user_conf_setting, set_user_conf_setting
from portprotonqt.portproton_api import get_user_conf_setting
from portprotonqt.scripts_utils.prefix_backup import is_legacy_squashfs_backup
from portprotonqt.settings_manager import get_available_prefix_options, get_available_wine_options
from portprotonqt.settings_manager import (
LG_WINE_ALIASES,
get_available_prefix_options,
get_available_wine_options,
)
logger = get_logger(__name__)
@@ -64,9 +68,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
formLayout.setSpacing(self.theme.wineSettingsSetSpacing)
formLayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
self.wine_versions = get_available_wine_options(
self.portproton_location, include_lg_aliases=True
)
self.wine_versions = get_available_wine_options(self.portproton_location)
self.wineCombo = CustomComboBox(theme=self.theme)
self.wineCombo.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
@@ -84,7 +86,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
if self.wine_versions:
self.wineCombo.setCurrentIndex(0)
default_wine = get_user_conf_setting('PW_DEFAULT_WINE_USE')
if default_wine:
if default_wine and default_wine not in dict(LG_WINE_ALIASES):
if self.wineCombo.findText(default_wine) == -1:
self.wineCombo.addItem(default_wine)
self.wineCombo.setCurrentText(default_wine)
@@ -158,7 +160,6 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
tools_grid.setSpacing(6)
tools = [
("default", _("Use by default")),
("--winecfg", _("Wine Configuration")),
("--winereg", _("Registry Editor")),
("--winefile", _("File Explorer")),
@@ -173,10 +174,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
btn.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
if tool_cmd == "default":
btn.clicked.connect(self.save_wine_defaults)
else:
btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t))
btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t))
tools_grid.addWidget(btn, row, col)
for col in range(3):
@@ -228,16 +226,6 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
wine_progress_layout.addWidget(self.wine_progress_bar)
layout.addLayout(wine_progress_layout)
def save_wine_defaults(self) -> None:
default_wine = self.wineCombo.currentText().strip()
raw_prefix = self.prefixCombo.currentText().strip()
default_prefix = re.sub(r"[ \t]", "_", raw_prefix).upper() if raw_prefix else ""
default_vulkan = self.defaultVulkanCombo.currentData()
set_user_conf_setting('PW_DEFAULT_WINE_USE', default_wine)
set_user_conf_setting('PW_DEFAULT_PREFIX_NAME', default_prefix)
set_user_conf_setting('PW_DEFAULT_VULKAN_USE', str(default_vulkan or ""))
def launch_generic_tool(self, cli_arg):
wine = self.wineCombo.currentText()
raw_prefix = self.prefixCombo.currentText().strip()
@@ -471,9 +459,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
if not self.portproton_location:
return
self.wine_versions = get_available_wine_options(
self.portproton_location, include_lg_aliases=True
)
self.wine_versions = get_available_wine_options(self.portproton_location)
self.wineCombo.clear()
self.wineCombo.addItems(self.wine_versions)
+54 -5
View File
@@ -10,32 +10,81 @@ class MainWindowWorkersMixin:
"bluetoothWorker": 12000,
"storageWorker": 3000,
"audioWorker": 3000,
"autoInstallLoadThread": 3000,
"autoInstallLoadThread": None,
"autoInstallScriptLoadThread": None,
"autoInstallCustomDataThread": None,
"appimageUpdateWorker": 3000,
"appImageIntegrationWorker": None,
"gogdlUpdateWorker": None,
"initialCommandWorker": None,
"egs_library_worker": None,
"egs_auth_worker": None,
"gog_library_worker": None,
"gog_metadata_worker": None,
"gog_repair_worker": None,
"store_dlc_worker": None,
"gog_auth_worker": None,
"gog_account_worker": None,
"themeStoreListWorker": None,
"themeStoreImageWorker": None,
"themeStoreDetailImageWorker": None,
"themeStoreDownloadWorker": None,
}
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",
):
self._stopWorkerThreads(workers_name)
downloader = getattr(self, "downloader", None)
for worker in list(getattr(downloader, "_active_threads", [])):
self._stopWorker(worker, "downloader", None)
for worker in getattr(self, "compatibility_workers", []):
worker.join()
self.compatibility_workers = []
input_manager = getattr(self, "input_manager", None)
if input_manager is not None:
input_manager.cleanup()
def _stopWorkerThread(self, worker_name: str, timeout_ms: int = 3000) -> None:
def _stopWorkerThread(
self, worker_name: str, timeout_ms: int | None = 3000
) -> None:
worker = getattr(self, worker_name, None)
if worker is None:
return
self._stopWorker(worker, worker_name, timeout_ms)
setattr(self, worker_name, None)
def _stopWorkerThreads(self, workers_name: str) -> None:
workers = getattr(self, workers_name, []) or []
for worker in workers:
self._stopWorker(worker, workers_name, None)
setattr(self, workers_name, [])
def _stopWorker(
self, worker: object, worker_name: str, timeout_ms: int | None
) -> None:
request_interruption = getattr(worker, "requestInterruption", None)
cancel = getattr(worker, "cancel", None)
try:
if callable(request_interruption):
request_interruption()
if callable(cancel):
cancel()
is_running = getattr(worker, "isRunning", None)
wait_method = getattr(worker, "wait", None)
if callable(is_running) and is_running() and callable(wait_method):
wait_method(timeout_ms)
if timeout_ms is None:
wait_method()
else:
wait_method(timeout_ms)
if callable(is_running) and is_running():
logger.warning("%s is still running during shutdown", worker_name)
except RuntimeError as error:
logger.debug("%s already deleted during shutdown: %s", worker_name, error)
setattr(self, worker_name, None)
+23
View File
@@ -651,6 +651,11 @@ class ThemeWrapper:
self._default_theme = None # Lazy-loaded default theme
self._generated_styles = None # Lazy-generated standard styles with custom constants
def get_theme_override(self, name: str) -> Any | None:
"""Return a value declared by the custom theme."""
declared = getattr(self.custom_theme, "_declared_theme_names", set())
return getattr(self.custom_theme, name, None) if name in declared else None
@property
def screenshots(self) -> list[tuple[QPixmap, str]]:
"""Load theme preview screenshots on first access."""
@@ -842,6 +847,19 @@ def load_theme(theme_name, inherit_chain=None):
if spec is None or spec.loader is None:
continue
custom_theme = importlib.util.module_from_spec(spec)
try:
with open(styles_file, encoding="utf-8") as source_file:
theme_tree = ast.parse(source_file.read(), filename=styles_file)
declared_theme_names = {
target.id
for node in theme_tree.body
if isinstance(node, ast.Assign)
for target in node.targets
if isinstance(target, ast.Name)
}
custom_theme.__dict__["_declared_theme_names"] = declared_theme_names
except (OSError, SyntaxError):
custom_theme.__dict__["_declared_theme_names"] = set()
# Temporarily add the theme directory to sys.path to support relative imports
theme_dir = os.path.dirname(styles_file)
@@ -1028,6 +1046,11 @@ class ThemeManager:
def invalidate_theme(self, theme_name: str) -> None:
"""Discard a cached theme after its files change."""
self._theme_module_cache.pop(theme_name, None)
_icon_dirs_cache.pop(theme_name, None)
marker = f"_{theme_name}_"
for cache_key in list(_icon_cache):
if marker in cache_key:
_icon_cache.pop(cache_key, None)
if self.current_theme_name == theme_name:
self.current_theme_name = None
self.current_theme_module = None
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more