Compare commits
402
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e9f935222
|
||
|
|
4fb74c1d15
|
||
|
|
78dab249c1
|
||
|
|
9e91eeb0dc
|
||
|
|
cb8d149b2a
|
||
|
|
98475c0e59
|
||
|
|
0391b894ee
|
||
|
|
ca352d0ae9
|
||
|
|
b0b22481c1
|
||
|
|
147187a02a
|
||
|
|
a874ea6979
|
||
|
|
ca658fa84f
|
||
|
|
3bd78f5c73
|
||
|
|
b3824a3556
|
||
|
|
4d9e39d9e3
|
||
|
|
9c148c2710 | ||
|
|
0dae987520
|
||
|
|
00ec0baf35 | ||
|
|
28726a5cbd | ||
|
|
36a1994e4f
|
||
|
|
6bae4616ec
|
||
|
|
e1080d0185
|
||
|
|
e224904d4f | ||
|
|
2b4a0ede44 | ||
|
|
13079b3a50
|
||
|
|
6eae0612b8
|
||
|
|
d77d972da6
|
||
|
|
0e9bfa9d44
|
||
|
|
00c1d086b1
|
||
|
|
a3a7f61f8b
|
||
|
|
8adc0ebda7
|
||
|
|
6f73fa2b67
|
||
|
|
c4f172faf1 | ||
|
|
974802c409 | ||
|
|
4d4c392d85
|
||
|
|
10d034031b
|
||
|
|
04d1e44447
|
||
|
|
10cc22a88a
|
||
|
|
b8e06201e2
|
||
|
|
7f8c31fbb8
|
||
|
|
c4d28f8f72 | ||
|
|
06c3bf3ff4 | ||
|
|
45341a35a9 | ||
|
|
6c0da73df4 | ||
|
|
43f9219ecd | ||
|
|
635038c7c4 | ||
|
|
c5326efc48
|
||
|
|
a27cbf57d2
|
||
|
|
6fdcfb212e
|
||
|
|
78bb8391bf
|
||
|
|
2bb47c0d0e
|
||
|
|
4ef0184c81
|
||
|
|
1eb9800a30
|
||
|
|
2caab288dd
|
||
|
|
e80773c601
|
||
|
|
4b42d19bea
|
||
|
|
d04f8fdc9e
|
||
|
|
40a3cacc36
|
||
|
|
d54c84f723
|
||
|
|
2cfbe08323
|
||
|
|
d6684691a4
|
||
|
|
0bf87e172d
|
||
|
|
07bfa9611f
|
||
|
|
1f4ee890cf
|
||
|
|
8feb58c5e0
|
||
|
|
f47ff16e97
|
||
|
|
47f8faeaf2 | ||
|
|
2085fff8c3 | ||
|
|
e8ddd4372e | ||
|
|
1bfacb887b | ||
|
|
461527eb0c | ||
|
|
eb3d507f9d
|
||
|
|
d501dfcecf
|
||
|
|
e21bb09081
|
||
|
|
fa8e8478e5
|
||
|
|
51b78b506c
|
||
|
|
1776dd9e94
|
||
|
|
7f75d5386d
|
||
|
|
64aedb4a12
|
||
|
|
bc41211ab1
|
||
|
|
bf93eff67d
|
||
|
|
5d2df85d19 | ||
|
|
68ba818d74
|
||
|
|
68c89eccbc
|
||
|
|
5109c50c99
|
||
|
|
9f194fd33f
|
||
|
|
d7838eceb1 | ||
|
|
94af16ceac
|
||
|
|
d2163b84b4
|
||
|
|
a19f446b44 | ||
|
|
d1c812c5bd | ||
|
|
e76abf430c
|
||
|
|
e9473ccaeb
|
||
|
|
63997f8e1a
|
||
|
|
45235891c8
|
||
|
|
682363ded2
|
||
|
|
522e52f032
|
||
|
|
c30be1214a
|
||
|
|
a99e752ec0
|
||
|
|
91eddcb161
|
||
|
|
7f68494a57
|
||
|
|
135d53df31
|
||
|
|
893ed8f057
|
||
|
|
aaeb550fd6
|
||
|
|
0210773642
|
||
|
|
6cf0fc4650
|
||
|
|
404d7c0fd0
|
||
|
|
fd2b0fca61 | ||
|
|
5fa734c2d1
|
||
|
|
9b602181cc
|
||
|
|
7a2be0c289
|
||
|
|
7704308d63
|
||
|
|
1f9882cec8
|
||
|
|
2f8d73e074
|
||
|
|
2b36fe0a92
|
||
|
|
cf1ed31f04
|
||
|
|
066b3b6767
|
||
|
|
ebb80a3602
|
||
|
|
1c37b4c961
|
||
|
|
16060274d6
|
||
|
|
197b5266ba
|
||
|
|
3787e9b2bf
|
||
|
|
4f5499b56d
|
||
|
|
e1b8bfde7f
|
||
|
|
311039a543 | ||
|
|
fc73d80e43
|
||
|
|
498a26c00e
|
||
|
|
2234066479
|
||
|
|
d44f561c4c
|
||
|
|
1d7602bb9e
|
||
|
|
facedf6c1a
|
||
|
|
78c5302a99 | ||
|
|
3fb29751ec | ||
|
|
910c18ad37 | ||
|
|
a050c2ed35 | ||
|
|
dc7e672ec8 | ||
|
|
ee093d727c | ||
|
|
a4f4f7e60f
|
||
|
|
d1ea3d28f7
|
||
|
|
c2dca6eda9
|
||
|
|
f9c3e52275 | ||
|
|
7091d5ccbd | ||
|
|
b88567a3ae
|
||
|
|
40b3644f80
|
||
|
|
636c535a33 | ||
|
|
e01076524c
|
||
|
|
ac84f3738b | ||
|
|
49f2d044b9
|
||
|
|
21603e32a4
|
||
|
|
4a35f05893
|
||
|
|
29362b3413
|
||
|
|
166667f7cf | ||
|
|
6c560b9123
|
||
|
|
595f58195e
|
||
|
|
e58d470960
|
||
|
|
760dd359b8
|
||
|
|
27f1247a1b | ||
|
|
e25b52f3d5
|
||
|
|
523446e827
|
||
|
|
a6610e8087
|
||
|
|
4dc09df895 | ||
|
|
20c8dbe350 | ||
|
|
839b4b7523 | ||
|
|
4860c232ea | ||
|
|
704c514ba4 | ||
|
|
68b77657d6 | ||
|
|
13e89d8b2b
|
||
|
|
30c3bfb3b1
|
||
|
|
8e12ec713f
|
||
|
|
e809f031ce
|
||
|
|
1b42c672bb
|
||
|
|
07b53f3160
|
||
|
|
40d80f0ce8
|
||
|
|
0b8536b163
|
||
|
|
31f6d84537
|
||
|
|
5c86b3d943
|
||
|
|
ec2ffee64d
|
||
|
|
60e9a2866a
|
||
|
|
94269455ec
|
||
|
|
f5c07cf3ea
|
||
|
|
509d4272ab
|
||
|
|
ff560fb43a
|
||
|
|
ed78af49d2
|
||
|
|
5c5a154a0d
|
||
|
|
ae011adfdf
|
||
|
|
b0d97e0e0b
|
||
|
|
8a303bf9ed
|
||
|
|
a14563dd78
|
||
|
|
abb511c173 | ||
|
|
ef0626a069
|
||
|
|
40f06caa16
|
||
|
|
bfd4bf4065
|
||
|
|
92ecc008f3
|
||
|
|
0c78e9c689
|
||
|
|
4840d512b5 | ||
|
|
c22992b803
|
||
|
|
57f955d827 | ||
|
|
f9694e576b | ||
|
|
a74c5631ec | ||
|
|
55361797c3
|
||
|
|
f3999e9bf1
|
||
|
|
35366bbf12
|
||
|
|
bab6d6b84e
|
||
|
|
061ee7fedf
|
||
|
|
8707c93835
|
||
|
|
53a44c5708
|
||
|
|
154cf43bf8
|
||
|
|
a70bf97774
|
||
|
|
a06f76a2e9
|
||
|
|
649aec7229
|
||
|
|
0da0d55537
|
||
|
|
c2f611c73e | ||
|
|
c4f9f05363 | ||
|
|
56c8a9781b
|
||
|
|
9d72466402
|
||
|
|
4df207fa3b
|
||
|
|
bd6dc490b1 | ||
|
|
383d17f523 | ||
|
|
9b889a8d2e | ||
|
|
64c9f4d196 | ||
|
|
0de9e46485
|
||
|
|
bdc14d1d86
|
||
|
|
1fc8d62a85
|
||
|
|
0e3a0be146 | ||
|
|
6e2f7452bd | ||
|
|
37168f8892 | ||
|
|
141f98d81f | ||
|
|
7031051dc4
|
||
|
|
769ad421a2
|
||
|
|
2316efe3d6
|
||
|
|
09b790fc26
|
||
|
|
a48b9050d2
|
||
|
|
b602880907
|
||
|
|
05a5b136e9
|
||
|
|
bde4ba9602
|
||
|
|
2634370ee3 | ||
|
|
9cea141f9c | ||
|
|
3d417c1a83
|
||
|
|
27003d4967
|
||
|
|
c75755bb64
|
||
|
|
67652ea258
|
||
|
|
3735c7514c
|
||
|
|
803c98b6ff
|
||
|
|
9214018c03
|
||
|
|
d2597fa8d6
|
||
|
|
25e20c3ea1
|
||
|
|
f1a5db4f34
|
||
|
|
096d86cb7b
|
||
|
|
7b7553a1fe
|
||
|
|
7c30418d61
|
||
|
|
fa30835e97
|
||
|
|
0bdab8fd40 | ||
|
|
43f4d0a665 | ||
|
|
779a5eca65 | ||
|
|
1a30a105d0
|
||
|
|
45fe72d393
|
||
|
|
3b9e531eda
|
||
|
|
822b454bf4
|
||
|
|
77a65d9e81 | ||
|
|
eee8fb5f0e | ||
|
|
4d84d74d70 | ||
|
|
c9fea6e713
|
||
|
|
b4f39b003b
|
||
|
|
f7e44d72c8
|
||
|
|
747b85ce2f
|
||
|
|
b907b5400c
|
||
|
|
043031baec
|
||
|
|
17e7fa2690
|
||
|
|
be1e517c68
|
||
|
|
bcd089439e
|
||
|
|
cb1b984f04
|
||
|
|
22bf9b2451
|
||
|
|
c8ff25dd69
|
||
|
|
927531620a
|
||
|
|
bb14e1a346
|
||
|
|
2a5a45fabd
|
||
|
|
5e0e5b785e
|
||
|
|
90c4901e8e | ||
|
|
c1e76b8e8c
|
||
|
|
782c9cae6a | ||
|
|
725c8d371e
|
||
|
|
53d79c50d3
|
||
|
|
af8679aa08
|
||
|
|
96a2bd92e8
|
||
|
|
810d09b0ad | ||
|
|
359ac7d72e | ||
|
|
6135f640fd
|
||
|
|
58d85dcf9f
|
||
|
|
6b614ce0c5
|
||
|
|
7b563dff32
|
||
|
|
d04e637030
|
||
|
|
00a222073f
|
||
|
|
e09c87491f | ||
|
|
4f5fa36a0d | ||
|
|
6e70c92afc
|
||
|
|
ff568fad1c
|
||
|
|
254f56fe17 | ||
|
|
b0e8345517 | ||
|
|
897daf983b | ||
|
|
2effcf5505 | ||
|
|
5aaa7cbc7e
|
||
|
|
6a502711ad | ||
|
|
4889ad1f48
|
||
|
|
2d5336bc51
|
||
|
|
a7fe71558e
|
||
|
|
b9de7e40fe
|
||
|
|
9ad69007ec
|
||
|
|
6af8b31a90
|
||
|
|
18ae09f4b0
|
||
|
|
ba56c92726
|
||
|
|
b55ffc9d09 | ||
|
|
f819e495f2
|
||
|
|
4e998edae8
|
||
|
|
6ccfeffdd8
|
||
|
|
3ce2acca89
|
||
|
|
2092a08e82
|
||
|
|
79a32d392e
|
||
|
|
f1a34d352b
|
||
|
|
7afd6d0baf | ||
|
|
a0a602daf5 | ||
|
|
9d797e8072
|
||
|
|
b695ec6a2a
|
||
|
|
d562bbf18d
|
||
|
|
cf4185007a
|
||
|
|
e8dbaeb9f7
|
||
|
|
2ec44c6a5b
|
||
|
|
257f3c85cf
|
||
|
|
53f1f5857c | ||
|
|
ae1d991286
|
||
|
|
693f0cb7f4
|
||
|
|
85af880000
|
||
|
|
ed1ba4dd28
|
||
|
|
de29dd92a9
|
||
|
|
6fa6f1af2a | ||
|
|
16ef000ed2
|
||
|
|
299a3cf89e | ||
|
|
056faafd82
|
||
|
|
4615b922c2
|
||
|
|
e842840101
|
||
|
|
8e76a50b1d
|
||
|
|
e7cf33cdc0
|
||
|
|
3c47d636f2
|
||
|
|
0b253a4d6e
|
||
|
|
ded84a6130
|
||
|
|
4a17f438fd | ||
|
|
3e6e138ff9
|
||
|
|
2f7dc41c49
|
||
|
|
ce1ca0d026
|
||
|
|
6ffdd4988f
|
||
|
|
debe88f2bc
|
||
|
|
efeac02ece
|
||
|
|
08d45ddc70
|
||
|
|
7a62207063
|
||
|
|
1deaaf74d8
|
||
|
|
a45792307e
|
||
|
|
a52d4a1f49
|
||
|
|
a5ea3e220f
|
||
|
|
10b498acb7
|
||
|
|
d9f3e7fbb8
|
||
|
|
998a4234e5
|
||
|
|
97c1addedc
|
||
|
|
b9a8a07a04
|
||
|
|
9e5fd9d1a7
|
||
|
|
cea7b40b82
|
||
|
|
2a89bd7573
|
||
|
|
c66538e32f
|
||
|
|
d2e06fcbc3 | ||
|
|
4b56725559
|
||
|
|
14cf5c73f8
|
||
|
|
347685e4f0
|
||
|
|
5e512279f3
|
||
|
|
05e7366706
|
||
|
|
862bfe4f8b
|
||
|
|
12b0889566 | ||
|
|
a62bfecfd3
|
||
|
|
7ec1602be7
|
||
|
|
7ae1904bb6
|
||
|
|
527cceccc2
|
||
|
|
42eff0d44b
|
||
|
|
12f3d3b753
|
||
|
|
3afe35929f
|
||
|
|
32e363d328
|
||
|
|
6443b71c01
|
||
|
|
d5db14db54
|
||
|
|
db2cc0365d
|
||
|
|
ab073c5413
|
||
|
|
2212c7c23a
|
||
|
|
3a3ae4be2e
|
||
|
|
3d9151df7b | ||
|
|
844fcff2da | ||
|
|
cf542d5ce2 | ||
|
|
a6c25bad98
|
||
|
|
0f0155cfb7
|
||
|
|
8b7849b3fd
|
||
|
|
e0d8f587fa
|
||
|
|
393d5cce98
|
||
|
|
cf7a14983d
|
||
|
|
024a1670eb
|
||
|
|
6cc3487d59 | ||
|
|
0ce8317763 | ||
|
|
c3e1222237 | ||
|
|
bdc52c8f02 |
No files matched your search
@@ -7,7 +7,7 @@ on:
|
||||
- main
|
||||
- scripts
|
||||
paths:
|
||||
- 'build-aux/vk_gpu_info.c'
|
||||
- 'build-aux/src/vk_gpu_info.c'
|
||||
- '.gitea/workflows/build-vk-gpu-info-debian.yml'
|
||||
|
||||
jobs:
|
||||
@@ -34,17 +34,16 @@ jobs:
|
||||
|
||||
- name: Compile vk_gpu_info
|
||||
run: |
|
||||
cd build-aux
|
||||
gcc -o vk_gpu_info vk_gpu_info.c -lvulkan
|
||||
mkdir -p build-aux/bin
|
||||
gcc -o build-aux/bin/vk_gpu_info build-aux/src/vk_gpu_info.c -lvulkan
|
||||
|
||||
- name: Test vk_gpu_info execution
|
||||
run: |
|
||||
cd build-aux
|
||||
# Check if the executable was created
|
||||
if [ -f "./vk_gpu_info" ]; then
|
||||
if [ -f "build-aux/bin/vk_gpu_info" ]; then
|
||||
echo "vk_gpu_info executable created successfully"
|
||||
# Try to run it (may fail if no Vulkan devices present, but that's OK for build test)
|
||||
timeout 10s ./vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
|
||||
timeout 10s build-aux/bin/vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
|
||||
else
|
||||
echo "ERROR: vk_gpu_info executable was not created"
|
||||
exit 1
|
||||
@@ -54,4 +53,4 @@ jobs:
|
||||
uses: https://git.linux-gaming.ru/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: vk_gpu_info-debian11-binary
|
||||
path: build-aux/vk_gpu_info
|
||||
path: build-aux/bin/vk_gpu_info
|
||||
+41
-10
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 1.3.1
|
||||
VERSION: 1.5.0
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -31,11 +31,16 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
- name: Configure Arch mirror
|
||||
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
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
|
||||
|
||||
- name: Install CI dependencies
|
||||
run: pacman -S --noconfirm --needed nodejs npm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@v4
|
||||
|
||||
@@ -69,6 +74,11 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
@@ -119,7 +129,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [42, 43, 44, rawhide]
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -128,9 +138,16 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools meson ninja-build python3-devel \
|
||||
# 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
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
@@ -170,9 +187,23 @@ 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 \
|
||||
libvulkan-dev meson ninja-build nodejs npm \
|
||||
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
|
||||
|
||||
- name: Checkout
|
||||
|
||||
@@ -22,21 +22,25 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout
|
||||
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- name: Install CI dependencies
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git nodejs npm
|
||||
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Activate .venv & run pre-commit
|
||||
- name: Install project and check dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
source build-aux/PKGBUILD-git
|
||||
pacman -S --noconfirm --needed "${depends[@]}" python-qrcode libpulse mesa pre-commit pyright python-pytest python-pytest-mock
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- name: Run pre-commit
|
||||
run: pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
stats:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
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@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
debian: ${{ steps.check.outputs.debian }}
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -80,19 +80,24 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
- name: Configure Arch mirror
|
||||
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
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- 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'
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -126,7 +131,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [42, 43, 44, rawhide]
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -135,9 +140,16 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools meson ninja-build python3-devel \
|
||||
# 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
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
@@ -147,11 +159,11 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -183,12 +195,17 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
pacman -Syu --noconfirm --needed git wget gnupg nodejs npm
|
||||
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||
yes | pacman -Scc
|
||||
@@ -211,11 +228,11 @@ jobs:
|
||||
chown user -R ..
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -249,23 +266,37 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20260623@sha256:d07d1b51c39f51188e60be9b64e6bf769fa94e187f092bc32b91305cfa34ba5a
|
||||
image: debian:trixie-20261005@sha256:913f6706df59a68922d1dd08f78c2476560a8d367897200a6005b00e5f67c2d5
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
|
||||
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
|
||||
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
apt-get update
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libvulkan-dev meson ninja-build nodejs npm \
|
||||
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
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
|
||||
@@ -9,11 +9,14 @@ jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
|
||||
env:
|
||||
UV_PYTHON_INSTALL_MIRROR: https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone/
|
||||
UV_DEFAULT_INDEX: https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- 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
|
||||
|
||||
|
||||
@@ -43,3 +43,6 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
|
||||
|
||||
# AI
|
||||
.codex
|
||||
CODE_QUALITY_PLAN.md
|
||||
.repowise
|
||||
.pz
|
||||
@@ -24,7 +24,7 @@ repos:
|
||||
hooks:
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: uv run pyright
|
||||
entry: pyright
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
@@ -62,7 +62,7 @@ repos:
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: uv run pytest tests/ -x -q --tb=line
|
||||
entry: pytest tests/ -x -q --tb=line
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^(tests/|portprotonqt/)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.10
|
||||
3.11
|
||||
@@ -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
|
||||
@@ -78,6 +78,31 @@ Example:
|
||||
|
||||
---
|
||||
|
||||
## Commit Classification
|
||||
|
||||
Use Conventional Commit prefixes according to the primary purpose of the change:
|
||||
|
||||
| Prefix | Use for |
|
||||
|--------|---------|
|
||||
| `feat` | A new user-facing capability or supported workflow |
|
||||
| `fix` | A correction for a confirmed defect or regression in existing behavior |
|
||||
| `chore` | Maintenance, integration adjustments, filtering, metadata, or routine upkeep that is neither a feature nor a confirmed bug fix |
|
||||
| `refactor` | Internal restructuring with no intended behavior change |
|
||||
| `perf` | A measurable performance improvement |
|
||||
| `test` | Test-only changes |
|
||||
| `docs` | Documentation-only changes |
|
||||
| `build` | Build system or dependency changes |
|
||||
| `ci` | CI or automation configuration changes |
|
||||
| `style` | Formatting-only changes with no behavior change |
|
||||
| `revert` | Reverting an earlier commit |
|
||||
|
||||
- Classify by intent, not merely by whether code behavior changed.
|
||||
- Do not default to `fix` for every behavioral correction or edge-case filter.
|
||||
- Use `fix` only when the task identifies a bug/regression or the defect is clearly demonstrated.
|
||||
- If `feat`, `fix`, and `chore` are ambiguous, prefer `chore` unless the task clearly establishes a new capability or confirmed defect.
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
```python
|
||||
@@ -250,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
|
||||
|
||||
@@ -263,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").
|
||||
|
||||
---
|
||||
|
||||
@@ -277,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
|
||||
|
||||
---
|
||||
|
||||
@@ -304,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
|
||||
@@ -384,24 +414,35 @@ 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_input_manager.py # Gamepad input navigation and focus regressions
|
||||
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
|
||||
├── test_input_manager.py # Gamepad input navigation, dialog keyboards, and focus regressions
|
||||
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
|
||||
├── test_boot_animation.py # Theme video inheritance, fullscreen boot rendering, gamepad skip, loading transitions, and shutdown
|
||||
├── test_main_window.py # Main window data processing and callback regressions
|
||||
├── test_library_resize.py # Library resize batching, cover reuse, and flow layout regressions
|
||||
├── test_library_loading.py # Incremental loading, source switching, first-paint focus, and cover callbacks
|
||||
├── test_store_games.py # GOG and EGS library, launch, install, and stop regressions
|
||||
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
|
||||
├── test_portproton_scripts.py # Shell script launch, second exe, exit codes, user.conf
|
||||
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
|
||||
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
|
||||
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
|
||||
├── test_file_explorer.py # File explorer themed icon state regressions
|
||||
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
|
||||
├── test_exe_metadata.py # PE version strings, shell output, and ExifTool fallback
|
||||
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
|
||||
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
|
||||
├── test_shortcuts.py # Desktop shortcut creation, paths with spaces, .desktop entry
|
||||
├── test_shortcuts.py # Desktop shortcuts, folder import, edited exe paths, and gamepad navigation
|
||||
├── test_theme_store.py # Theme store UI worker lifecycle and race regressions
|
||||
├── test_theme_manager.py # Theme AST injection, parent resolution, ThemeWrapper, style integrity
|
||||
├── test_theme_security.py # Theme security checker (allowlist AST, forbidden modules/methods, SVG/font/image safety)
|
||||
└── test_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
|
||||
├── test_settings_search.py # MangoHud, vkBasalt, Gamescope settings search and layout regressions
|
||||
├── test_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
|
||||
└── test_proton_manager.py # Local Wine/Proton drag-and-drop and safe archive extraction
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
@@ -426,9 +467,18 @@ pre-commit run pytest
|
||||
- Tests MUST mock external resources (network, system paths, env vars)
|
||||
- Tests MUST NOT depend on system state (installed apps, real Steam dirs)
|
||||
- Use `monkeypatch` for environment variables and function patching
|
||||
- Keep tests locale-independent (assert numbers, not translated strings)
|
||||
- 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 |
|
||||
@@ -442,6 +492,7 @@ pre-commit run pytest
|
||||
| `classic-light/styles.py` | NameError: border_none not defined (no styles/constants.py) | 519edd1 |
|
||||
| `detail_pages` | DETAIL_PAGE_GRADIENT stops override, wave background modes | — |
|
||||
| `detail_pages` | Edit/Add shortcut buttons shown together for non-Steam games | 7e38d9a |
|
||||
| `wine_extractor` | Archive traversal and process-wide cwd changes during extraction | — |
|
||||
|
||||
---
|
||||
|
||||
@@ -463,6 +514,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**
|
||||
|
||||
@@ -874,21 +926,113 @@ install_data(
|
||||
|
||||
```
|
||||
PortProtonQt/
|
||||
├── portprotonqt/ # Python package
|
||||
│ ├── app.py # Entry point
|
||||
│ ├── main_window.py # Main window
|
||||
│ ├── game_card.py # Game card widget
|
||||
│ ├── steam_api.py # Steam integration
|
||||
│ ├── egs_api.py # Epic Games integration
|
||||
│ ├── theme_manager.py # Theme management
|
||||
│ ├── logger.py # Logging
|
||||
│ └── themes/ # Theme files
|
||||
├── tests/ # Unit tests
|
||||
├── build-aux/ # Build resources
|
||||
├── dev-scripts/ # Development scripts
|
||||
├── documentation/ # Documentation
|
||||
├── meson.build # Meson config
|
||||
├── pyproject.toml # Python config
|
||||
├── portprotonqt/ # Python package
|
||||
│ ├── animations/ # UI animation modules
|
||||
│ │ ├── detail_background.py
|
||||
│ │ ├── detail_page.py
|
||||
│ │ ├── game_card.py
|
||||
│ │ ├── library_controls.py
|
||||
│ │ └── virtual_keyboard.py
|
||||
│ ├── config/ # Application configuration
|
||||
│ │ ├── base.py
|
||||
│ │ ├── cache.py
|
||||
│ │ ├── display.py
|
||||
│ │ ├── favorites.py
|
||||
│ │ ├── game.py
|
||||
│ │ ├── gamepad.py
|
||||
│ │ ├── portproton.py
|
||||
│ │ ├── proxy.py
|
||||
│ │ ├── ui.py
|
||||
│ │ ├── validators.py
|
||||
│ │ └── window.py
|
||||
│ ├── debug_utils/ # Debug data collection and processing
|
||||
│ │ ├── debug_log_manager.py
|
||||
│ │ ├── env_utils.py
|
||||
│ │ ├── game_debug.py
|
||||
│ │ ├── gpu_info.py
|
||||
│ │ ├── log_processor.py
|
||||
│ │ ├── system_info.py
|
||||
│ │ └── xorg_utils.py
|
||||
│ ├── detail_pages/ # Game detail page components
|
||||
│ │ ├── utils.py
|
||||
│ │ └── widgets.py
|
||||
│ ├── dialogs/ # Application dialogs
|
||||
│ │ ├── appimage_update.py
|
||||
│ │ ├── base.py
|
||||
│ │ ├── compatibility_report.py
|
||||
│ │ ├── dialog_utils.py
|
||||
│ │ ├── file_explorer.py
|
||||
│ │ ├── prefix_backup.py
|
||||
│ │ ├── proton_manager.py
|
||||
│ │ ├── settings_dialog.py
|
||||
│ │ ├── settings_gamescope.py
|
||||
│ │ ├── settings_mangohud.py
|
||||
│ │ ├── settings_vkbasalt.py
|
||||
│ │ ├── wine_downloader.py
|
||||
│ │ ├── wine_extractor.py
|
||||
│ │ ├── wine_loader.py
|
||||
│ │ └── winetricks_dialog.py
|
||||
│ ├── input_manager/ # Keyboard and gamepad input routing
|
||||
│ │ ├── buttons.py
|
||||
│ │ ├── constants.py
|
||||
│ │ ├── dialog_modes.py
|
||||
│ │ ├── dpad.py
|
||||
│ │ ├── file_explorer.py
|
||||
│ │ ├── keyboard.py
|
||||
│ │ ├── mixin.py
|
||||
│ │ ├── runtime.py
|
||||
│ │ ├── settings.py
|
||||
│ │ └── settings_visual.py
|
||||
│ ├── scripts_utils/ # Helpers used by PortProton scripts
|
||||
│ │ ├── dbus_tools.py
|
||||
│ │ ├── easyterm.py
|
||||
│ │ ├── graphics_detector.py
|
||||
│ │ ├── idle_inhibit.py
|
||||
│ │ ├── json_tools.py
|
||||
│ │ ├── mount_points.py
|
||||
│ │ ├── power_profiles.py
|
||||
│ │ ├── prefix_backup.py
|
||||
│ │ └── shortcut_tools.py
|
||||
│ ├── steam_api/ # Steam API, cache, and shortcuts
|
||||
│ │ ├── api.py
|
||||
│ │ ├── cache.py
|
||||
│ │ ├── shortcuts.py
|
||||
│ │ └── utils.py
|
||||
│ ├── system_manager/ # System settings backends
|
||||
│ │ ├── audio.py
|
||||
│ │ ├── bluetooth.py
|
||||
│ │ ├── common.py
|
||||
│ │ ├── network.py
|
||||
│ │ └── storage.py
|
||||
│ ├── tabs/ # Main window tabs and workers
|
||||
│ │ ├── autoinstall_tab.py
|
||||
│ │ ├── control_hints.py
|
||||
│ │ ├── download_tab.py
|
||||
│ │ ├── library_tab.py
|
||||
│ │ ├── settings_tab.py
|
||||
│ │ ├── system_tab.py
|
||||
│ │ ├── theme_store.py
|
||||
│ │ ├── theme_store_workers.py
|
||||
│ │ ├── theme_tab.py
|
||||
│ │ ├── wine_tab.py
|
||||
│ │ └── workers.py
|
||||
│ ├── locales/ # Translation catalogs
|
||||
│ ├── terminal_schemes/ # Terminal color schemes
|
||||
│ ├── themes/ # Built-in themes and assets
|
||||
│ ├── app.py # Entry point
|
||||
│ ├── game_card.py # Game card widget
|
||||
│ ├── gog_api.py # GOG integration
|
||||
│ ├── main_window.py # Main window
|
||||
│ ├── native_gamepad.py # Native SDL3 gamepad backend
|
||||
│ ├── portproton_api.py # PortProton integration
|
||||
│ ├── theme_manager.py # Theme management
|
||||
│ └── logger.py # Logging
|
||||
├── tests/ # Unit tests
|
||||
├── build-aux/ # Build resources
|
||||
├── dev-scripts/ # Development scripts
|
||||
├── documentation/ # Documentation
|
||||
├── meson.build # Meson config
|
||||
├── pyproject.toml # Python config
|
||||
└── .pre-commit-config.yaml
|
||||
```
|
||||
|
||||
@@ -914,6 +1058,8 @@ PortProtonQt/
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Use the existing `orjson` dependency instead of standard-library `json` for faster JSON parsing and serialization. For formatted output, use `orjson.OPT_INDENT_2`.
|
||||
|
||||
### Core
|
||||
|
||||
| Package | Purpose | License |
|
||||
@@ -940,12 +1086,10 @@ PortProtonQt/
|
||||
|
||||
- [Theme documentation](documentation/theme_guide)
|
||||
- [Localization guide](documentation/localization_guide)
|
||||
- [Metadata override guide](documentation/metadata_override)
|
||||
- [TODO list](TODO.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2026-07-20
|
||||
**Version:** 1.2
|
||||
**Last updated:** 2026-08-11
|
||||
**Version:** 1.3
|
||||
**Status:** Release
|
||||
+218
@@ -5,21 +5,218 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен CLI для получения локальных данных библиотеки
|
||||
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
|
||||
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
|
||||
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
|
||||
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
|
||||
- Добавлена возможность добавлять установленные игры GOG и EGS в Steam
|
||||
- Добавлен массовый импорт игр из выбранной папки
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: пробел включает и отменяет режим с очисткой выделения, ЛКМ и кнопка A геймпада переключают выделение карточек
|
||||
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
|
||||
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
|
||||
- Добавлено предупреждение о кириллице в путях к игре или PortProton
|
||||
- Добавлен резервный запрет сна через logind/elogind при недоступности ScreenSaver и портала, в том числе для дистрибутивов без systemd
|
||||
|
||||
### Changed
|
||||
|
||||
- В Run After / Wrapper выбор файлов ограничен `.exe` и файлами с правом исполнения в Linux
|
||||
- В режиме CLOUD данные ProtonDB и ссылки на обложки SteamGridDB загружаются через `deckyloader.ru`
|
||||
- Повторный запуск с `--silent` теперь запрашивает подтверждение: при согласии текущий PortProtonQt и запущенные игры завершаются, затем приложение запускается заново для новой игры: при отказе новый запуск полностью отменяется, текущая сессия продолжает работать
|
||||
- Найденные Steam AppID сохраняются в кеше, чтобы при повторной загрузке библиотеки пропускать чтение метаданных EXE и поиск по каталогу Steam
|
||||
- При определении языка Steam_EMU теперь учитывается переменная `LANGUAGE`
|
||||
- При возврате из карточки игры библиотека сохраняет прокрутку и фокус на этой карточке
|
||||
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
|
||||
- После добавления или удаления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог
|
||||
- В скриптах PortProton ускорено чтение метаданных exe с помощью pefile
|
||||
- Разбор ответа PPDB в диалоге отчёта и запись форматированного JSON-кеша переведены на orjson
|
||||
- Настройки exe и `user.conf` применяются автоматически при изменении
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
|
||||
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
|
||||
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
|
||||
- Исправлено зависание при смене сетки библиотеки
|
||||
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
|
||||
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
|
||||
- При включённом фильтре установленных игр метаданные GOG и EGS загружаются только для установленных игр
|
||||
- Исправлено отображение хвостиков источника игр в вертикальном режиме библиотеки
|
||||
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
|
||||
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
|
||||
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
|
||||
- Наведение на карточки больше не перехватывает фокус из строки поиска при вводе текста
|
||||
- Исправлена ошибка форматирования даты последнего запуска и времени игры при системной локали `C` или `POSIX`
|
||||
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
|
||||
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
|
||||
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
- Исправлено обрезание значений в избранных настройках
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен поиск настроек игры вводом с клавиатуры без предварительного выбора поля поиска
|
||||
- Добавлена навигация геймпадом к кнопкам под таблицами
|
||||
- Добавлена настройка скрытия курсора в полноэкранном режиме, включённая по умолчанию
|
||||
- Добавлена настройка отключения поиска установленных Proton в Steam для списков выбора версий и окна управления WINE/Proton
|
||||
- Добавлена возможность добавления загрузочной WebM-заставки из тем при переходе в fullscreen, включая автоматический переход при подключении геймпада
|
||||
- Добавлен переход к загрузке Wine/Proton из списка версий в настройках игры
|
||||
- Добавлены выбор и установка DLC при установке игр GOG
|
||||
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
|
||||
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
|
||||
- Добавлена настройка отключения проверки 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
|
||||
- Автоматическая загрузка PPDB с linux-gaming.ru теперь отключена по умолчанию
|
||||
- Кнопка «Открыть PPDB» переименована в «Редактировать PPDB», чтобы не путать открытие файла с его запуском
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Испанский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/`
|
||||
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
|
||||
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
|
||||
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
|
||||
- Открытие карточек и тихий запуск унифицированы
|
||||
- `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
|
||||
|
||||
---
|
||||
|
||||
## [1.4.1] - 2026-08-26
|
||||
|
||||
### Changed
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
|
||||
### Fixed
|
||||
- Произведён откат Steam Runtime Sniper до версии V3 из-за несовместимости GStreamer внутри Proton 10.x с SLR 4
|
||||
|
||||
### Contributors
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-08-23
|
||||
|
||||
### Added
|
||||
- Добавлен анализатор проблем в случае вылета Windows-программы
|
||||
- Добавлены анимированные и статические фоны детальной страницы `aurora`, `diagnostics`, `metaballs`, `leaf` и `veins`, вдохновлённые проектом omikuji
|
||||
- Добавлена интеграция GOG с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
|
||||
- Добавлена отдельная галочка `Only Installed`, сочетаемая с фильтрами источника и скрывающая неустановленные игры
|
||||
- Добавлено отображение иконок `.exe` вместо обложек на компактной детальной странице
|
||||
- Добавлено редактирование ярлыков Steam по AppID
|
||||
- Добавлены тихий и логируемый режимы запуска `.exe` через меню «Открыть с помощью»
|
||||
- Добавлена пользовательская интеграция AppImage
|
||||
- Добавлен однокнопочный установщик AppImage с загрузкой через Gitea или GitHub и автоматической интеграцией в систему
|
||||
- В игровые ярлыки добавлены действия для запуска в тихом режиме и с записью лога
|
||||
- Добавлен аргумент командной строки `--log` для запуска игры в режиме логирования
|
||||
- Добавлены звуки интерфейса
|
||||
- Добавлен выбор аккаунта Steam в настройках с автоматическим режимом по умолчанию и режимом `All` для добавления ярлыков всем пользователям
|
||||
- Добавлено безопасное чтение вариантов dark/light палитры matugen из Dank Material Shell
|
||||
- Добавлен поиск параметров MangoHud, vkBasalt и Gamescope по названиям и описаниям
|
||||
- Добавлена установка локальных архивов WINE/Proton перетаскиванием в библиотеку или менеджер версий
|
||||
- Добавлена кнопка открытия папки WINE в менеджере версий
|
||||
- Добавлена настройка для скрытия подсказок по управлению
|
||||
- Добавлена кнопка удаления пользовательских тем
|
||||
|
||||
### Changed
|
||||
- GitHub-зеркала [PortProtonQt](https://github.com/linux-gaming-ru/PortProtonQt) и [метаданных WINE/Proton](https://github.com/linux-gaming-ru/PortProtonQt-Wine-Metadata) перенесены в организацию `linux-gaming-ru`
|
||||
- Иконки системных уведомлений перенесены в темы и теперь соответствуют выбранной теме
|
||||
- Выбранное зеркало теперь используется для загрузки метаданных WINE/Proton, проверки обновлений AppImage и получения списка изменений
|
||||
- Обновлена ссылка на раздел помощи в логе ошибки
|
||||
- В AppImage добавлены `7z` для извлечения содержимого ISO и MDF-образов и `vk_gpu_info` для определения видеокарт Vulkan
|
||||
- Вертикальная навигация геймпадом в сетках настроек больше не переходит между столбцами
|
||||
- Удалена сборка для Fedora 42
|
||||
- Версия Windows по умолчанию изменена с 10 на 11
|
||||
- Обновлены все иконки подсказок управления
|
||||
- API и ссылки PPDB переведены с `ppdb.linux-gaming.ru` на `linux-gaming.ru`
|
||||
- Блок игровой статистики на полной детальной странице помещён в тематизируемый контейнер, как в компактном режиме
|
||||
- Кэш EXE-иконок унифицирован для карточек и детальных страниц
|
||||
@@ -33,6 +230,18 @@
|
||||
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
|
||||
|
||||
### Fixed
|
||||
- Настройки прокси теперь передаются `curl` при загрузке файлов скриптами PortProton
|
||||
- Устранена повторная перестройка индексов Steam и PPDB для каждой карточки в экономном режиме
|
||||
- Скрытие бейджей больше не скрывает ленту источника игры на карточках библиотеки
|
||||
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
|
||||
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
|
||||
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
|
||||
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
|
||||
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
|
||||
- Исправлено обрезание подсказок управления при переносе на вторую строку
|
||||
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
|
||||
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
|
||||
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
|
||||
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
|
||||
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
|
||||
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
|
||||
@@ -43,14 +252,23 @@
|
||||
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
|
||||
- Исправлено зависание интерфейса после завершения игры
|
||||
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
|
||||
- Распаковка WINE/Proton больше не меняет рабочий каталог приложения и отклоняет пути из архива, выходящие за каталог установки
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @Rubin13
|
||||
- @rogerio-mattos
|
||||
- @Vekt0R_null
|
||||
|
||||
---
|
||||
|
||||
## [1.3.2] - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
- Исправлено определение SteamID по `AutoLogin` или единственному профилю `userdata`, если в `loginusers.vdf` отсутствует `MostRecent`
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Name=Install PortProtonQt
|
||||
Name[ru]=Установить PortProtonQt
|
||||
Name[es]=Instalar PortProtonQt
|
||||
Comment=Download and install the latest PortProtonQt AppImage
|
||||
Comment[ru]=Скачать и установить последнюю версию PortProtonQt AppImage
|
||||
Exec=sh -c "(curl -fsSL https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/install.sh || curl -fsSL https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/main/install.sh) | sh"
|
||||
Icon=system-software-install
|
||||
StartupNotify=false
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=Game;
|
||||
@@ -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)
|
||||
@@ -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-5471-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton and Steam. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
<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&label=Release" alt="Releases"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-17.9k-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&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&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&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
@@ -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-5471-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск 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&label=Релиз" alt="Релизы"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-17.9k-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&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&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&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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -20,16 +19,12 @@ export OPTIMIZE_LAUNCH=1
|
||||
# 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
|
||||
|
||||
@@ -39,15 +34,28 @@ pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
|
||||
|
||||
# Deploy dependencies
|
||||
# Qt libs have to be passed manually due to the app being a python script
|
||||
./quick-sharun \
|
||||
GAMEPAD_LIBRARY=$(find /usr/lib -type f \
|
||||
-path '*/site-packages/portprotonqt/libportprotonqt_gamepad.so' \
|
||||
-print -quit)
|
||||
test -n "$GAMEPAD_LIBRARY"
|
||||
quick-sharun \
|
||||
/usr/bin/portprotonqt* \
|
||||
/usr/bin/bash \
|
||||
/usr/bin/update-desktop-database \
|
||||
/usr/bin/update-mime-database \
|
||||
/usr/bin/vk_gpu_info \
|
||||
"$GAMEPAD_LIBRARY" \
|
||||
/usr/lib/libQt6Core.so* \
|
||||
/usr/lib/libQt6Gui.so* \
|
||||
/usr/lib/libQt6Multimedia.so* \
|
||||
/usr/lib/libQt6Network.so* \
|
||||
/usr/lib/qt6/plugins/imageformats/libqwebp.so
|
||||
/usr/lib/qt6/plugins/imageformats/libqwebp.so \
|
||||
/usr/lib/7zip/7z*
|
||||
|
||||
# quick-sharun removes MIME package XML files during dependency deployment
|
||||
mkdir -p ./AppDir/share/mime/packages
|
||||
cp /usr/share/mime/packages/ru.linux_gaming.PortProtonQt.xml \
|
||||
./AppDir/share/mime/packages
|
||||
|
||||
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
|
||||
# build/development content and PySide bindings not imported by PortProtonQt.
|
||||
@@ -90,4 +98,4 @@ done
|
||||
rm -rf "$PYSIDE_DIR"/QtAsyncio
|
||||
|
||||
# Turn AppDir into AppImage
|
||||
./quick-sharun --make-appimage
|
||||
quick-sharun --make-appimage
|
||||
+16
-16
@@ -1,16 +1,26 @@
|
||||
pkgname=('portprotonqt')
|
||||
pkgver=1.3.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"
|
||||
arch=('any')
|
||||
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"
|
||||
|
||||
+15
-15
@@ -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)"
|
||||
arch=('any')
|
||||
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"
|
||||
|
||||
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.3.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
|
||||
@@ -22,6 +22,7 @@ BuildRequires: git
|
||||
BuildRequires: gettext
|
||||
BuildRequires: systemd-rpm-macros
|
||||
BuildRequires: vulkan-loader-devel
|
||||
BuildRequires: pkgconfig(sdl3)
|
||||
BuildRequires: gcc
|
||||
|
||||
Obsoletes: python3-%{pypi_name}-git < %{version}-%{release}
|
||||
@@ -42,7 +43,6 @@ Requires: python3-pillow-qt
|
||||
Requires: python3-rapidfuzz
|
||||
Requires: python3-libarchive-c
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: SDL3
|
||||
Requires: qt6-qtsvg
|
||||
Requires: qt6-qtimageformats
|
||||
Requires: qt6-qtmultimedia
|
||||
@@ -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}
|
||||
|
||||
@@ -120,7 +120,6 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
|
||||
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
|
||||
%{_datadir}/portproton/scripts/
|
||||
%{_datadir}/portproton/conf/
|
||||
%{_datadir}/portproton/img/
|
||||
%{bash_completions_dir}/portprotonqt
|
||||
%{fish_completions_dir}/portprotonqt.fish
|
||||
%{zsh_completions_dir}/_portprotonqt
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.3.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
|
||||
@@ -19,6 +19,7 @@ BuildRequires: git
|
||||
BuildRequires: gettext
|
||||
BuildRequires: systemd-rpm-macros
|
||||
BuildRequires: vulkan-loader-devel
|
||||
BuildRequires: pkgconfig(sdl3)
|
||||
BuildRequires: gcc
|
||||
|
||||
Obsoletes: python3-%{pypi_name} < %{version}-%{release}
|
||||
@@ -39,7 +40,6 @@ Requires: python3-pillow-qt
|
||||
Requires: python3-rapidfuzz
|
||||
Requires: python3-libarchive-c
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: SDL3
|
||||
Requires: qt6-qtsvg
|
||||
Requires: qt6-qtimageformats
|
||||
Requires: qt6-qtmultimedia
|
||||
@@ -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}
|
||||
|
||||
@@ -118,7 +118,6 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
|
||||
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
|
||||
%{_datadir}/portproton/scripts/
|
||||
%{_datadir}/portproton/conf/
|
||||
%{_datadir}/portproton/img/
|
||||
%{bash_completions_dir}/portprotonqt
|
||||
%{fish_completions_dir}/portprotonqt.fish
|
||||
%{zsh_completions_dir}/_portprotonqt
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -2,13 +2,13 @@
|
||||
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
|
||||
Categories=Game;Utility;
|
||||
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-executable;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds-disk;application/x-nrg;
|
||||
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds;application/x-nrg;
|
||||
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;
|
||||
NoDisplay=true
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;
|
||||
NoDisplay=true
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -5,15 +5,15 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<summary>Modern GUI for managing and launching games from PortProton and Steam</summary>
|
||||
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton и Steam</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 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.
|
||||
</p>
|
||||
<p xml:lang="ru">
|
||||
Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
|
||||
Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
@@ -24,9 +24,21 @@
|
||||
</developer>
|
||||
|
||||
<releases>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<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>
|
||||
<release version="1.3.2" date="2026-08-10">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.3.0" date="2026-07-13">
|
||||
<description/>
|
||||
</release>
|
||||
|
||||
@@ -10,4 +10,16 @@
|
||||
<comment xml:lang="ru">Скрипт автоустановки PortProton</comment>
|
||||
<glob pattern="*.ppai"/>
|
||||
</mime-type>
|
||||
<mime-type type="application/x-portproton-ppdb">
|
||||
<comment>PortProton configuration</comment>
|
||||
<comment xml:lang="ru">Конфигурация PortProton</comment>
|
||||
<sub-class-of type="text/plain"/>
|
||||
<glob pattern="*.ppdb" weight="100"/>
|
||||
</mime-type>
|
||||
<mime-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>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@@ -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 () {
|
||||
@@ -135,8 +158,10 @@ rm_from_array () {
|
||||
}
|
||||
|
||||
fatal () {
|
||||
#TODO
|
||||
pw_notify_send -i error "$@"
|
||||
if [[ -z "$*" ]] ; then
|
||||
set -- "Fatal error"
|
||||
fi
|
||||
python_module dbus_tools notify -i error "$@" &>/dev/null
|
||||
print_error "$@"
|
||||
stop_portproton
|
||||
exit 1
|
||||
@@ -152,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"
|
||||
}
|
||||
|
||||
@@ -459,6 +484,26 @@ try_download () {
|
||||
no_mirror=true
|
||||
esac
|
||||
|
||||
local curl_proxy_args=()
|
||||
local _pp_conf=""
|
||||
if [[ -n "${XDG_CONFIG_HOME:-}" ]] \
|
||||
&& [[ -f "$XDG_CONFIG_HOME/PortProtonQt.conf" ]] ; then
|
||||
_pp_conf="$XDG_CONFIG_HOME/PortProtonQt.conf"
|
||||
elif [[ -f "$HOME/.config/PortProtonQt.conf" ]] ; then
|
||||
_pp_conf="$HOME/.config/PortProtonQt.conf"
|
||||
fi
|
||||
local _pp_url="" _pp_user="" _pp_pass=""
|
||||
if [[ -n "$_pp_conf" ]] ; then
|
||||
_pp_url="$(grep "proxy_url" "$_pp_conf" | awk -F"= " '{print $2}')"
|
||||
if [[ -n "$_pp_url" ]] ; then
|
||||
curl_proxy_args=(-x "$_pp_url")
|
||||
_pp_user="$(grep "proxy_user" "$_pp_conf" | awk -F"= " '{print $2}')"
|
||||
_pp_pass="$(grep "proxy_password" "$_pp_conf" | awk -F"= " '{print $2}')"
|
||||
[[ -n "$_pp_user" && -n "$_pp_pass" ]] \
|
||||
&& curl_proxy_args+=(-u "$_pp_user:$_pp_pass")
|
||||
fi
|
||||
fi
|
||||
|
||||
local filename
|
||||
if [[ -n "${PW_AUTOINSTALL_EXE}" ]] \
|
||||
&& [[ "$no_mirror" == "true" ]]
|
||||
@@ -482,7 +527,7 @@ try_download () {
|
||||
if check_gamescope_session ; then
|
||||
$PW_TERM "echo ; echo ; echo \"Downloading: $filename. Please wait...\" \
|
||||
; curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\""
|
||||
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\" ${curl_proxy_args[*]}"
|
||||
|
||||
|
||||
[[ "$?" != 0 ]] && return 1 || return 0
|
||||
@@ -491,10 +536,10 @@ try_download () {
|
||||
# Normal download
|
||||
print_info "Download $filename from ${FIRST_URL[0]}..."
|
||||
if [[ "$silent" == "true" ]] ; then
|
||||
curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
curl "${curl_proxy_args[@]}" -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1
|
||||
else
|
||||
curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
curl "${curl_proxy_args[@]}" -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1 \
|
||||
| tr '\r' '\n' | sed -ur 's|[# ]+||g;s|100||g;s|0-||g;s|.*=.*||g'
|
||||
|
||||
@@ -543,48 +588,6 @@ var_ld_library_path_update () {
|
||||
fi
|
||||
}
|
||||
|
||||
# GUI NOTIFY SEND
|
||||
pw_notify_send () {
|
||||
local icon title body app timeout OPTIND
|
||||
app="ru.linux_gaming.PortProtonQt"
|
||||
timeout="5000"
|
||||
OPTIND="1"
|
||||
while getopts a:i:t: opt ; do
|
||||
case "$opt" in
|
||||
a)
|
||||
app=$OPTARG
|
||||
;;
|
||||
i)
|
||||
case "$OPTARG" in
|
||||
info) local OPTARG="${PORT_IMG_PATH}/notify/info.svg";;
|
||||
warning) local OPTARG="${PORT_IMG_PATH}/notify/warning.svg";;
|
||||
error) local OPTARG="${PORT_IMG_PATH}/notify/error.svg";;
|
||||
esac
|
||||
icon=$OPTARG
|
||||
;;
|
||||
t)
|
||||
timeout=$OPTARG
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0
|
||||
[-a] name application (PortProtonQt default)
|
||||
[-i] info, warning, error (none default)
|
||||
[-t] timeout in ms (5000 default)" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shift $(( OPTIND - 1 ))
|
||||
[[ "$1" == "--" ]] && shift
|
||||
title="$1" ; shift
|
||||
body="$*"
|
||||
|
||||
python_module dbus_tools notify \
|
||||
-a "$app" -i "$icon" -t "$timeout" -- "$title" "$body" &>/dev/null
|
||||
return 0
|
||||
}
|
||||
export -f pw_notify_send
|
||||
|
||||
check_gamescope_session () {
|
||||
if [[ -n "$GAMESCOPE_IN_USE" ]]
|
||||
then [[ "$GAMESCOPE_IN_USE" == 1 ]] && return 0 || return 1
|
||||
@@ -1084,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"
|
||||
@@ -1210,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
|
||||
@@ -1301,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"
|
||||
}
|
||||
@@ -1510,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
|
||||
@@ -1636,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" \
|
||||
@@ -1664,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" \
|
||||
@@ -1692,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
|
||||
@@ -1839,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
|
||||
@@ -2001,7 +2018,7 @@ pw_check_vulkan_extensions () {
|
||||
local vk_gpu_info_cmd
|
||||
ext_key="supports_$(echo "${ext_name}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../../dev-scripts/vk_gpu_info"
|
||||
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../bin/vk_gpu_info"
|
||||
if [[ -x "${vk_gpu_info_from_repo}" ]] ; then
|
||||
vk_gpu_info_cmd="${vk_gpu_info_from_repo}"
|
||||
else
|
||||
@@ -2194,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
|
||||
|
||||
@@ -2206,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/')
|
||||
@@ -2217,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
|
||||
@@ -2227,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"
|
||||
@@ -2242,9 +2272,14 @@ manage_user_conf_value () {
|
||||
|
||||
use_exiftool () {
|
||||
if [[ ! -f "${PW_TMPFS_PATH}/exiftool.tmp" ]] \
|
||||
|| ! grep -q "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
|| ! grep -Fqx -- "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
echo "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
printf '%s\n' "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
if [[ "${1,,}" == *.exe ]] \
|
||||
&& python_module exe_metadata "$1" >> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
if ! command -v exiftool &>/dev/null \
|
||||
|| ! timeout 3 exiftool "$1" &>> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
@@ -2311,7 +2346,7 @@ pw_create_gui_png () {
|
||||
pw_create_unique_exe () {
|
||||
BASEDIR_GAME="$(dirname "$PW_EXE_FILE")"
|
||||
if [[ -d "$BASEDIR_GAME" ]] ; then
|
||||
pushd "$BASEDIR_GAME" || fatal
|
||||
pushd "$BASEDIR_GAME" || fatal "Failed to enter game directory: $BASEDIR_GAME"
|
||||
BASENAME_GAME_EXE="$(basename "$PW_EXE_FILE")"
|
||||
if [[ -n "$1" ]] ; then
|
||||
BASENAME_GAME="$(basename "$1" .exe).exe"
|
||||
@@ -2324,7 +2359,7 @@ pw_create_unique_exe () {
|
||||
print_warning "There are no arguments for creating a symbolic link! Skip it..."
|
||||
fi
|
||||
try_remove_file "${PW_EXE_FILE}.ppdb"
|
||||
popd || fatal
|
||||
popd || fatal "Failed to return from game directory: $BASEDIR_GAME"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -2589,21 +2624,16 @@ pw_enable_gamemode_basic () {
|
||||
if check_flatpak ; then
|
||||
export GAMEMODERUN=1
|
||||
PW_GAMEMODERUN_SLR="gamemoderun"
|
||||
elif command -v gamemoderun &>/dev/null ; then
|
||||
export GAMEMODERUN=1
|
||||
PW_GAMEMODERUN_SLR="gamemoderun"
|
||||
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
|
||||
gamemoded &>/dev/null &
|
||||
sleep 0.1
|
||||
fi
|
||||
else
|
||||
export GAMEMODERUN=1
|
||||
if [[ -n "${PW_LD_PRELOAD}" ]]; then
|
||||
export PW_LD_PRELOAD="${PW_LD_PRELOAD}:libgamemodeauto.so.0"
|
||||
else
|
||||
export PW_LD_PRELOAD="libgamemodeauto.so.0"
|
||||
fi
|
||||
if ! pw_is_process_active gamemoded ; then
|
||||
local GAMEMODEAUTO_NAME="libgamemodeauto.so.0"
|
||||
export PW_LD_PRELOAD="${GAMEMODEAUTO_NAME}${PW_LD_PRELOAD:+:$PW_LD_PRELOAD}"
|
||||
if command -v gamemoderun &>/dev/null ; then
|
||||
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
|
||||
gamemoded &>/dev/null &
|
||||
sleep 0.1
|
||||
fi
|
||||
elif ! pw_is_process_active gamemoded ; then
|
||||
env LD_LIBRARY_PATH="${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32" \
|
||||
"${PW_PLUGINS_PATH}/portable/bin/gamemoded" &>/dev/null &
|
||||
sleep 0.1
|
||||
@@ -2626,6 +2656,10 @@ start_portproton () {
|
||||
pw_check_and_download_dxvk_and_vkd3d
|
||||
|
||||
pw_skip_get_info
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
|
||||
[[ -z "${PW_USE_NVAPI_AND_DLSS:-}" ]] && export PW_USE_NVAPI_AND_DLSS="1"
|
||||
[[ -z "${PW_USE_RAY_TRACING:-}" ]] && export PW_USE_RAY_TRACING="1"
|
||||
fi
|
||||
if [[ "${PW_LOCALE_SELECT}" != "disabled" ]] && [[ -n "${PW_LOCALE_SELECT}" ]] ; then
|
||||
export LC_ALL="${PW_LOCALE_SELECT}"
|
||||
if [[ "${PW_USE_RUNTIME}" == "1" ]] && [[ "${HOST_LC_ALL}" != "${LC_ALL}" ]] ; then
|
||||
@@ -2895,7 +2929,9 @@ start_portproton () {
|
||||
|
||||
# export WINE_HIDE_AMD_GPU="1"
|
||||
export DXVK_NVAPI_DRIVER_VERSION="99999"
|
||||
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
|
||||
if [[ $(check_vendor_gpu) != "nvidia" ]] ; then
|
||||
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
|
||||
fi
|
||||
export PW_USE_NVAPI_AND_DLSS="1"
|
||||
export PW_USE_RAY_TRACING="1"
|
||||
;;
|
||||
@@ -3014,7 +3050,7 @@ start_portproton () {
|
||||
elif [[ "$PW_USE_GAMEMODE" = "1" ]] \
|
||||
&& [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]
|
||||
then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn ; then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn minazuki ; then
|
||||
export GAMEMODERUN=0
|
||||
export PW_GAMEMODERUN_SLR=""
|
||||
local current_power_profile
|
||||
@@ -3226,7 +3262,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}"
|
||||
@@ -3462,10 +3498,17 @@ start_portproton () {
|
||||
|
||||
if [[ "${PW_USE_D7VK}" == "1" ]] ; then
|
||||
PATH_TO_D7VK_FILES="${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}"
|
||||
print_info "D7VK ${D7VK_VAR_VER} in use"
|
||||
D7VK_FILE="${PATH_TO_D7VK_FILES}/x32/ddraw.dll"
|
||||
if [[ "$PW_USE_SUPPLIED_DXVK_VKD3D" == "2" ]] \
|
||||
&& [[ -f "${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll" ]] ; then
|
||||
D7VK_FILE="${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll"
|
||||
print_info "Bundled D7VK in use"
|
||||
else
|
||||
print_info "D7VK ${D7VK_VAR_VER} in use"
|
||||
fi
|
||||
print_info "Try create symlink D7VK files..."
|
||||
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw_.dll"
|
||||
try_force_link_file "${PATH_TO_D7VK_FILES}/x32/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
try_force_link_file "${D7VK_FILE}" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
var_winedlloverride_update "ddraw=n,b"
|
||||
else
|
||||
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
@@ -3754,16 +3797,21 @@ start_portproton () {
|
||||
*) get_and_set_reg_file --delete 'Software\Wine\Drivers' 'Audio' ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${PW_NETWORK_BLOCKLIST:-}" ]] ; then
|
||||
export PW_LD_PRELOAD="${PW_LD_PRELOAD:+${PW_LD_PRELOAD}:}libppqt-network-filter.so"
|
||||
fi
|
||||
|
||||
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
|
||||
@@ -3800,6 +3848,7 @@ start_portproton () {
|
||||
}
|
||||
|
||||
pw_run () {
|
||||
local pw_game_exit_code
|
||||
if [[ "$PW_USE_TERMINAL" == 1 ]] \
|
||||
&& [[ "${PRESSURE_VESSEL_TERMINAL:-}" != "tty" ]]
|
||||
then
|
||||
@@ -3866,6 +3915,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
|
||||
@@ -3886,6 +3936,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
|
||||
@@ -3915,6 +3966,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:"
|
||||
@@ -3935,9 +3987,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
|
||||
|
||||
@@ -3957,6 +4012,25 @@ pw_launch_wrapper () {
|
||||
return
|
||||
fi
|
||||
|
||||
local -a wrapper_scripts=()
|
||||
local wrapper_script
|
||||
local wrapper_scripts_valid=1
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == *","* ]] ; then
|
||||
IFS=',' read -r -a wrapper_scripts <<< "${PW_RUN_AFTER_EXE}"
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
if [[ ! -f "${wrapper_script}" || "${wrapper_script,,}" == *.exe ]] ; then
|
||||
wrapper_scripts_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ "${wrapper_scripts_valid}" == 1 ]] ; then
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
"${wrapper_script}"
|
||||
done
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local -a wrapper_cmd
|
||||
eval "wrapper_cmd=(${PW_RUN_AFTER_EXE})"
|
||||
if [[ ${#wrapper_cmd[@]} -eq 0 ]] ; then
|
||||
@@ -4093,6 +4167,7 @@ steamplay_launch () {
|
||||
fi
|
||||
|
||||
print_info "$proton_runner in use."
|
||||
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
|
||||
pw_launch_wrapper \
|
||||
${PW_RUN_GAMESCOPE} \
|
||||
env \
|
||||
@@ -4102,6 +4177,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 ))
|
||||
@@ -4118,6 +4194,7 @@ portwine_launch () {
|
||||
fi
|
||||
|
||||
[[ $PW_LOG != 1 ]] && debug_timer --start -s "PW_TIME_IN_GAME"
|
||||
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
|
||||
if [[ -n "${LAUNCH_URI}" ]] ; then
|
||||
export WINEFSYNC="1"
|
||||
pw_run start /high /b "${LAUNCH_URI}" &
|
||||
@@ -4135,15 +4212,27 @@ portwine_launch () {
|
||||
fi
|
||||
done
|
||||
else
|
||||
local -a run_after_exes=() run_after_environment=()
|
||||
local run_after_entry run_after_index
|
||||
local run_after_valid=1
|
||||
if [[ -f "${PW_RUN_AFTER_EXE:-}" ]] ; then
|
||||
run_after_exes=("${PW_RUN_AFTER_EXE}")
|
||||
else
|
||||
IFS=',' read -r -a run_after_exes <<< "${PW_RUN_AFTER_EXE:-}"
|
||||
fi
|
||||
for run_after_entry in "${run_after_exes[@]}" ; do
|
||||
if [[ ! -f "${run_after_entry}" || "${run_after_entry,,}" != *.exe ]] ; then
|
||||
run_after_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]] \
|
||||
&& [[ "${run_after_valid}" == 1 ]] \
|
||||
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
|
||||
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
|
||||
then
|
||||
local wait_delay wait_delay_int run_after_bat
|
||||
local run_after_dir run_after_name
|
||||
local pw_exe_file_win
|
||||
local pw_exe_file_win run_after_exe_win
|
||||
local wine_drive_c
|
||||
wait_delay="${PW_RUN_AFTER_DELAY:-3}"
|
||||
wait_delay_int="${wait_delay%%.*}"
|
||||
@@ -4162,17 +4251,49 @@ portwine_launch () {
|
||||
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
|
||||
fi
|
||||
pw_exe_file_win="${pw_exe_file_win//\//\\}"
|
||||
local PW_RUN_AFTER_EXE="${run_after_exes[0]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
|
||||
run_after_exe_win="c:\\${PW_RUN_AFTER_EXE#"${wine_drive_c}/"}"
|
||||
else
|
||||
run_after_exe_win="z:${PW_RUN_AFTER_EXE//\//\\}"
|
||||
fi
|
||||
fi
|
||||
run_after_exe_win="${run_after_exe_win//\//\\}"
|
||||
|
||||
cat > "${run_after_bat}" <<EOF
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
start "" "${pw_exe_file_win}" ${LAUNCH_PARAMETERS}
|
||||
start "" "%PW_RUN_AFTER_MAIN_PATH%" ${LAUNCH_PARAMETERS}
|
||||
timeout /t ${wait_delay_int} /nobreak
|
||||
start "" /unix "${PW_RUN_AFTER_EXE}"
|
||||
start "" "%PW_RUN_AFTER_SECOND_PATH%"
|
||||
EOF
|
||||
|
||||
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
local run_after_first_win="${run_after_exe_win}"
|
||||
for ((run_after_index=1; run_after_index<${#run_after_exes[@]}; run_after_index++)) ; do
|
||||
run_after_entry="${run_after_exes[run_after_index]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${run_after_entry}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${run_after_entry}" == "${wine_drive_c}/"* ]] ; then
|
||||
run_after_exe_win="c:\\${run_after_entry#"${wine_drive_c}/"}"
|
||||
else
|
||||
run_after_exe_win="z:${run_after_entry//\//\\}"
|
||||
fi
|
||||
fi
|
||||
run_after_environment+=("PW_RUN_AFTER_PATH_${run_after_index}=${run_after_exe_win//\//\\}")
|
||||
printf 'start "" "%%PW_RUN_AFTER_PATH_%s%%"\r\n' "${run_after_index}" >> "${run_after_bat}"
|
||||
done
|
||||
|
||||
(
|
||||
if [[ ${#run_after_environment[@]} -gt 0 ]] ; then
|
||||
export "${run_after_environment[@]}"
|
||||
fi
|
||||
PW_RUN_AFTER_EXE="" LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_first_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
)
|
||||
try_remove_file "${run_after_bat}"
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
@@ -4217,8 +4338,13 @@ gui_proton_downloader () {
|
||||
return 0
|
||||
fi
|
||||
|
||||
METADATA_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
METADATA=$(curl -A 'PortProton' -s "$METADATA_URL")
|
||||
METADATA_CLOUD_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
METADATA_GITHUB_URL="https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt-Wine-Metadata/main/wine_metadata_all.json"
|
||||
if [[ "${MIRROR}" == CLOUD ]] ; then
|
||||
METADATA=$(curl -fL -A 'PortProton' -s "$METADATA_CLOUD_URL")
|
||||
else
|
||||
METADATA=$(curl -fL -s "$METADATA_GITHUB_URL")
|
||||
fi
|
||||
if [[ -z "$METADATA" ]] ; then
|
||||
fatal "failed to fetch metadata"
|
||||
fi
|
||||
@@ -4363,7 +4489,9 @@ gui_edit_db () {
|
||||
|
||||
edit_db_from_gui "${PW_EDIT_DB_FINAL_LIST[@]}"
|
||||
|
||||
if [[ -z "$MANGOHUD_CONFIG" ]] ; then
|
||||
if [[ "${PW_MANGOHUD}" == "1" ]] \
|
||||
&& [[ "${PW_MANGOHUD_USER_CONF}" != "1" ]] \
|
||||
&& [[ -z "$MANGOHUD_CONFIG" ]] ; then
|
||||
MONITOR_HEIGHT="$(echo "$PW_SCREEN_RESOLUTION" | awk -F'x' '{print $2}')"
|
||||
MH_FONT_SIZE="font_size=$(( MONITOR_HEIGHT / 45 ))"
|
||||
|
||||
@@ -4907,7 +5035,7 @@ pw_create_prefix_backup_cli () {
|
||||
PW_PREFIX_NAME="$1"
|
||||
PW_PREFIX_TO_BACKUP="$2"
|
||||
|
||||
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
backup "${PW_PREFIX_NAME}" "${PW_PREFIX_TO_BACKUP}"
|
||||
return $?
|
||||
}
|
||||
@@ -4961,7 +5089,7 @@ pw_unpack_prefix () {
|
||||
return $?
|
||||
fi
|
||||
|
||||
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
restore "${PW_BACKUP_FILE}"
|
||||
return $?
|
||||
}
|
||||
Executable → Regular
File mode changed.
Executable → Regular
File mode changed.
@@ -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,13 +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_IMG_PATH="$(dirname "$PORT_SCRIPTS_PATH")/img"
|
||||
PORT_CONF_PATH="$(dirname "$PORT_SCRIPTS_PATH")/conf"
|
||||
|
||||
if [[ -z "$PORT_DATA_PATH" ]] ; then
|
||||
@@ -55,7 +61,7 @@ else
|
||||
export PW_FIND_PPDB=1
|
||||
fi
|
||||
|
||||
export PORT_SCRIPTS_PATH PORT_IMG_PATH PORT_DATA_PATH PORT_CONF_PATH
|
||||
export PORT_SCRIPTS_PATH PORT_DATA_PATH PORT_CONF_PATH
|
||||
export PW_LOG_FILE="${PORT_DATA_PATH}/PortProton.log"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
@@ -148,7 +154,7 @@ create_new_dir "${PORT_DATA_PATH}/data/prefixes/DEFAULT"
|
||||
create_new_dir "${PORT_DATA_PATH}/data/prefixes/DOTNET"
|
||||
try_force_link_dir "${PORT_DATA_PATH}/data/prefixes" "${PORT_DATA_PATH}"
|
||||
|
||||
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal
|
||||
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal "Failed to enter prefixes directory"
|
||||
for pfx_dir in ./* ; do
|
||||
[[ -d "$pfx_dir" ]] || continue
|
||||
pfx_dir_new="${pfx_dir//[[:blank:]]/_}"
|
||||
@@ -156,7 +162,7 @@ for pfx_dir in ./* ; do
|
||||
mv -- "${PORT_DATA_PATH}/data/prefixes/$pfx_dir" "${PORT_DATA_PATH}/data/prefixes/${pfx_dir_new^^}"
|
||||
fi
|
||||
done
|
||||
popd 1>/dev/null || fatal
|
||||
popd 1>/dev/null || fatal "Failed to return from prefixes directory"
|
||||
|
||||
create_new_dir "${PORT_WINE_TMP_PATH}"/gecko
|
||||
create_new_dir "${PORT_WINE_TMP_PATH}"/mono
|
||||
@@ -164,12 +170,12 @@ create_new_dir "${PORT_WINE_TMP_PATH}"/mono
|
||||
export PW_VULKAN_DIR="${PORT_WINE_TMP_PATH}/VULKAN"
|
||||
create_new_dir "${PW_VULKAN_DIR}"
|
||||
|
||||
cd "${PORT_SCRIPTS_PATH}" || fatal
|
||||
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
|
||||
cd "${XDG_DATA_HOME:-$HOME}" || fatal "Data directory not found: ${XDG_DATA_HOME:-$HOME}"
|
||||
|
||||
[[ ! -f "$VKBASALT_CONFIG_FILE" ]] && cp -f "${PORT_CONF_PATH}/vkBasalt.conf" "$VKBASALT_CONFIG_FILE"
|
||||
[[ ! -f "$DXVK_CONFIG_FILE" ]] && cp -f "${PORT_CONF_PATH}/dxvk.conf" "$DXVK_CONFIG_FILE"
|
||||
@@ -216,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 () {
|
||||
@@ -267,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
|
||||
@@ -472,7 +478,7 @@ Usage examples:
|
||||
--winecmd)
|
||||
get_wine_and_pfx "$2" "$3"
|
||||
start_portproton
|
||||
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal
|
||||
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found"
|
||||
PW_USE_TERMINAL=1 pw_run cmd
|
||||
stop_portproton
|
||||
;;
|
||||
|
||||
@@ -39,7 +39,7 @@ check_variables PW_USE_NTSYNC "0"
|
||||
check_variables PW_USE_GAMEMODE "0"
|
||||
check_variables PW_USE_INHIBIT_SLEEP "1"
|
||||
check_variables PW_USE_TERMINAL "0"
|
||||
check_variables PW_WINDOWS_VER "10"
|
||||
check_variables PW_WINDOWS_VER "11"
|
||||
check_variables PW_USE_GSTREAMER "1"
|
||||
check_variables PW_USE_D3D_EXTRAS "1"
|
||||
check_variables PW_PREFIX_NAME "DEFAULT"
|
||||
@@ -62,7 +62,7 @@ check_variables PW_USE_GALLIUM_NINE "0"
|
||||
check_variables PW_USE_GALLIUM_ZINK "0"
|
||||
check_variables PW_USE_WINED3D_VULKAN "0"
|
||||
check_variables PW_USE_SUPPLIED_DXVK_VKD3D "1"
|
||||
check_variables PW_WINE_FULLSCREEN_FSR "1"
|
||||
check_variables PW_WINE_FULLSCREEN_FSR "0"
|
||||
check_variables WINE_FULLSCREEN_FSR_MODE "0"
|
||||
|
||||
###DXVK_AND_VKD3D###
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <stdbool.h>
|
||||
#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 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 false;
|
||||
}
|
||||
GamepadEntry *entries = realloc(
|
||||
gamepads->entries, sizeof(*entries) * (gamepads->count + 1));
|
||||
if (entries == NULL) {
|
||||
SDL_CloseGamepad(gamepad);
|
||||
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;
|
||||
}
|
||||
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)
|
||||
{
|
||||
SDL_ClearError();
|
||||
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
|
||||
return NULL;
|
||||
}
|
||||
PortProtonGamepad *result = calloc(1, sizeof(*result));
|
||||
if (result == NULL) {
|
||||
SDL_SetError("Failed to allocate gamepad collection");
|
||||
return NULL;
|
||||
}
|
||||
update_gamepads(result);
|
||||
if (result->count == 0) {
|
||||
free(result);
|
||||
SDL_ClearError();
|
||||
return NULL;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const char *portproton_gamepad_get_error(void)
|
||||
{
|
||||
const char *error = SDL_GetError();
|
||||
return error != NULL ? error : "";
|
||||
}
|
||||
|
||||
void portproton_gamepad_close(PortProtonGamepad *gamepad)
|
||||
{
|
||||
if (gamepad == NULL) {
|
||||
return;
|
||||
}
|
||||
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->count > 0;
|
||||
}
|
||||
|
||||
void portproton_gamepad_update(PortProtonGamepad *gamepad)
|
||||
{
|
||||
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)
|
||||
{
|
||||
const GamepadEntry *active = get_active_gamepad(gamepad);
|
||||
if (active == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return SDL_GetGamepadButton(active->gamepad, button);
|
||||
}
|
||||
|
||||
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
|
||||
{
|
||||
const GamepadEntry *active = get_active_gamepad(gamepad);
|
||||
if (active == NULL) {
|
||||
return 0;
|
||||
}
|
||||
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->count == 0) {
|
||||
return "";
|
||||
}
|
||||
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->count > 0
|
||||
? gamepad->entries[0].instance_id : 0;
|
||||
}
|
||||
|
||||
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
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)
|
||||
{
|
||||
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
|
||||
}
|
||||
File renamed without changes.
@@ -421,7 +421,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "call of duty black ops cold war",
|
||||
"status": "Broken",
|
||||
"status": "Running",
|
||||
"slug": "call-of-duty-black-ops-cold-war"
|
||||
},
|
||||
{
|
||||
@@ -1286,7 +1286,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "nikke goddess of victory",
|
||||
"status": "Broken",
|
||||
"status": "Running",
|
||||
"slug": "nikke"
|
||||
},
|
||||
{
|
||||
@@ -1899,6 +1899,11 @@
|
||||
"status": "Broken",
|
||||
"slug": "tekken-7-for-esl"
|
||||
},
|
||||
{
|
||||
"normalized_name": "rocket league",
|
||||
"status": "Supported",
|
||||
"slug": "rocket-league"
|
||||
},
|
||||
{
|
||||
"normalized_name": "rocket league (faceit)",
|
||||
"status": "Broken",
|
||||
@@ -3341,7 +3346,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "the crew motorfest",
|
||||
"status": "Broken",
|
||||
"status": "Supported",
|
||||
"slug": "the-crew-motorfest"
|
||||
},
|
||||
{
|
||||
@@ -3776,7 +3781,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "exoborne",
|
||||
"status": "Broken",
|
||||
"status": "Denied",
|
||||
"slug": "exoborne"
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
+87474
-3226
File diff suppressed because it is too large.
Load diff
Binary file not shown.
+1207
-22
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Vendored
+24
@@ -1,3 +1,27 @@
|
||||
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.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Wed, 26 Aug 2026 13:03:46 +0500
|
||||
|
||||
portprotonqt (1.4.0) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Sun, 23 Aug 2026 14:32:36 +0500
|
||||
|
||||
portprotonqt (1.3.2) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Mon, 10 Aug 2026 22:02:11 +0500
|
||||
|
||||
portprotonqt (1.3.1) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
Vendored
+1
@@ -5,6 +5,7 @@ Maintainer: Ivan Mazhukin <vanyamajukin@outlook.com>
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
gettext,
|
||||
libsdl3-dev,
|
||||
libvulkan-dev,
|
||||
meson,
|
||||
ninja-build,
|
||||
|
||||
@@ -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"
|
||||
@@ -18,8 +18,8 @@ _portprotonqt_completions() {
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# All available options
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
|
||||
# Values for options with arguments
|
||||
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -73,11 +73,19 @@ _portprotonqt() {
|
||||
'--add-steam-compat-tool[Add as Steam compatibility tool]'
|
||||
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
|
||||
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
|
||||
'--list-games[Print local game libraries as JSON]'
|
||||
'--game-info[Print one game as JSON]:ID:'
|
||||
'--edit-game[Edit game settings]:ID:'
|
||||
'*--set[Set a game setting]:KEY=VALUE:'
|
||||
'--all[Include uninstalled games in the library list]'
|
||||
'--fields[Select JSON fields]:FIELD,...:'
|
||||
'--pretty[Format JSON with indentation]'
|
||||
'--raw[Print compact JSON]'
|
||||
'--clear-cache[Clear PortProtonQt cache and exit]'
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
'--silent[Launch .exe file without GUI]'
|
||||
'--log[Enable PortProton logging]'
|
||||
'--silent[Launch Windows file or native game in tray]'
|
||||
'--log[Enable game logging]'
|
||||
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
|
||||
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
@@ -94,18 +102,26 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
|
||||
# Fish completion for portprotonqt
|
||||
|
||||
complete -c portprotonqt -f
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
|
||||
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
|
||||
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
complete -c portprotonqt -l add-steam-compat-tool -d "Add as Steam compatibility tool"
|
||||
complete -c portprotonqt -l reinstall-steam-compat-tool -d "Reinstall Steam compatibility tool"
|
||||
complete -c portprotonqt -l remove-steam-compat-tool -d "Remove Steam compatibility tool"
|
||||
complete -c portprotonqt -l list-games -d "Print local game libraries as JSON"
|
||||
complete -c portprotonqt -l game-info -d "Print one game as JSON" -r -f
|
||||
complete -c portprotonqt -l edit-game -d "Edit game settings" -r -f
|
||||
complete -c portprotonqt -l set -d "Set a game setting (KEY=VALUE)" -r -f
|
||||
complete -c portprotonqt -l all -d "Include uninstalled games in the library list"
|
||||
complete -c portprotonqt -l fields -d "Select JSON fields (comma-separated)" -r -f
|
||||
complete -c portprotonqt -l pretty -d "Format JSON with indentation"
|
||||
complete -c portprotonqt -l raw -d "Print compact JSON"
|
||||
complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
|
||||
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
|
||||
complete -c portprotonqt -l ppqtos -d "Show the system tab"
|
||||
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
|
||||
complete -c portprotonqt -l log -d "Enable PortProton logging"
|
||||
complete -c portprotonqt -l silent -d "Launch Windows file or native game in tray"
|
||||
complete -c portprotonqt -l log -d "Enable game logging"
|
||||
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
|
||||
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
|
||||
complete -c portprotonqt -s h -l help -d "Show help message"
|
||||
|
||||
@@ -1,58 +1,65 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
GITHUB_REPO="Boria138/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 ==="
|
||||
echo
|
||||
|
||||
echo "--- GitHub (${GITHUB_REPO}) ---"
|
||||
GH_RAW=$(curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=100")
|
||||
echo "--- GitHub (${GITHUB_REPOS[*]}) ---"
|
||||
GH_RAW=$(for repo in "${GITHUB_REPOS[@]}"; do
|
||||
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
|
||||
@@ -60,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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run PortProtonQt against a fake AppImage update server."""
|
||||
|
||||
import argparse
|
||||
import http.server
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
APPIMAGE_MAGIC_OFFSET = 8
|
||||
APPIMAGE_TYPE_2_MAGIC = b"AI\x02"
|
||||
TEST_APPIMAGE_SIZE_BYTES = 128 * 1024 * 1024
|
||||
HTTP_COPY_BUFFER_SIZE = 1024 * 1024
|
||||
OLD_APPIMAGE_FILLER = b"OLD-APPIMAGE-BLOCK\n"
|
||||
UPDATED_APPIMAGE_FILLER = b"UPDATED-APPIMAGE-BLOCK\n"
|
||||
|
||||
|
||||
class UpdateHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""Serve fake update metadata and the new AppImage."""
|
||||
|
||||
test_dir = Path()
|
||||
server_url = ""
|
||||
old_version = "0.1.12"
|
||||
new_version = "1.2.0"
|
||||
new_appimage_name = "PortProtonQt-1.2.0-x86_64.AppImage"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"[server] {fmt % args}")
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.endswith(".zsync"):
|
||||
self._send_zsync()
|
||||
elif self.path.startswith("/download/"):
|
||||
self._send_download()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def _send_download(self) -> None:
|
||||
filename = self.path.split("/download/", 1)[1]
|
||||
path = self.test_dir / filename
|
||||
if not path.exists():
|
||||
self.send_error(404, f"Not found: {filename}")
|
||||
return
|
||||
self._send_file(path)
|
||||
|
||||
def _send_zsync(self) -> None:
|
||||
filename = self.path.rsplit("/", 1)[-1]
|
||||
path = self.test_dir / filename
|
||||
if not path.exists():
|
||||
self.send_error(404, f"Not found: {filename}")
|
||||
return
|
||||
self._send_file(path)
|
||||
|
||||
def _send_file(self, path: Path) -> None:
|
||||
file_size = path.stat().st_size
|
||||
byte_range = self._parse_range(file_size)
|
||||
start = 0
|
||||
end = file_size - 1
|
||||
if byte_range is not None:
|
||||
start, end = byte_range
|
||||
self.send_response(206)
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
|
||||
else:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", str(end - start + 1))
|
||||
self.end_headers()
|
||||
with open(path, "rb") as appimage_file:
|
||||
appimage_file.seek(start)
|
||||
remaining = end - start + 1
|
||||
while remaining > 0:
|
||||
chunk = appimage_file.read(min(HTTP_COPY_BUFFER_SIZE, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
def _parse_range(self, file_size: int) -> tuple[int, int] | None:
|
||||
range_header = self.headers.get("Range", "")
|
||||
if not range_header.startswith("bytes="):
|
||||
return None
|
||||
|
||||
range_spec = range_header.removeprefix("bytes=").split(",", 1)[0].strip()
|
||||
start_text, separator, end_text = range_spec.partition("-")
|
||||
if not separator:
|
||||
return None
|
||||
try:
|
||||
if not start_text:
|
||||
length = int(end_text)
|
||||
return max(file_size - length, 0), file_size - 1
|
||||
start = int(start_text)
|
||||
end = int(end_text) if end_text else file_size - 1
|
||||
except ValueError:
|
||||
return None
|
||||
if start < 0 or end < start or start >= file_size:
|
||||
return None
|
||||
return start, min(end, file_size - 1)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("old_version", nargs="?", default="0.1.12")
|
||||
parser.add_argument("new_version", nargs="?", default="1.2.0")
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
parser.add_argument("--keep", action="store_true")
|
||||
parser.add_argument("--version", default=None,
|
||||
help="Simulate current app version (e.g. --version 0.1.8)")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def write_appimage_base(path: Path) -> None:
|
||||
true_path = shutil.which("true") or "/usr/bin/true"
|
||||
shutil.copyfile(true_path, path)
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def update_info(server_url: str, new_appimage_name: str) -> str:
|
||||
return f"zsync|{server_url}/{new_appimage_name}.zsync"
|
||||
|
||||
|
||||
def finalize_appimage(path: Path, test_dir: Path, update_info_text: str, filler: bytes) -> None:
|
||||
update_info_file = test_dir / f"{path.name}.upd_info"
|
||||
update_info_file.write_text(update_info_text, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["objcopy", "--add-section", f".upd_info={update_info_file}", str(path)],
|
||||
check=True,
|
||||
)
|
||||
with open(path, "r+b") as appimage_file:
|
||||
appimage_file.seek(APPIMAGE_MAGIC_OFFSET)
|
||||
appimage_file.write(APPIMAGE_TYPE_2_MAGIC)
|
||||
appimage_file.seek(0, os.SEEK_END)
|
||||
while appimage_file.tell() < TEST_APPIMAGE_SIZE_BYTES:
|
||||
appimage_file.write(
|
||||
filler[:TEST_APPIMAGE_SIZE_BYTES - appimage_file.tell()]
|
||||
)
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def write_zsync(test_dir: Path, server_url: str, new_appimage_name: str) -> None:
|
||||
new_appimage = test_dir / new_appimage_name
|
||||
zsync_file = test_dir / f"{new_appimage_name}.zsync"
|
||||
download_url = f"{server_url}/download/{new_appimage_name}"
|
||||
subprocess.run(
|
||||
[
|
||||
"zsyncmake",
|
||||
"-e",
|
||||
"-u",
|
||||
download_url,
|
||||
"-o",
|
||||
str(zsync_file),
|
||||
"-f",
|
||||
new_appimage_name,
|
||||
str(new_appimage),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def start_server(args: argparse.Namespace, test_dir: Path, new_appimage_name: str):
|
||||
server = http.server.HTTPServer(("127.0.0.1", args.port), UpdateHandler)
|
||||
server_url = f"http://127.0.0.1:{server.server_port}"
|
||||
UpdateHandler.test_dir = test_dir
|
||||
UpdateHandler.server_url = server_url
|
||||
UpdateHandler.old_version = args.old_version
|
||||
UpdateHandler.new_version = args.new_version
|
||||
UpdateHandler.new_appimage_name = new_appimage_name
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, server_url
|
||||
|
||||
|
||||
def python_command() -> str:
|
||||
venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if venv_python.exists():
|
||||
return str(venv_python)
|
||||
return sys.executable
|
||||
|
||||
|
||||
def run_gui(env: dict[str, str]) -> int:
|
||||
proc = subprocess.Popen(
|
||||
[python_command(), "-m", "portprotonqt.app"],
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
)
|
||||
return proc.wait()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.version:
|
||||
args.old_version = args.version
|
||||
test_dir = Path(tempfile.mkdtemp(prefix="ppqt-appimage-update-"))
|
||||
old_appimage = test_dir / f"PortProtonQt-{args.old_version}-x86_64.AppImage"
|
||||
new_appimage_name = f"PortProtonQt-{args.new_version}-x86_64.AppImage"
|
||||
new_appimage = test_dir / new_appimage_name
|
||||
server = None
|
||||
|
||||
try:
|
||||
write_appimage_base(old_appimage)
|
||||
write_appimage_base(new_appimage)
|
||||
server, server_url = start_server(args, test_dir, new_appimage_name)
|
||||
update_info_text = update_info(server_url, new_appimage_name)
|
||||
finalize_appimage(
|
||||
old_appimage,
|
||||
test_dir,
|
||||
update_info_text,
|
||||
OLD_APPIMAGE_FILLER,
|
||||
)
|
||||
finalize_appimage(
|
||||
new_appimage,
|
||||
test_dir,
|
||||
update_info_text,
|
||||
UPDATED_APPIMAGE_FILLER,
|
||||
)
|
||||
write_zsync(test_dir, server_url, new_appimage_name)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["APPIMAGE"] = str(old_appimage)
|
||||
env["PORTPROTONQT_VERSION"] = args.old_version
|
||||
|
||||
print("=== AppImage update GUI test ===")
|
||||
print(f"Test dir : {test_dir}")
|
||||
print(f"Old AppImage : {old_appimage}")
|
||||
print(f"New AppImage : {new_appimage}")
|
||||
print(f"AppImage size : {TEST_APPIMAGE_SIZE_BYTES // 1024 // 1024} MiB")
|
||||
print(f"HTTP server : {server_url}")
|
||||
print(f"Update info : {update_info_text}")
|
||||
print("")
|
||||
print("In GUI: wait for the update dialog, press Update, watch progress.")
|
||||
print("After success, old AppImage should be replaced by the new file.")
|
||||
print("")
|
||||
|
||||
raise SystemExit(run_gui(env))
|
||||
finally:
|
||||
if server is not None:
|
||||
server.shutdown()
|
||||
if args.keep:
|
||||
print(f"Keeping test dir: {test_dir}")
|
||||
else:
|
||||
shutil.rmtree(test_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [Detail Page Layout Mode](#detail-page-layout-mode)
|
||||
- [Detail Page Background Mode](#detail-page-background-mode)
|
||||
- [Background Configuration](#background-configuration)
|
||||
- [Library Background](#library-background)
|
||||
- [Preloader](#preloader)
|
||||
- [Source Corner (Ribbon)](#source-corner-ribbon)
|
||||
- [Terminal Color Schemes](#terminal-color-schemes)
|
||||
@@ -234,14 +235,36 @@ NAV_BUTTON_STYLE = f"""
|
||||
You can control the game library card layout directly from the theme via `styles.py`:
|
||||
|
||||
```python
|
||||
# "grid" (default) or "list"
|
||||
# "grid", "list", "vertical", "horizontal", or "horizontal_top"
|
||||
LIBRARY_LAYOUT_MODE = "grid"
|
||||
```
|
||||
|
||||
- `grid`: multi-column card grid (classic behavior).
|
||||
- `list`: horizontal row-style cards (launcher-style list).
|
||||
- `vertical`: a full-width table with game title, last launch, time spent, and
|
||||
library columns.
|
||||
- `horizontal`: one horizontally scrolling row containing every game.
|
||||
- `horizontal_top`: the first `horizontalTopGameLimit` games from the current
|
||||
filter and sort order, followed by a tile composed from the next games that
|
||||
opens the complete grid.
|
||||
|
||||
Card geometry is controlled by `GAME_CARD_LIST` and `GAME_CARD_GRID`:
|
||||
The `horizontal_top` preview tile is configured with theme-level constants:
|
||||
|
||||
```python
|
||||
horizontalTopGameLimit = 10 # Games shown before the tile.
|
||||
fullLibraryTileGameCount = 4 # Cover images shown in the tile.
|
||||
fullLibraryTileSize = (180, 180) # Tile width and height.
|
||||
fullLibraryTileColumns = 2 # Cover columns inside the tile.
|
||||
fullLibraryTileRows = 2 # Cover rows inside the tile.
|
||||
fullLibraryTileRadius = 10 # Tile corner radius in pixels.
|
||||
```
|
||||
|
||||
`fullLibraryTileColumns * fullLibraryTileRows` should be at least
|
||||
`fullLibraryTileGameCount`. The tile opens the complete library grid when
|
||||
activated.
|
||||
|
||||
Card geometry is controlled by `GAME_CARD_LIST`, `GAME_CARD_GRID`, and
|
||||
`GAME_CARD_HORIZONTAL`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
@@ -258,6 +281,7 @@ GAME_CARD_LIST = {
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
@@ -265,12 +289,48 @@ GAME_CARD_GRID = {
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_orientation": "vertical",
|
||||
"card_animation_type": "scale",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 0.56,
|
||||
"card_height_ratio": 0.78,
|
||||
"cover_radius": 15,
|
||||
"layout_margins": (20, 20, 20, 20),
|
||||
"layout_spacing": 20,
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Card animation keys can be overridden independently in `GAME_CARD_LIST`,
|
||||
`GAME_CARD_GRID`, or `GAME_CARD_HORIZONTAL`: `card_animation_type`, border
|
||||
widths, pulse settings, gradient angles and colors, fill/stripe/glow colors and
|
||||
opacity, scale values, animation durations, and easing curves. For example,
|
||||
`horizontal_top` can define its own `scale` type, scale values, duration, and
|
||||
easing while the complete grid uses separately configured `gradient` values.
|
||||
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback;
|
||||
`card_animation_type` defaults to `gradient`.
|
||||
|
||||
```python
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_animation_type": "scale",
|
||||
"default_scale": 1.0,
|
||||
"hover_scale": 1.055,
|
||||
"focus_scale": 1.075,
|
||||
"scale_anim_duration": 135,
|
||||
"scale_easing_curve": "OutCubic",
|
||||
"scale_easing_curve_out": "InOutCubic",
|
||||
}
|
||||
```
|
||||
|
||||
Card shadows use the shared `shadow_blur_radius` and `shadow_offset` theme
|
||||
constants in both grid and list layouts.
|
||||
|
||||
@@ -452,6 +512,53 @@ Existing themes may continue using `DETAIL_PAGE_GRADIENT`,
|
||||
`DETAIL_PAGE_GRADIENT_FY`, and `DETAIL_PAGE_WAVES`. Legacy values override the
|
||||
corresponding nested settings.
|
||||
|
||||
## Library Background
|
||||
|
||||
The library can use the cover of the currently hovered or focused card as its
|
||||
background. This feature is opt-in: if `LIBRARY_BACKGROUND` is not defined, the
|
||||
library keeps the theme's regular background unchanged.
|
||||
|
||||
`LIBRARY_BACKGROUND` selects the effect and can override detail-page
|
||||
background settings locally. Its `backgrounds` dictionary is merged with
|
||||
`DETAIL_PAGE_BACKGROUNDS`, so only the values that should differ need to be
|
||||
specified:
|
||||
|
||||
```python
|
||||
LIBRARY_BACKGROUND = {
|
||||
"margins": (0, 0, 0, 0),
|
||||
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
|
||||
"backgrounds": {
|
||||
"animation_interval_ms": 30,
|
||||
"animation_speed": 0.03,
|
||||
"blur": {
|
||||
"render_resolution": (1280, 720),
|
||||
"radius": 64,
|
||||
},
|
||||
"gradient": {
|
||||
"stops": None,
|
||||
"type": "linear",
|
||||
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4,
|
||||
"wave_amplitude_ratio": 0.06,
|
||||
"wave_frequency": 2.0,
|
||||
"layer_spacing_ratio": 0.04,
|
||||
"base_opacity": 0.45,
|
||||
"opacity_decay": 0.85,
|
||||
"animation_speed": 0.03,
|
||||
"animation_interval_ms": 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `blur` settings control the intermediate image size and blur radius.
|
||||
`margins` are applied to the background layer layout. Animated modes use the
|
||||
same effect-specific dictionaries documented in `DETAIL_PAGE_BACKGROUNDS`;
|
||||
copy the corresponding dictionary there when overriding `aurora`, `leaf`,
|
||||
`metaballs`, `veins`, or `diagnostics`.
|
||||
|
||||
---
|
||||
|
||||
The `PRELOADER` dictionary controls the loading indicator style and animation. If not defined, the default spinner is used.
|
||||
@@ -851,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`:
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
|
||||
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
|
||||
- [Настройка фона](#настройка-фона)
|
||||
- [Фон библиотеки](#фон-библиотеки)
|
||||
- [Прелоадер](#прелоадер)
|
||||
- [Уголок источника (Лента)](#уголок-источника-лента)
|
||||
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
|
||||
@@ -234,14 +235,36 @@ NAV_BUTTON_STYLE = f"""
|
||||
Вы можете управлять типом отображения карточек библиотеки прямо из темы через `styles.py`:
|
||||
|
||||
```python
|
||||
# "grid" (по умолчанию) или "list"
|
||||
# "grid", "list", "vertical", "horizontal" или "horizontal_top"
|
||||
LIBRARY_LAYOUT_MODE = "grid"
|
||||
```
|
||||
|
||||
- `grid`: многоколоночная сетка карточек (классическое поведение).
|
||||
- `list`: горизонтальные карточки-строки (формат лаунчера).
|
||||
- `vertical`: полноширинная таблица с названием игры, последним запуском,
|
||||
временем в игре и библиотекой.
|
||||
- `horizontal`: одна горизонтально прокручиваемая лента со всеми играми,
|
||||
- `horizontal_top`: первые `horizontalTopGameLimit` игр из текущих результатов
|
||||
фильтрации и сортировки, затем плитка из следующих игр для открытия полной
|
||||
сетки.
|
||||
|
||||
Геометрия карточек управляется через `GAME_CARD_LIST` и `GAME_CARD_GRID`:
|
||||
Плитка перехода в режиме `horizontal_top` настраивается константами темы:
|
||||
|
||||
```python
|
||||
horizontalTopGameLimit = 10 # Игр показывается до плитки.
|
||||
fullLibraryTileGameCount = 4 # Обложек показывается в плитке.
|
||||
fullLibraryTileSize = (180, 180) # Ширина и высота плитки.
|
||||
fullLibraryTileColumns = 2 # Столбцов обложек внутри плитки.
|
||||
fullLibraryTileRows = 2 # Строк обложек внутри плитки.
|
||||
fullLibraryTileRadius = 10 # Радиус скругления плитки в пикселях.
|
||||
```
|
||||
|
||||
Произведение `fullLibraryTileColumns * fullLibraryTileRows` должно быть не
|
||||
меньше `fullLibraryTileGameCount`. При активации плитка открывает полную сетку
|
||||
библиотеки.
|
||||
|
||||
Геометрия карточек управляется через `GAME_CARD_LIST`, `GAME_CARD_GRID` и
|
||||
`GAME_CARD_HORIZONTAL`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
@@ -258,6 +281,7 @@ GAME_CARD_LIST = {
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
@@ -265,13 +289,50 @@ GAME_CARD_GRID = {
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_orientation": "vertical",
|
||||
"card_animation_type": "scale",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 0.56,
|
||||
"card_height_ratio": 0.78,
|
||||
"cover_radius": 15,
|
||||
"layout_margins": (20, 20, 20, 20),
|
||||
"layout_spacing": 20,
|
||||
}
|
||||
```
|
||||
|
||||
Укажите `card_orientation = "vertical"`, чтобы использовать в горизонтальной
|
||||
ленте вертикальные пропорции карточек из сетки. Без параметра сохраняются
|
||||
пропорции горизонтальных блоков.
|
||||
|
||||
Значения задаются в логических пикселях. `cover_radius` применяется к
|
||||
статическим и анимированным обложкам, а `border_radius` — к отрисовываемой
|
||||
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
|
||||
определяется настройкой размера карточек библиотеки.
|
||||
|
||||
Параметры анимации карточек можно отдельно переопределить в
|
||||
`GAME_CARD_LIST`, `GAME_CARD_GRID` или `GAME_CARD_HORIZONTAL`: тип анимации,
|
||||
ширину обводки, pulse, углы и цвета градиента, цвета и прозрачность
|
||||
fill/stripe/glow, масштаб, длительности и easing-кривые. Например,
|
||||
`horizontal_top` может задавать собственные тип `scale`, значения масштаба,
|
||||
длительность и easing, а полная сетка — отдельные параметры `gradient`.
|
||||
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`, а
|
||||
`card_animation_type` по умолчанию использует `gradient`.
|
||||
|
||||
```python
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_animation_type": "scale",
|
||||
"default_scale": 1.0,
|
||||
"hover_scale": 1.055,
|
||||
"focus_scale": 1.075,
|
||||
"scale_anim_duration": 135,
|
||||
"scale_easing_curve": "OutCubic",
|
||||
"scale_easing_curve_out": "InOutCubic",
|
||||
}
|
||||
```
|
||||
|
||||
Тень карточек использует общие константы темы `shadow_blur_radius` и
|
||||
`shadow_offset` как в сеточном, так и в списочном режиме.
|
||||
|
||||
@@ -452,6 +513,54 @@ DETAIL_PAGE_BACKGROUNDS = {
|
||||
`DETAIL_PAGE_GRADIENT_FY` и `DETAIL_PAGE_WAVES`. Старые значения
|
||||
переопределяют соответствующие вложенные настройки.
|
||||
|
||||
## Фон библиотеки
|
||||
|
||||
Библиотека может использовать обложку карточки, на которой находится курсор
|
||||
или фокус, как фон. Функция включается только при наличии
|
||||
`LIBRARY_BACKGROUND`; если словарь не определён, обычный фон темы библиотеки
|
||||
остаётся без изменений.
|
||||
|
||||
`LIBRARY_BACKGROUND` выбирает эффект и может локально переопределять настройки
|
||||
фона детальной страницы. Словарь `backgrounds` объединяется с
|
||||
`DETAIL_PAGE_BACKGROUNDS`, поэтому достаточно указать только отличающиеся
|
||||
значения:
|
||||
|
||||
```python
|
||||
LIBRARY_BACKGROUND = {
|
||||
"margins": (0, 0, 0, 0),
|
||||
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
|
||||
"backgrounds": {
|
||||
"animation_interval_ms": 30,
|
||||
"animation_speed": 0.03,
|
||||
"blur": {
|
||||
"render_resolution": (1280, 720),
|
||||
"radius": 64,
|
||||
},
|
||||
"gradient": {
|
||||
"stops": None,
|
||||
"type": "linear",
|
||||
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4,
|
||||
"wave_amplitude_ratio": 0.06,
|
||||
"wave_frequency": 2.0,
|
||||
"layer_spacing_ratio": 0.04,
|
||||
"base_opacity": 0.45,
|
||||
"opacity_decay": 0.85,
|
||||
"animation_speed": 0.03,
|
||||
"animation_interval_ms": 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Настройки `blur` задают размер промежуточного изображения и радиус размытия.
|
||||
`margins` применяются к слою фона. Анимированные режимы используют те же
|
||||
словаря эффектов, что и `DETAIL_PAGE_BACKGROUNDS`; для переопределения
|
||||
`aurora`, `leaf`, `metaballs`, `veins` или `diagnostics` скопируйте нужный
|
||||
словарь из описания выше.
|
||||
|
||||
---
|
||||
|
||||
Словарь `PRELOADER` управляет стилем и анимацией индикатора загрузки. Если не определён, используется стандартный спиннер.
|
||||
@@ -851,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`:
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
REPOSITORY="Linux-Gaming/PortProtonQt"
|
||||
SERVER="https://git.linux-gaming.ru"
|
||||
GITHUB_REPOSITORY="linux-gaming-ru/PortProtonQt"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || {
|
||||
echo "PortProtonQt installer requires curl." >&2
|
||||
exit 1
|
||||
}
|
||||
command -v mktemp >/dev/null 2>&1 || {
|
||||
echo "PortProtonQt installer requires mktemp." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
amd64) ARCH="x86_64" ;;
|
||||
arm64) ARCH="aarch64" ;;
|
||||
esac
|
||||
|
||||
GITEA_API="${SERVER}/api/v1/repos/${REPOSITORY}/releases/latest"
|
||||
GITHUB_API="https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest"
|
||||
APPIMAGE_PATH=$(mktemp "${TMPDIR:-/tmp}/portprotonqt.XXXXXX.AppImage")
|
||||
DOWNLOADED=false
|
||||
|
||||
trap 'rm -f "$APPIMAGE_PATH"' EXIT HUP INT TERM
|
||||
|
||||
for RELEASE_API in "$GITEA_API" "$GITHUB_API"; do
|
||||
APPIMAGE_URL=$(curl -fsSL "$RELEASE_API" |
|
||||
tr ',' '\n' |
|
||||
sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\.AppImage\)".*/\1/p' |
|
||||
grep -- "-${ARCH}\.AppImage$" |
|
||||
head -n 1 || true)
|
||||
if [ -z "$APPIMAGE_URL" ]; then
|
||||
continue
|
||||
fi
|
||||
echo "Downloading PortProtonQt AppImage from ${APPIMAGE_URL}..."
|
||||
if curl -fL --progress-bar "$APPIMAGE_URL" -o "$APPIMAGE_PATH"; then
|
||||
DOWNLOADED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DOWNLOADED" != true ]; then
|
||||
echo "Failed to download PortProtonQt AppImage for ${ARCH}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod 755 "$APPIMAGE_PATH"
|
||||
|
||||
if ! PORTPROTONQT_INTEGRATE_APPIMAGE=1 "$APPIMAGE_PATH"; then
|
||||
echo "Failed to integrate PortProtonQt AppImage." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PortProtonQt was installed and integrated."
|
||||
+8
-22
@@ -1,5 +1,5 @@
|
||||
project('portprotonqt', 'c',
|
||||
version: '1.3.1',
|
||||
version: '1.5.0',
|
||||
meson_version: '>= 0.61.2',
|
||||
license: 'GPL-3.0',
|
||||
)
|
||||
@@ -13,15 +13,15 @@ conf = configuration_data()
|
||||
conf.set('PYTHON', python3.full_path())
|
||||
|
||||
version_conf = configuration_data()
|
||||
version_conf.set_quoted('APP_VERSION', meson.project_version())
|
||||
version_conf.set_quoted('APP_COMMIT', '')
|
||||
version_conf.set('APP_VERSION', meson.project_version())
|
||||
version_conf.set('APP_COMMIT', '')
|
||||
|
||||
if get_option('nightly')
|
||||
git = find_program('git', required: false)
|
||||
if git.found()
|
||||
git_commit = run_command(git, 'rev-parse', '--short', 'HEAD', check: false)
|
||||
if git_commit.returncode() == 0
|
||||
version_conf.set_quoted('APP_COMMIT', git_commit.stdout().strip())
|
||||
version_conf.set('APP_COMMIT', git_commit.stdout().strip())
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
@@ -36,19 +36,10 @@ purelibdir_opt = get_option('python_purelibdir')
|
||||
pythondir = purelibdir_opt != '' ? purelibdir_opt: python3.get_install_dir(pure: true)
|
||||
pkgdatadir = pythondir / meson.project_name()
|
||||
|
||||
# C compiler for vk_gpu_info
|
||||
# Native C build dependencies and sources
|
||||
cc = meson.get_compiler('c')
|
||||
|
||||
configured_version = configure_file(
|
||||
input: 'portprotonqt/_version.py.in',
|
||||
output: '_version.py',
|
||||
configuration: version_conf,
|
||||
)
|
||||
|
||||
install_data(
|
||||
configured_version,
|
||||
install_dir: pythondir / meson.project_name(),
|
||||
)
|
||||
sdl3_dep = dependency('sdl3', required: true)
|
||||
gamepad_sources = files('build-aux/src/gamepad_backend.c')
|
||||
|
||||
# Install Python package
|
||||
subdir('portprotonqt')
|
||||
@@ -67,7 +58,7 @@ install_data(configured_portprotonqt,
|
||||
)
|
||||
|
||||
# Build and install vk_gpu_info executable
|
||||
vk_gpu_info_sources = files('build-aux/vk_gpu_info.c')
|
||||
vk_gpu_info_sources = files('build-aux/src/vk_gpu_info.c')
|
||||
vk_gpu_info_deps = [dependency('vulkan', required: true)]
|
||||
|
||||
executable('vk_gpu_info',
|
||||
@@ -120,11 +111,6 @@ install_subdir(
|
||||
install_dir: datadir / 'portproton',
|
||||
)
|
||||
|
||||
install_subdir(
|
||||
'build-aux/share/portproton/img',
|
||||
install_dir: datadir / 'portproton',
|
||||
)
|
||||
|
||||
install_subdir(
|
||||
'build-aux/share/portproton/scripts/portwine_db',
|
||||
install_dir: datadir / 'portproton' / 'scripts',
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
APP_VERSION = @APP_VERSION@
|
||||
APP_COMMIT = @APP_COMMIT@
|
||||
@@ -30,7 +30,16 @@ class DetailBackgroundAnimations:
|
||||
if effect not in self.effects:
|
||||
self.remove(page)
|
||||
return False
|
||||
config = theme.DETAIL_PAGE_BACKGROUNDS
|
||||
config = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
|
||||
animated = not mode.startswith("static_")
|
||||
if not isinstance(config, dict) or not isinstance(config.get(effect), dict):
|
||||
self.remove(page)
|
||||
return False
|
||||
if animated and not all(
|
||||
key in config for key in ("animation_interval_ms", "animation_speed")
|
||||
):
|
||||
self.remove(page)
|
||||
return False
|
||||
self.remove(page)
|
||||
state = {
|
||||
"effect": effect,
|
||||
@@ -47,7 +56,7 @@ class DetailBackgroundAnimations:
|
||||
self._paint(page, state)
|
||||
|
||||
page.paintEvent = paint_event
|
||||
if not mode.startswith("static_"):
|
||||
if animated:
|
||||
self._start_timer(page, state)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from math import radians, sin
|
||||
from typing import Any
|
||||
import warnings
|
||||
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -22,6 +24,22 @@ class GameCardAnimations:
|
||||
self.pulse_anim: QPropertyAnimation | None = None
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
def _animation_type(self) -> str:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
return layout_config.get("card_animation_type", "gradient")
|
||||
|
||||
def _config_value(self, key: str) -> Any:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
if key in layout_config:
|
||||
return layout_config[key]
|
||||
return self.theme.GAME_CARD_ANIMATION[key]
|
||||
|
||||
def _optional_config_value(self, key: str, default: Any) -> Any:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
if key in layout_config:
|
||||
return layout_config[key]
|
||||
return self.theme.GAME_CARD_ANIMATION.get(key, default)
|
||||
|
||||
def _disconnect_pulse_animation(self) -> None:
|
||||
if not self._isPulseAnimationConnected or self.thickness_anim is None:
|
||||
return
|
||||
@@ -49,20 +67,20 @@ class GameCardAnimations:
|
||||
def _restart_gradient_animation(self) -> None:
|
||||
self._stop_animation("gradient_anim")
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
|
||||
self.gradient_anim.setStartValue(self._config_value("gradient_start_angle"))
|
||||
self.gradient_anim.setEndValue(self._config_value("gradient_end_angle"))
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
def _easing_curve(self, easing_key: str) -> QEasingCurve:
|
||||
easing_type = QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION[easing_key]]
|
||||
easing_type = QEasingCurve.Type[self._config_value(easing_key)]
|
||||
return QEasingCurve(easing_type)
|
||||
|
||||
def _restart_scale_animation(self, end_value: float, easing_key: str) -> None:
|
||||
self._stop_animation("scale_anim")
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
|
||||
self.scale_anim.setEasingCurve(self._easing_curve(easing_key))
|
||||
self.scale_anim.setStartValue(self.game_card._scale)
|
||||
self.scale_anim.setEndValue(end_value)
|
||||
@@ -89,18 +107,47 @@ class GameCardAnimations:
|
||||
self._stop_animation("pulse_anim")
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
def refresh_theme(self, theme: Any) -> None:
|
||||
"""Recreate animation state for a live theme change."""
|
||||
self.cleanup()
|
||||
self.theme = theme
|
||||
animation_type = self._animation_type()
|
||||
self.game_card._gradientAngle = self._config_value("gradient_start_angle")
|
||||
if self.game_card._hovered:
|
||||
self.game_card._borderWidth = self._config_value("hover_border_width")
|
||||
active_scale = self._config_value("hover_scale")
|
||||
elif self.game_card._focused:
|
||||
self.game_card._borderWidth = self._config_value("focus_border_width")
|
||||
active_scale = self._config_value("focus_scale")
|
||||
else:
|
||||
self.game_card._borderWidth = self._config_value("default_border_width")
|
||||
active_scale = self._config_value("default_scale")
|
||||
self.game_card._scale = (
|
||||
active_scale
|
||||
if animation_type in {"scale", "scale_fill"}
|
||||
else self._config_value("default_scale")
|
||||
)
|
||||
self.setup_animations()
|
||||
if (self.game_card._hovered or self.game_card._focused) and animation_type in {
|
||||
"gradient", "glow",
|
||||
}:
|
||||
self._restart_gradient_animation()
|
||||
if self.game_card._hovered or self.game_card._focused:
|
||||
self.start_pulse_animation()
|
||||
self.game_card.update()
|
||||
|
||||
def setup_animations(self):
|
||||
"""Initialize animation properties based on theme."""
|
||||
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
self.thickness_anim.setDuration(self._config_value("thickness_anim_duration"))
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
|
||||
|
||||
def start_pulse_animation(self):
|
||||
"""Start pulse animation for border width when hovered or focused."""
|
||||
@@ -110,26 +157,27 @@ class GameCardAnimations:
|
||||
self._stop_animation("pulse_anim")
|
||||
|
||||
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||
self.pulse_anim.setDuration(self._config_value("pulse_anim_duration"))
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0, self._config_value("pulse_min_border_width"))
|
||||
self.pulse_anim.setKeyValueAt(0.5, self._config_value("pulse_max_border_width"))
|
||||
self.pulse_anim.setKeyValueAt(1, self._config_value("pulse_min_border_width"))
|
||||
self.pulse_anim.start()
|
||||
|
||||
def handle_enter_event(self):
|
||||
"""Handle mouse enter event animations."""
|
||||
self.game_card._hovered = True
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, True)
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
if not isinstance(QApplication.focusWidget(), QLineEdit):
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["hover_border_width"],
|
||||
self._config_value("hover_border_width"),
|
||||
"thickness_easing_curve",
|
||||
True,
|
||||
)
|
||||
@@ -137,21 +185,21 @@ class GameCardAnimations:
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._restart_gradient_animation()
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["hover_scale"], "scale_easing_curve")
|
||||
self._restart_scale_animation(self._config_value("hover_scale"), "scale_easing_curve")
|
||||
|
||||
def handle_leave_event(self):
|
||||
"""Handle mouse leave event animations."""
|
||||
self.game_card._hovered = False
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._focused:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._stop_animation("gradient_anim")
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
|
||||
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
|
||||
self._stop_animation("pulse_anim")
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["default_border_width"],
|
||||
self._config_value("default_border_width"),
|
||||
"thickness_easing_curve_out",
|
||||
False,
|
||||
)
|
||||
@@ -165,10 +213,10 @@ class GameCardAnimations:
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"],
|
||||
self._config_value("focus_border_width"),
|
||||
"thickness_easing_curve",
|
||||
True,
|
||||
)
|
||||
@@ -176,21 +224,21 @@ class GameCardAnimations:
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._restart_gradient_animation()
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["focus_scale"], "scale_easing_curve")
|
||||
self._restart_scale_animation(self._config_value("focus_scale"), "scale_easing_curve")
|
||||
|
||||
def handle_focus_out_event(self):
|
||||
"""Handle focus out event animations."""
|
||||
self.game_card._focused = False
|
||||
self.game_card.focusChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._hovered:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._stop_animation("gradient_anim")
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
|
||||
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
|
||||
self._stop_animation("pulse_anim")
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["default_border_width"],
|
||||
self._config_value("default_border_width"),
|
||||
"thickness_easing_curve_out",
|
||||
False,
|
||||
)
|
||||
@@ -203,39 +251,39 @@ class GameCardAnimations:
|
||||
pen = QPen()
|
||||
pen.setWidth(self.game_card._borderWidth)
|
||||
fill_brush = QBrush(Qt.BrushStyle.NoBrush)
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
|
||||
center = self.game_card.rect().center()
|
||||
gradient = QConicalGradient(center, self.game_card._gradientAngle)
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
for stop in self._config_value("gradient_colors"):
|
||||
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type in {"fill", "scale_fill"}:
|
||||
fill_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
fill_color_value = self._optional_config_value(
|
||||
"fill_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
fill_alpha = int(self.theme.GAME_CARD_ANIMATION.get("fill_alpha", 90))
|
||||
fill_alpha = int(self._optional_config_value("fill_alpha", 90))
|
||||
fill_color = QColor(fill_color_value)
|
||||
fill_color.setAlpha(max(0, min(255, fill_alpha)))
|
||||
fill_brush = QBrush(fill_color)
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "stripe":
|
||||
stripe_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
stripe_color_value = self._optional_config_value(
|
||||
"stripe_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
stripe_alpha = int(self.theme.GAME_CARD_ANIMATION.get("stripe_alpha", 255))
|
||||
stripe_alpha = int(self._optional_config_value("stripe_alpha", 255))
|
||||
stripe_color = QColor(stripe_color_value)
|
||||
stripe_color.setAlpha(max(0, min(255, stripe_alpha)))
|
||||
pen.setColor(stripe_color)
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "glow":
|
||||
glow_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
glow_color_value = self._optional_config_value(
|
||||
"stripe_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
glow_base_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_base_alpha", 120))
|
||||
glow_pulse_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_pulse_alpha", 80))
|
||||
glow_base_alpha = int(self._optional_config_value("glow_base_alpha", 120))
|
||||
glow_pulse_alpha = int(self._optional_config_value("glow_pulse_alpha", 80))
|
||||
glow_wave = 0.5 + 0.5 * sin(radians(self.game_card._gradientAngle))
|
||||
glow_alpha = max(0, min(255, glow_base_alpha + int(glow_pulse_alpha * glow_wave)))
|
||||
glow_color = QColor(glow_color_value)
|
||||
|
||||
+316
-125
@@ -1,39 +1,31 @@
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
import importlib
|
||||
import subprocess
|
||||
import shlex
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote, unquote
|
||||
from logging import Logger
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
|
||||
try:
|
||||
version_module = importlib.import_module("portprotonqt._version")
|
||||
APP_COMMIT = version_module.APP_COMMIT
|
||||
APP_VERSION = version_module.APP_VERSION
|
||||
except ImportError:
|
||||
APP_COMMIT = ""
|
||||
APP_VERSION = "1.3.1"
|
||||
|
||||
__app_version__ = os.environ.get("PORTPROTONQT_VERSION", APP_VERSION)
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSystemTrayIcon,
|
||||
)
|
||||
from PySide6.QtGui import QAction, QIcon
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from portprotonqt.config import (
|
||||
consume_download_counter_skip,
|
||||
display_config,
|
||||
find_game_by_exe,
|
||||
get_portproton_start_command,
|
||||
get_portproton_location,
|
||||
save_portdata_path_to_config,
|
||||
@@ -42,6 +34,7 @@ from portprotonqt.config import (
|
||||
window_config,
|
||||
)
|
||||
from portprotonqt.logger import get_logger, setup_logger
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, create_native_desktop_file
|
||||
from portprotonqt.cli import (
|
||||
parse_args,
|
||||
is_portproton_url,
|
||||
@@ -60,6 +53,22 @@ from portprotonqt.cli import (
|
||||
)
|
||||
from portprotonqt.localization import _, get_steam_language
|
||||
|
||||
APP_VERSION = "@APP_VERSION@"
|
||||
APP_COMMIT = "@APP_COMMIT@"
|
||||
if APP_VERSION == "@APP" "_VERSION@":
|
||||
APP_VERSION = "1.5.0"
|
||||
try:
|
||||
APP_COMMIT = subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode("utf-8").strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||
APP_COMMIT = ""
|
||||
|
||||
__app_version__ = APP_VERSION
|
||||
|
||||
RESTART_STOP_TIMEOUT_SECONDS = 30
|
||||
|
||||
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
|
||||
|
||||
def get_version():
|
||||
@@ -76,70 +85,6 @@ def get_version():
|
||||
return __app_version__
|
||||
|
||||
|
||||
def stop_portproton_game(start_sh: list[str], logger: Logger) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
start_sh + ["cli", "--stop"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("Failed to execute PortProton stop command: %s", e)
|
||||
|
||||
|
||||
def get_portproton_tray_icon() -> QIcon:
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
icon = ThemeManager().get_icon("tray_portproton", ui_config.get_theme())
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
|
||||
"""Launch a game with a minimal tray stop action."""
|
||||
logger = get_logger(__name__)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
|
||||
tray_icon.setToolTip(__app_name__)
|
||||
|
||||
from datetime import datetime
|
||||
from portprotonqt.time_utils import save_last_launch, save_playtime
|
||||
save_last_launch(os.path.splitext(os.path.basename(exe_path))[0], datetime.now())
|
||||
start_time = datetime.now()
|
||||
|
||||
process = subprocess.Popen(start_sh + [exe_path], env=os.environ.copy(), shell=False)
|
||||
tray_menu = QMenu()
|
||||
|
||||
def end_silent_run():
|
||||
monitor_timer.stop()
|
||||
elapsed = int((datetime.now() - start_time).total_seconds())
|
||||
if elapsed > 0:
|
||||
save_playtime(exe_path, elapsed)
|
||||
tray_icon.hide()
|
||||
app.quit()
|
||||
|
||||
def stop_game() -> None:
|
||||
stop_portproton_game(start_sh, logger)
|
||||
end_silent_run()
|
||||
|
||||
def close_when_game_exits(_tray_menu: QMenu = tray_menu) -> None:
|
||||
if process.poll() is not None:
|
||||
end_silent_run()
|
||||
|
||||
stop_action = QAction(_("Stop Game"), tray_menu)
|
||||
stop_action.triggered.connect(stop_game)
|
||||
tray_menu.addAction(stop_action)
|
||||
tray_icon.setContextMenu(tray_menu)
|
||||
tray_icon.show()
|
||||
|
||||
monitor_timer = QTimer(app)
|
||||
monitor_timer.timeout.connect(close_when_game_exits)
|
||||
monitor_timer.start(1000)
|
||||
|
||||
|
||||
def restore_prefix_backup(start_sh: list[str], backup_path: str) -> int:
|
||||
"""Restore a PortProton prefix backup."""
|
||||
logger = get_logger(__name__)
|
||||
@@ -197,6 +142,57 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
if parsed_args.list_games or parsed_args.game_info or parsed_args.edit_game:
|
||||
import orjson
|
||||
|
||||
from portprotonqt.cli import edit_game, list_games
|
||||
|
||||
try:
|
||||
if parsed_args.edit_game:
|
||||
result = edit_game(parsed_args.edit_game, parsed_args.settings)
|
||||
elif parsed_args.game_info:
|
||||
games = list_games()
|
||||
result = next((game for game in games if game["id"] == parsed_args.game_info), None)
|
||||
if result is None:
|
||||
get_logger(__name__).error("Game not found: %s", parsed_args.game_info)
|
||||
return 1
|
||||
else:
|
||||
result = [game for game in list_games() if parsed_args.all_games or game.get("installed", False)]
|
||||
if parsed_args.fields:
|
||||
entries = result if isinstance(result, list) else [result]
|
||||
selected = [{field: game.get(field) for field in parsed_args.fields} for game in entries]
|
||||
result = selected if isinstance(result, list) else selected[0]
|
||||
option = orjson.OPT_INDENT_2 if parsed_args.pretty else 0
|
||||
sys.stdout.write(orjson.dumps(result, option=option).decode("utf-8") + "\n")
|
||||
except OSError as error:
|
||||
get_logger(__name__).error("Library command failed: %s", error)
|
||||
return 1
|
||||
except ValueError as error:
|
||||
get_logger(__name__).error("Invalid library command: %s", error)
|
||||
return 1
|
||||
except RuntimeError as error:
|
||||
get_logger(__name__).error("Failed to edit game settings: %s", error)
|
||||
return 1
|
||||
return 0
|
||||
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
from portprotonqt.appimage_integration import integrate_appimage
|
||||
|
||||
setup_logger(parsed_args.debug_level)
|
||||
try:
|
||||
destination = integrate_appimage()
|
||||
restart_env = os.environ.copy()
|
||||
restart_env.pop("PORTPROTONQT_INTEGRATE_APPIMAGE", None)
|
||||
subprocess.Popen(
|
||||
[str(destination), *sys.argv[1:]],
|
||||
env=restart_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, KeyError) as error:
|
||||
get_logger(__name__).error("Failed to integrate AppImage: %s", error)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
# Handle --reinstall-steam-compat-tool flag
|
||||
if parsed_args.reinstall_steam_compat_tool:
|
||||
@@ -277,6 +273,17 @@ def main():
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
args = parsed_args
|
||||
native_launch_uri = None
|
||||
if args.file_or_url and args.file_or_url.startswith(NATIVE_URI_PREFIX):
|
||||
native_launch_uri = args.file_or_url
|
||||
elif args.file_or_url and not is_launch_file(args.file_or_url):
|
||||
target = normalize_launch_path(args.file_or_url)
|
||||
if os.path.isfile(target) and os.access(target, os.X_OK) and not is_autoinstall_file(target) and not is_prefix_backup_file(target):
|
||||
entry = find_game_by_exe(target)
|
||||
name = entry.get("Name", os.path.basename(target)) if entry is not None else os.path.basename(target)
|
||||
result = create_native_desktop_file(target, name)
|
||||
if result and os.path.isfile(result[1]):
|
||||
native_launch_uri = NATIVE_URI_PREFIX + result[1]
|
||||
setup_logger(args.debug_level)
|
||||
logger = get_logger(__name__)
|
||||
if consume_download_counter_skip():
|
||||
@@ -291,6 +298,7 @@ def main():
|
||||
autoinstall_path = None
|
||||
theme_store_id = None
|
||||
gog_launch_uri = None
|
||||
egs_launch_uri = None
|
||||
resolution_from_args = None
|
||||
if args.resolution:
|
||||
resolution_from_args = parse_resolution(args.resolution)
|
||||
@@ -304,14 +312,14 @@ def main():
|
||||
prefix_name, backup_dir = args.create_backup
|
||||
backup_request = (prefix_name, os.path.abspath(os.path.expanduser(backup_dir)))
|
||||
ipc_message = "backup:{}:{}".format(quote(prefix_name, safe=""), quote(backup_request[1], safe=""))
|
||||
elif is_silent_launch and not args.log and args.file_or_url and is_launch_file(args.file_or_url):
|
||||
run_silent_tray(app, start_sh, normalize_launch_path(args.file_or_url))
|
||||
sys.exit(app.exec())
|
||||
elif is_restore_prefix_request(args):
|
||||
restore_prefix_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
|
||||
elif native_launch_uri:
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{quote(native_launch_uri, safe='')}"
|
||||
elif args.file_or_url and is_launch_file(args.file_or_url):
|
||||
launch_action = "log" if args.log else "open"
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
elif args.file_or_url and is_autoinstall_file(args.file_or_url):
|
||||
autoinstall_path = normalize_launch_path(args.file_or_url)
|
||||
@@ -320,19 +328,32 @@ def main():
|
||||
app_id = args.file_or_url.removeprefix("gog://launch/")
|
||||
if app_id.isdigit():
|
||||
gog_launch_uri = args.file_or_url
|
||||
ipc_message = f"gog:{app_id}"
|
||||
if args.log:
|
||||
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
|
||||
else:
|
||||
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"gog:{app_id}"
|
||||
elif args.file_or_url and args.file_or_url.startswith("egs://launch/"):
|
||||
app_id = args.file_or_url.removeprefix("egs://launch/")
|
||||
if app_id and "/" not in app_id:
|
||||
egs_launch_uri = args.file_or_url
|
||||
if args.log:
|
||||
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
|
||||
else:
|
||||
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"egs:{app_id}"
|
||||
elif args.file_or_url:
|
||||
theme_store_id = parse_portprotonqt_theme_url(args.file_or_url)
|
||||
if theme_store_id is not None:
|
||||
ipc_message = f"theme:{theme_store_id}"
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtCore import QLockFile, QStandardPaths, QThread, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from requests import RequestException
|
||||
from portprotonqt.appimage_updater import (
|
||||
APPIMAGE_UPDATE_START_DELAY_MS,
|
||||
AppImageUpdateWorker,
|
||||
)
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.port_data_path_selector import ask_portdata_path, is_portdata_path_read_write
|
||||
from portprotonqt.portproton_api import (
|
||||
PortProtonAPI,
|
||||
@@ -350,7 +371,10 @@ def main():
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
|
||||
# --- Single-instance logic ---
|
||||
server_name = __app_id__
|
||||
runtime_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
|
||||
server_name = os.path.join(runtime_dir, __app_id__)
|
||||
instance_lock = QLockFile(server_name + ".lock")
|
||||
owns_instance = instance_lock.tryLock(0)
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
@@ -363,7 +387,11 @@ def main():
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Remove old socket if it exists
|
||||
if not owns_instance:
|
||||
logger.warning("PortProtonQt is already running but its IPC server is unavailable")
|
||||
return
|
||||
|
||||
# Only the lock owner can remove a stale socket.
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
@@ -387,7 +415,7 @@ def main():
|
||||
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
|
||||
|
||||
# Check if we have a portproton:// URL or launch file to handle
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri and not native_launch_uri:
|
||||
if is_portproton_url(args.file_or_url):
|
||||
# Parse the portproton:// URL to get the full download URL
|
||||
download_url = parse_portproton_url(args.file_or_url)
|
||||
@@ -471,13 +499,89 @@ def main():
|
||||
logger.warning(f"Invalid resolution format: {args.resolution}, expected WIDTHxHEIGHT (e.g. 1920x1080)")
|
||||
|
||||
launch_path = exe_path or autoinstall_path
|
||||
silent_game_request = is_silent_launch and not args.log and bool(
|
||||
exe_path or gog_launch_uri or egs_launch_uri or native_launch_uri
|
||||
)
|
||||
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
|
||||
window.silent_launch_mode = silent_game_request
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = NATIVE_URI_PREFIX + app_id if source == "native" else f"{source}://launch/{app_id}"
|
||||
if log_mode:
|
||||
window._pending_log_exe = exec_line if source == "native" else getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
|
||||
def open_card(games: list[tuple]) -> None:
|
||||
game = next((item for item in games if item[5] == exec_line), None)
|
||||
if game is None:
|
||||
return
|
||||
window.openGameDetailPage({
|
||||
"name": game[0], "description": game[1], "cover_path": game[2],
|
||||
"appid": game[3], "controller_support": game[4],
|
||||
"exec_line": game[5], "last_launch": game[6],
|
||||
"formatted_playtime": game[7], "protondb_tier": game[8],
|
||||
"anticheat_status": game[9], "playtime_seconds": game[11],
|
||||
"game_source": game[12], "anticheat_slug": game[13],
|
||||
"ppdb_id": game[14], "ppdb_rating": game[15],
|
||||
"protondb_appid": game[16] if len(game) > 16 else "",
|
||||
})
|
||||
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
loader(open_card)
|
||||
|
||||
restart_target = None
|
||||
|
||||
def handle_game_request(
|
||||
target: str, silent: bool, log_mode: bool = False, confirm: bool = False,
|
||||
) -> None:
|
||||
nonlocal restart_target
|
||||
if restart_target is not None:
|
||||
return
|
||||
if silent and (confirm or window.game_processes):
|
||||
message_box = QMessageBox(window)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(__app_name__)
|
||||
message_box.setText(_("PortProtonQt is already running. Stop it and all games, then restart for the new launch?"))
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
restart_target = target
|
||||
app.quit()
|
||||
return
|
||||
if target.startswith(NATIVE_URI_PREFIX):
|
||||
if silent and not log_mode:
|
||||
window.toggleGame(target)
|
||||
else:
|
||||
open_store_game_card("native", target.removeprefix(NATIVE_URI_PREFIX), log_mode)
|
||||
return
|
||||
if is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
window.toggleGame(shlex.quote(path))
|
||||
return
|
||||
window.handle_launch_exe(path, log_mode=log_mode)
|
||||
return
|
||||
source, app_id = target.split("://launch/", 1)
|
||||
if silent:
|
||||
window.toggleGame(target)
|
||||
return
|
||||
open_store_game_card(source, app_id, log_mode)
|
||||
|
||||
# Handle launch file if provided
|
||||
if exe_path:
|
||||
if native_launch_uri:
|
||||
def handle_native_launch() -> None:
|
||||
handle_game_request(native_launch_uri, is_silent_launch and not args.log, args.log)
|
||||
QTimer.singleShot(0, handle_native_launch)
|
||||
elif exe_path:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe():
|
||||
window.handle_launch_exe(exe_path, log_mode=args.log)
|
||||
def handle_launch_exe() -> None:
|
||||
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
|
||||
QTimer.singleShot(0, handle_launch_exe)
|
||||
elif autoinstall_path:
|
||||
def handle_autoinstall():
|
||||
@@ -492,9 +596,13 @@ def main():
|
||||
window._perform_restore(restore_prefix_path)
|
||||
QTimer.singleShot(0, handle_restore_prefix)
|
||||
elif gog_launch_uri:
|
||||
def handle_gog_launch():
|
||||
window.toggleGame(gog_launch_uri)
|
||||
def handle_gog_launch() -> None:
|
||||
handle_game_request(gog_launch_uri, is_silent_launch, args.log)
|
||||
QTimer.singleShot(0, handle_gog_launch)
|
||||
elif egs_launch_uri:
|
||||
def handle_egs_launch() -> None:
|
||||
handle_game_request(egs_launch_uri, is_silent_launch, args.log)
|
||||
QTimer.singleShot(0, handle_egs_launch)
|
||||
|
||||
# --- Handle incoming connections ---
|
||||
def handle_new_connection():
|
||||
@@ -513,37 +621,46 @@ def main():
|
||||
msg.startswith("show")
|
||||
or msg.startswith("open:")
|
||||
or msg.startswith("log:")
|
||||
or msg.startswith("silent:")
|
||||
or msg.startswith("restore:")
|
||||
or msg.startswith("backup:")
|
||||
or msg.startswith("theme:")
|
||||
or msg.startswith("autoinstall:")
|
||||
or msg.startswith("gog:")
|
||||
or msg.startswith("egs:")
|
||||
):
|
||||
# Ensure the window is visible and not minimized
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
if not msg.startswith("silent:"):
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
|
||||
# Ensure window is in active state for systems with strict focus policies
|
||||
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
|
||||
# Ensure active state for strict focus policies
|
||||
window.setWindowState(
|
||||
window.windowState() | Qt.WindowState.WindowActive
|
||||
)
|
||||
|
||||
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("open:") or msg.startswith("log:"):
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True, confirm=True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = msg.split(":", 1)[1].strip()
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
window.handle_launch_exe(launch_path, log_mode=log_mode)
|
||||
handle_game_request(launch_path, False, log_mode)
|
||||
elif launch_path.startswith(NATIVE_URI_PREFIX) or (log_mode and launch_path.startswith(("gog://launch/", "egs://launch/"))):
|
||||
handle_game_request(unquote(launch_path), False, log_mode)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -556,9 +673,15 @@ def main():
|
||||
elif msg.startswith("gog:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id.isdigit():
|
||||
window.toggleGame(f"gog://launch/{app_id}")
|
||||
handle_game_request(f"gog://launch/{app_id}", False)
|
||||
else:
|
||||
logger.warning("Invalid GOG app id via IPC: %s", app_id)
|
||||
elif msg.startswith("egs:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id and "/" not in app_id:
|
||||
handle_game_request(f"egs://launch/{app_id}", False)
|
||||
else:
|
||||
logger.warning("Invalid Epic app id via IPC: %s", app_id)
|
||||
elif msg.startswith("backup:"):
|
||||
parts = msg.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
@@ -601,16 +724,20 @@ def main():
|
||||
and getattr(window.input_manager, "gamepad", None) is not None
|
||||
)
|
||||
launch_minimized = (
|
||||
display_config.get_start_minimized()
|
||||
and not args.fullscreen
|
||||
and not launch_auto_fullscreen
|
||||
and window_resolution is None
|
||||
and exe_path is None
|
||||
and backup_request is None
|
||||
and restore_prefix_path is None
|
||||
and autoinstall_path is None
|
||||
and theme_store_id is None
|
||||
and gog_launch_uri is None
|
||||
silent_game_request
|
||||
or (
|
||||
display_config.get_start_minimized()
|
||||
and not args.fullscreen
|
||||
and not launch_auto_fullscreen
|
||||
and window_resolution is None
|
||||
and exe_path is None
|
||||
and backup_request is None
|
||||
and restore_prefix_path is None
|
||||
and autoinstall_path is None
|
||||
and theme_store_id is None
|
||||
and gog_launch_uri is None
|
||||
and egs_launch_uri is None
|
||||
)
|
||||
)
|
||||
if launch_minimized:
|
||||
logger.info("Launching in tray")
|
||||
@@ -620,7 +747,7 @@ def main():
|
||||
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
|
||||
)
|
||||
display_config.set_fullscreen(True)
|
||||
window.showFullScreen()
|
||||
window.input_manager.handle_fullscreen_slot(True)
|
||||
elif launch_auto_fullscreen:
|
||||
logger.info("Launching in fullscreen mode (gamepad)")
|
||||
window.input_manager.handle_fullscreen_slot(True)
|
||||
@@ -667,13 +794,35 @@ 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)
|
||||
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."""
|
||||
|
||||
def __init__(self, api: GOGAPI) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.api.update_gogdl()
|
||||
except (OSError, RequestException):
|
||||
logger.exception("Failed to update gogdl")
|
||||
|
||||
gogdl_update_worker = GOGDLUpdateWorker(window.gog_api)
|
||||
window.gogdlUpdateWorker = gogdl_update_worker
|
||||
gogdl_update_worker.finished.connect(
|
||||
lambda: setattr(window, "gogdlUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
gogdl_update_worker.finished.connect(gogdl_update_worker.deleteLater)
|
||||
gogdl_update_worker.start()
|
||||
|
||||
# Execute the initial PortProton command after the UI is set up
|
||||
class InitialCommandWorker(QThread):
|
||||
@@ -739,10 +888,15 @@ def main():
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
if start_sh:
|
||||
# Initial setup can stop Wine and must not race a silent game launch.
|
||||
if start_sh and not silent_game_request:
|
||||
worker = InitialCommandWorker(start_sh)
|
||||
window.initialCommandWorker = worker
|
||||
worker.finished.connect(
|
||||
lambda: setattr(window, "initialCommandWorker", None)
|
||||
)
|
||||
worker.start()
|
||||
else:
|
||||
elif not start_sh:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
# --- Cleanup ---
|
||||
@@ -757,7 +911,44 @@ def main():
|
||||
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
sys.exit(app.exec())
|
||||
exit_code = app.exec()
|
||||
if restart_target is not None:
|
||||
if window.game_processes:
|
||||
window._terminate_game_processes()
|
||||
window.resetPlayButton()
|
||||
window._stopBackgroundWorkers()
|
||||
try:
|
||||
children = psutil.Process().children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(children, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
for child in alive:
|
||||
try:
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(alive, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
if alive:
|
||||
logger.error("Cannot restart while child processes are still running")
|
||||
sys.exit(1)
|
||||
if start_sh:
|
||||
subprocess.run(start_sh + ["cli", "--stop"], check=True,
|
||||
timeout=RESTART_STOP_TIMEOUT_SECONDS)
|
||||
instance_lock.unlock()
|
||||
os.execv(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", restart_target])
|
||||
except OSError as error:
|
||||
logger.error("Failed to restart PortProtonQt: %s", error)
|
||||
exit_code = 1
|
||||
except psutil.Error as error:
|
||||
logger.error("Failed to stop PortProtonQt child processes: %s", error)
|
||||
exit_code = 1
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.error("Failed to stop PortProton before restart: %s", error)
|
||||
exit_code = 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,19 +6,21 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtCore import QStandardPaths, QThread, Signal
|
||||
|
||||
from portprotonqt.config import get_portproton_location, migrate_legacy_shortcut
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
APP_ID = "ru.linux_gaming.PortProtonQt"
|
||||
MIME_PACKAGE_NAME = f"{APP_ID}.xml"
|
||||
DESKTOP_DATABASE_TIMEOUT = 10
|
||||
EXECUTABLE_MODE = 0o755
|
||||
WINDOWS_MIME_TYPES = (
|
||||
"application/x-ms-dos-executable;application/x-msdos-program;"
|
||||
"application/x-ms-dos-exec;application/x-executable;"
|
||||
"application/x-ms-dos-exec;"
|
||||
"application/x-dosexec;application/vnd.microsoft.portable-executable;"
|
||||
)
|
||||
INTEGRATION_MODES = ("log", "silent")
|
||||
@@ -53,10 +55,10 @@ def _remove_existing_integration(applications_dir: Path, icon_dir: Path) -> None
|
||||
(icon_dir / f"{APP_ID}{suffix}").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _rewrite_main_desktop(content: str, appimage: Path, icon: Path) -> str:
|
||||
def _rewrite_main_desktop(content: str, appimage: Path) -> str:
|
||||
exec_line = f"Exec={shlex.join([str(appimage), '%u'])}"
|
||||
content = re.sub(r"^Exec=.*$", exec_line, content, count=1, flags=re.MULTILINE)
|
||||
content = re.sub(r"^Icon=.*$", f"Icon={icon}", content, flags=re.MULTILINE)
|
||||
content = re.sub(r"^Icon=.*$", f"Icon={APP_ID}", content, flags=re.MULTILINE)
|
||||
if re.search(r"^TryExec=", content, flags=re.MULTILINE):
|
||||
return re.sub(
|
||||
r"^TryExec=.*$",
|
||||
@@ -68,7 +70,7 @@ def _rewrite_main_desktop(content: str, appimage: Path, icon: Path) -> str:
|
||||
return content.replace("Type=Application\n", f"Type=Application\nTryExec={appimage}\n", 1)
|
||||
|
||||
|
||||
def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
|
||||
def _mode_desktop(mode: str, appimage: Path) -> str:
|
||||
action = _("Run in logging mode") if mode == "log" else _("Run in silent mode")
|
||||
exec_line = shlex.join([str(appimage), f"--{mode}", "%u"])
|
||||
return (
|
||||
@@ -79,7 +81,7 @@ def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
|
||||
"Type=Application\n"
|
||||
f"Comment={action}\n"
|
||||
"Terminal=false\n"
|
||||
f"Icon={icon}\n"
|
||||
f"Icon={APP_ID}\n"
|
||||
f"StartupWMClass={APP_ID}\n"
|
||||
"Categories=Game;\n"
|
||||
"NoDisplay=true\n"
|
||||
@@ -92,17 +94,21 @@ def _install_desktop_files(
|
||||
source: Path,
|
||||
applications_dir: Path,
|
||||
appimage: Path,
|
||||
icon: Path,
|
||||
) -> None:
|
||||
content = source.read_text(encoding="utf-8")
|
||||
rewritten_content = _rewrite_main_desktop(content, appimage)
|
||||
main_path = applications_dir / f"{APP_ID}.desktop"
|
||||
main_path.write_text(
|
||||
_rewrite_main_desktop(content, appimage, icon),
|
||||
encoding="utf-8",
|
||||
main_path.write_text(rewritten_content, encoding="utf-8")
|
||||
desktop_dir = Path(
|
||||
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
)
|
||||
desktop_dir.mkdir(parents=True, exist_ok=True)
|
||||
desktop_path = desktop_dir / f"{APP_ID}.desktop"
|
||||
desktop_path.write_text(rewritten_content, encoding="utf-8")
|
||||
desktop_path.chmod(EXECUTABLE_MODE)
|
||||
for mode in INTEGRATION_MODES:
|
||||
path = applications_dir / f"{APP_ID}.{mode}.desktop"
|
||||
path.write_text(_mode_desktop(mode, appimage, icon), encoding="utf-8")
|
||||
path.write_text(_mode_desktop(mode, appimage), encoding="utf-8")
|
||||
|
||||
|
||||
def _set_default_mime_handlers() -> None:
|
||||
@@ -130,21 +136,33 @@ def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
|
||||
f"share/applications/{APP_ID}.desktop",
|
||||
),
|
||||
)
|
||||
icon = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
".DirIcon",
|
||||
f"{APP_ID}.svg",
|
||||
f"{APP_ID}.png",
|
||||
f"usr/share/icons/hicolor/scalable/apps/{APP_ID}.svg",
|
||||
f"share/icons/hicolor/scalable/apps/{APP_ID}.svg",
|
||||
),
|
||||
)
|
||||
if not desktop or not icon:
|
||||
icon = appdir / f"{APP_ID}.svg"
|
||||
if not desktop or not icon.is_file():
|
||||
raise FileNotFoundError("AppImage desktop metadata is incomplete")
|
||||
return desktop, icon
|
||||
|
||||
|
||||
def _install_mime_package(appdir: Path, data_home: Path) -> None:
|
||||
source = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
f"share/mime/packages/{MIME_PACKAGE_NAME}",
|
||||
f"usr/share/mime/packages/{MIME_PACKAGE_NAME}",
|
||||
),
|
||||
)
|
||||
if not source:
|
||||
raise FileNotFoundError("AppImage MIME metadata is incomplete")
|
||||
mime_dir = data_home / "mime"
|
||||
packages_dir = mime_dir / "packages"
|
||||
packages_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, packages_dir / MIME_PACKAGE_NAME)
|
||||
subprocess.run(
|
||||
["update-mime-database", str(mime_dir)],
|
||||
check=True,
|
||||
timeout=DESKTOP_DATABASE_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def integrate_appimage() -> Path:
|
||||
"""Install the running AppImage and its desktop handlers for this user."""
|
||||
source = Path(os.environ["APPIMAGE"]).expanduser()
|
||||
@@ -156,18 +174,27 @@ def integrate_appimage() -> Path:
|
||||
appimages_dir = Path.home() / "AppImages"
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
applications_dir = data_home / "applications"
|
||||
icon_dir = appimages_dir / ".icons"
|
||||
icon_dir = data_home / "icons"
|
||||
appimages_dir.mkdir(parents=True, exist_ok=True)
|
||||
applications_dir.mkdir(parents=True, exist_ok=True)
|
||||
icon_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
destination = appimages_dir / "portprotonqt.appimage"
|
||||
resolved_icon = icon_source.resolve()
|
||||
icon = icon_dir / f"{APP_ID}{resolved_icon.suffix or '.svg'}"
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
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()
|
||||
migrate_legacy_shortcut(
|
||||
portproton_location or "",
|
||||
launcher_command=[str(destination), "--silent"],
|
||||
)
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
shutil.copy2(resolved_icon, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination)
|
||||
_install_mime_package(appdir, data_home)
|
||||
_set_default_mime_handlers()
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -177,6 +204,8 @@ def integrate_appimage() -> Path:
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError) as error:
|
||||
logger.warning("Failed to update desktop database: %s", error)
|
||||
if legacy_appimage.resolve() != destination.resolve():
|
||||
legacy_appimage.unlink(missing_ok=True)
|
||||
return destination
|
||||
|
||||
|
||||
|
||||
@@ -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__)
|
||||
@@ -28,15 +29,15 @@ APPIMAGE_UPDATE_CHECK_TIMEOUT = 120
|
||||
APPIMAGE_UPDATE_TIMEOUT = 900
|
||||
APPIMAGE_UPDATE_START_DELAY_MS = 5000
|
||||
EXECUTABLE_MODE = 0o755
|
||||
FALLBACK_UPDATE_INFO = (
|
||||
"gh-releases-zsync|Boria138|PortProtonQt|latest|*x86_64.AppImage.zsync"
|
||||
GITHUB_UPDATE_INFO = (
|
||||
"gh-releases-zsync|linux-gaming-ru|PortProtonQt|latest|*x86_64.AppImage.zsync"
|
||||
)
|
||||
CHANGELOG_URL = (
|
||||
"https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/"
|
||||
"CHANGELOG.md"
|
||||
)
|
||||
CHANGELOG_FALLBACK_URL = (
|
||||
"https://raw.githubusercontent.com/Boria138/PortProtonQt/refs/heads/main/"
|
||||
CHANGELOG_GITHUB_URL = (
|
||||
"https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/refs/heads/main/"
|
||||
"CHANGELOG.md"
|
||||
)
|
||||
CHANGELOG_TIMEOUT = 10
|
||||
@@ -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:
|
||||
@@ -129,16 +130,22 @@ def _run_appimageupdatetool(
|
||||
|
||||
|
||||
def _download_changelog() -> str:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
session = get_requests_session()
|
||||
for url in (CHANGELOG_URL, CHANGELOG_FALLBACK_URL):
|
||||
try:
|
||||
with session.get(url, timeout=CHANGELOG_TIMEOUT) as response:
|
||||
response.raise_for_status()
|
||||
data = response.content[:CHANGELOG_MAX_CHARS]
|
||||
return data.decode("utf-8", errors="replace")
|
||||
except (OSError, requests.RequestException) as error:
|
||||
logger.debug("Failed to download changelog from %s: %s", url, error)
|
||||
return ""
|
||||
url = (
|
||||
CHANGELOG_URL
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD"
|
||||
else CHANGELOG_GITHUB_URL
|
||||
)
|
||||
try:
|
||||
with session.get(url, timeout=CHANGELOG_TIMEOUT) as response:
|
||||
response.raise_for_status()
|
||||
data = response.content[:CHANGELOG_MAX_CHARS]
|
||||
return data.decode("utf-8", errors="replace")
|
||||
except (OSError, requests.RequestException) as error:
|
||||
logger.debug("Failed to download changelog from %s: %s", url, error)
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_latest_version_changelog(changelog: str, current_version: str = "") -> str:
|
||||
@@ -306,15 +313,9 @@ class AppImageUpdateWorker(QThread):
|
||||
self.update_info = previous_info
|
||||
return result
|
||||
|
||||
def _is_check_failure(self, result: subprocess.CompletedProcess[str] | None) -> bool:
|
||||
if result is None:
|
||||
return True
|
||||
if result.returncode not in (0, 1):
|
||||
return True
|
||||
output = f"{result.stdout}\n{result.stderr}"
|
||||
return " Error:" in output or "Failed to fetch" in output
|
||||
|
||||
def run(self) -> None:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
appimage_path = _appimage_path()
|
||||
if not ui_config.get_auto_appimage_updates() or not appimage_path:
|
||||
return
|
||||
@@ -330,14 +331,10 @@ class AppImageUpdateWorker(QThread):
|
||||
self._run_update(tool_path, appimage_path)
|
||||
return
|
||||
|
||||
update_infos = ["", FALLBACK_UPDATE_INFO]
|
||||
check_code = None
|
||||
selected_info = ""
|
||||
for update_info in update_infos:
|
||||
check_code = self._check_for_update(tool_path, appimage_path, update_info)
|
||||
if not self._is_check_failure(check_code):
|
||||
selected_info = update_info
|
||||
break
|
||||
selected_info = (
|
||||
"" if get_user_conf_setting("MIRROR") == "CLOUD" else GITHUB_UPDATE_INFO
|
||||
)
|
||||
check_code = self._check_for_update(tool_path, appimage_path, selected_info)
|
||||
|
||||
if check_code is None or check_code.returncode != 1:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Theme boot video rendered inside the application's startup page."""
|
||||
import os
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPaintEvent
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import _find_theme_folder, _read_theme_parent_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtMultimedia import QVideoFrame
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def find_boot_video(theme_name: str) -> str | None:
|
||||
"""Find the theme's WebM, following its existing inheritance chain."""
|
||||
visited = set()
|
||||
while theme_name and theme_name not in visited:
|
||||
visited.add(theme_name)
|
||||
folder = _find_theme_folder(theme_name)
|
||||
if folder is not None:
|
||||
path = os.path.join(folder, "bootanimation.webm")
|
||||
if os.path.isfile(path):
|
||||
if os.path.dirname(os.path.realpath(path)) == os.path.realpath(folder):
|
||||
return path
|
||||
logger.warning("Boot video outside theme directory blocked: %s", path)
|
||||
return None
|
||||
theme_name = _read_theme_parent_name(theme_name) or ""
|
||||
return None
|
||||
|
||||
|
||||
class BootAnimation(QWidget):
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, theme: Any, video_path: str, parent: QWidget) -> None:
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer, QVideoSink
|
||||
|
||||
super().__init__(parent)
|
||||
self._background = QColor(theme.color_boot_animation)
|
||||
self._video_path = video_path
|
||||
self._frame = QImage()
|
||||
self._stopped = False
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.sink = QVideoSink(self)
|
||||
self.sink.videoFrameChanged.connect(self._receive_frame)
|
||||
self.audio = QAudioOutput(self)
|
||||
self.player = QMediaPlayer(self)
|
||||
self.player.setAudioOutput(self.audio)
|
||||
self.player.setVideoSink(self.sink)
|
||||
self.player.mediaStatusChanged.connect(self._media_status_changed)
|
||||
self.player.errorOccurred.connect(self._playback_error)
|
||||
self.timeout = QTimer(self)
|
||||
self.timeout.setSingleShot(True)
|
||||
self.timeout.setInterval(theme.bootAnimationTimeoutMs)
|
||||
self.timeout.timeout.connect(self.finish)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.installEventFilter(self)
|
||||
self.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
self.timeout.start()
|
||||
self.player.setSource(QUrl.fromLocalFile(self._video_path))
|
||||
if not self._stopped:
|
||||
self.player.play()
|
||||
|
||||
def _receive_frame(self, frame: "QVideoFrame") -> None:
|
||||
if self._stopped or not frame.isValid():
|
||||
return
|
||||
image = frame.toImage()
|
||||
if not image.isNull():
|
||||
self._frame = image
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), self._background)
|
||||
if not self._frame.isNull():
|
||||
size = self._frame.size().scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
|
||||
target = QRect(QPoint(), size)
|
||||
target.moveCenter(self.rect().center())
|
||||
painter.drawImage(target, self._frame)
|
||||
|
||||
def _media_status_changed(self, status: object) -> None:
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
|
||||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
self.finish()
|
||||
|
||||
def _playback_error(self, error: object, message: str) -> None:
|
||||
logger.warning("Boot video playback failed: %s", message)
|
||||
self.finish()
|
||||
|
||||
def finish(self) -> None:
|
||||
if not self._stopped:
|
||||
self.stop()
|
||||
self.finished.emit()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
self._stopped = True
|
||||
self.timeout.stop()
|
||||
self.player.stop()
|
||||
self.sink.videoFrameChanged.disconnect(self._receive_frame)
|
||||
self._frame = QImage()
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.removeEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if watched is self and event.type() in (QEvent.Type.KeyPress, QEvent.Type.MouseButtonPress):
|
||||
self.finish()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
+291
-3
@@ -2,6 +2,7 @@ import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +18,27 @@ def parse_args():
|
||||
Parses command-line arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
|
||||
library = parser.add_mutually_exclusive_group()
|
||||
library.add_argument(
|
||||
"--list-games", action="store_true",
|
||||
help="Print local PortProton, Steam, GOG and EGS library data as JSON and exit"
|
||||
)
|
||||
library.add_argument(
|
||||
"--game-info", metavar="ID",
|
||||
help="Print one game as JSON using its source-qualified ID from --list-games"
|
||||
)
|
||||
library.add_argument("--edit-game", metavar="ID", help="Edit a game's PortProton settings and print its updated data")
|
||||
parser.add_argument("--set", dest="settings", action="append", default=[], metavar="KEY=VALUE",
|
||||
help="Set a game setting; repeat to change multiple values with --edit-game")
|
||||
parser.add_argument("--all", dest="all_games", action="store_true",
|
||||
help="Include uninstalled games from cached libraries in --list-games")
|
||||
parser.add_argument("--fields", metavar="FIELD,...",
|
||||
help="Select JSON fields for --list-games or --game-info (e.g. executable,icon,name)")
|
||||
formatting = parser.add_mutually_exclusive_group()
|
||||
formatting.add_argument("--pretty", dest="pretty", action="store_true", default=True,
|
||||
help="Format JSON with indentation (default)")
|
||||
formatting.add_argument("--raw", dest="pretty", action="store_false",
|
||||
help="Print compact JSON without indentation")
|
||||
parser.add_argument(
|
||||
"--fullscreen",
|
||||
action="store_true",
|
||||
@@ -67,12 +89,12 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--silent",
|
||||
action="store_true",
|
||||
help="Launch supported Windows file in tray without showing the main window"
|
||||
help="Launch supported Windows file or native game in tray without showing the main window"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log",
|
||||
action="store_true",
|
||||
help="Enable PortProton logging for the launched Windows file"
|
||||
help="Enable logging for the launched Windows file or native game"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore-prefix",
|
||||
@@ -89,7 +111,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
'file_or_url',
|
||||
nargs='?',
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg) or portproton:// URL"
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg), native executable, or portproton:// URL"
|
||||
)
|
||||
if os.environ.get("STEAM_COMPAT") == "1" and "--" in sys.argv[1:]:
|
||||
separator_index = sys.argv.index("--")
|
||||
@@ -106,10 +128,276 @@ def parse_args():
|
||||
args = parser.parse_args()
|
||||
launch_args = []
|
||||
|
||||
if args.settings and not args.edit_game:
|
||||
parser.error("--set requires --edit-game")
|
||||
if args.edit_game and not args.settings:
|
||||
parser.error("--edit-game requires at least one --set KEY=VALUE")
|
||||
if args.all_games and not args.list_games:
|
||||
parser.error("--all requires --list-games")
|
||||
if args.fields is not None:
|
||||
if not (args.list_games or args.game_info):
|
||||
parser.error("--fields requires --list-games or --game-info")
|
||||
args.fields = [field.strip() for field in args.fields.split(",")]
|
||||
if any(not re.fullmatch(r"[a-z][a-z_0-9]*", field) for field in args.fields):
|
||||
parser.error("--fields requires comma-separated field names without empty entries")
|
||||
args.launch_args = launch_args
|
||||
return args
|
||||
|
||||
|
||||
def _read_game_settings(path: Path) -> dict[str, str]:
|
||||
"""Read literal shell assignments without executing configuration code."""
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
settings: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return settings
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
get_logger(__name__).warning("Failed to read %s: %s", path, error)
|
||||
return settings
|
||||
for line in content.splitlines():
|
||||
match = re.fullmatch(r"(?:export\s+)?([A-Za-z_][A-Za-z_0-9]*)=(.*)", line)
|
||||
if not match:
|
||||
match = re.fullmatch(r"check_variables ([A-Za-z_][A-Za-z_0-9]*) (.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
value = match[2]
|
||||
if any(token in value for token in ("$", "`")):
|
||||
continue
|
||||
try:
|
||||
lexer = shlex.shlex(value, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
parts = list(lexer)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(parts) <= 1:
|
||||
settings[match[1]] = parts[0] if parts else ""
|
||||
return settings
|
||||
|
||||
|
||||
def _get_game_metadata(name: str, exec_line: str, executable: str | None) -> dict:
|
||||
"""Read custom artwork and cached metadata without fetching remote resources."""
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.steam_api.api import get_cached_steam_game_info
|
||||
from portprotonqt.time_utils import (
|
||||
get_last_launch_timestamp, get_playtime_for_exe, get_statistics_path,
|
||||
)
|
||||
|
||||
metadata = get_cached_steam_game_info(name, exec_line)
|
||||
metadata.setdefault("name", name)
|
||||
metadata.setdefault("description", "")
|
||||
metadata.setdefault("cover", "")
|
||||
if not executable:
|
||||
return metadata
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
folder = Path(resolve_custom_data_dir(str(data_home / "PortProtonQt/custom_data"), executable))
|
||||
metadata["custom_data_directory"] = str(folder)
|
||||
metadata_file = folder / "metadata.txt"
|
||||
if metadata_file.is_file():
|
||||
translations = read_metadata_translations(str(metadata_file), get_metadata_language())
|
||||
metadata.update({key: value for key, value in translations.items() if value})
|
||||
for extension in COVER_IMAGE_EXTENSIONS:
|
||||
cover = folder / f"cover{extension}"
|
||||
if cover.is_file():
|
||||
metadata["cover"] = str(cover)
|
||||
break
|
||||
metadata["custom_icon"] = get_custom_icon_path(executable)
|
||||
metadata["playtime_seconds"] = get_playtime_for_exe(get_statistics_path(), executable)
|
||||
metadata["last_launch_timestamp"] = get_last_launch_timestamp(Path(executable).stem)
|
||||
return metadata
|
||||
|
||||
|
||||
def _get_game_runtime(
|
||||
location: Path, executable: str | None, variables: dict[str, str], overrides: dict[str, str]
|
||||
) -> dict:
|
||||
"""Export configured runtime values; never source PPDB files or launch Wine."""
|
||||
user_conf = location / "data/user.conf"
|
||||
ppdb = Path(f"{executable}.ppdb") if executable else None
|
||||
settings = _read_game_settings(ppdb) if ppdb else {}
|
||||
runner = settings.get("PW_WINE_USE", overrides.get("PW_DEFAULT_WINE_USE", variables.get("PW_WINE_USE")))
|
||||
prefix = settings.get("PW_PREFIX_NAME", overrides.get("PW_DEFAULT_PREFIX_NAME", variables.get("PW_PREFIX_NAME")))
|
||||
settings.update(overrides)
|
||||
runner = overrides.get("PW_WINE_VER") or overrides.get("PW_WINE_USE", runner)
|
||||
prefix = overrides.get("PW_PREFIX_NAME", prefix)
|
||||
resolved_runner = runner
|
||||
if runner == "PROTON_LG":
|
||||
resolved_runner = variables.get("PW_PROTON_LG_VER")
|
||||
elif runner and (runner == "WINE_LG" or runner.startswith("WINE_") and runner.endswith("_LG")):
|
||||
resolved_runner = variables.get("PW_WINE_LG_VER")
|
||||
runner_path = None
|
||||
if resolved_runner and resolved_runner != "USE_SYSTEM_WINE":
|
||||
runner_path = Path(resolved_runner) if Path(resolved_runner).is_absolute() else (
|
||||
location / "data/dist" / resolved_runner.upper()
|
||||
)
|
||||
elif resolved_runner == "USE_SYSTEM_WINE":
|
||||
wine = shutil.which("wine")
|
||||
runner_path = Path(wine).parent.parent if wine else None
|
||||
wine_directory = runner_path
|
||||
if wine_directory and (wine_directory / "files").is_dir() and not (wine_directory / "dist").is_dir():
|
||||
wine_directory /= "files"
|
||||
wine_bin = "bin-wow64" if settings.get("PW_USE_WOW64") == "1" and wine_directory and (
|
||||
wine_directory / "bin-wow64"
|
||||
).is_dir() else "bin"
|
||||
prefix_path = location / "data/prefixes" / prefix if prefix else None
|
||||
return {
|
||||
"wine_path": str(wine_directory / wine_bin / "wine") if wine_directory else None,
|
||||
"wineserver_path": str(wine_directory / wine_bin / "wineserver") if wine_directory else None,
|
||||
"runner": resolved_runner,
|
||||
"runner_path": str(runner_path) if runner_path else None,
|
||||
"runner_installed": runner_path.is_dir() if runner_path else False,
|
||||
"prefix": prefix, "prefix_path": str(prefix_path) if prefix_path else None,
|
||||
"settings": settings, "user_config": str(user_conf),
|
||||
"ppdb_exists": ppdb.is_file() if ppdb else False,
|
||||
}
|
||||
|
||||
|
||||
def _list_store_games(source: str, location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Export a cached GOG or Epic library using the existing store backends."""
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
api = GOGAPI() if source == "gog" else EGSAPI()
|
||||
installed = api.load_installed()
|
||||
games = []
|
||||
for record in api.load_library():
|
||||
app_id = str(record.get("app_id", ""))
|
||||
if not app_id:
|
||||
continue
|
||||
is_installed = api.is_game_installed(app_id, installed)
|
||||
target = api.get_launch_target(app_id) if is_installed else None
|
||||
name, cover = MainWindow._get_custom_store_data(source, app_id, str(record.get("title", app_id)))
|
||||
metadata = _get_game_metadata(name, target, target) if target else {}
|
||||
metadata.pop("name", None)
|
||||
icon = metadata.pop("custom_icon", "") or record.get("icon", "")
|
||||
games.append({
|
||||
**{key: value for key, value in record.items() if key not in {"app_id", "title"}},
|
||||
**metadata,
|
||||
"id": f"{source}:{app_id}", "source": source, "name": name,
|
||||
"description": record.get("description") or metadata.get("description", ""),
|
||||
"cover": cover or record.get("cover") or metadata.get("cover", ""),
|
||||
"icon": icon, "installed": is_installed,
|
||||
"exec": f"{source}://{'launch' if is_installed else 'install'}/{app_id}",
|
||||
"executable": target, "directory": str(Path(target).parent) if target else None,
|
||||
"runtime": _get_game_runtime(Path(location), target, variables, overrides) if location and target else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_steam_games() -> list[dict]:
|
||||
"""Export installed Steam games and their locally cached metadata."""
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import get_steam_installed_games
|
||||
from portprotonqt.steam_api.api import get_cached_full_steam_game_info
|
||||
|
||||
games = []
|
||||
for name, appid, last_played, playtime in get_steam_installed_games():
|
||||
metadata = get_cached_full_steam_game_info(appid, name)
|
||||
name, cover = MainWindow._get_custom_store_data("steam", appid, name)
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"steam:{appid}", "source": "steam", "name": name,
|
||||
"cover": cover or metadata.get("cover", ""),
|
||||
"installed": True, "exec": f"steam://rungameid/{appid}",
|
||||
"last_launch_timestamp": last_played, "playtime_seconds": playtime,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_portproton_games(location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Return local PortProton shortcuts without launching games or downloading metadata."""
|
||||
from portprotonqt.config.portproton import extract_exec_target_path, parse_desktop_entry
|
||||
|
||||
if not location:
|
||||
return []
|
||||
games = []
|
||||
for desktop_path in sorted(Path(location).glob("*.desktop")):
|
||||
entry = parse_desktop_entry(str(desktop_path))
|
||||
if entry is None:
|
||||
continue
|
||||
exec_line = entry.get("Exec", "")
|
||||
executable = extract_exec_target_path(exec_line)
|
||||
metadata = _get_game_metadata(entry.get("Name", desktop_path.stem), exec_line, executable)
|
||||
icon = metadata.pop("custom_icon", "") or entry.get("Icon", "")
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"portproton:{desktop_path.name}", "source": "portproton",
|
||||
"installed": bool(executable and Path(executable).is_file()),
|
||||
"name": metadata.get("name") or entry.get("Name", desktop_path.stem),
|
||||
"runtime": _get_game_runtime(Path(location), executable, variables, overrides),
|
||||
"desktop_file": str(desktop_path),
|
||||
"exec": exec_line,
|
||||
"executable": executable,
|
||||
"directory": str(Path(executable).parent) if executable else None,
|
||||
"icon": icon,
|
||||
"ppdb": f"{executable}.ppdb" if executable else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def list_games() -> list[dict]:
|
||||
"""Return locally available library data from all supported game sources."""
|
||||
from portprotonqt.config.portproton import get_portproton_location, get_portproton_scripts_path
|
||||
|
||||
location = get_portproton_location()
|
||||
scripts = get_portproton_scripts_path()
|
||||
variables = _read_game_settings(Path(scripts) / "var") if scripts else {}
|
||||
overrides = _read_game_settings(Path(location) / "data/user.conf") if location else {}
|
||||
games = _list_portproton_games(location, variables, overrides)
|
||||
games.extend(_list_steam_games())
|
||||
for source in ("gog", "egs"):
|
||||
games.extend(_list_store_games(source, location, variables, overrides))
|
||||
return games
|
||||
|
||||
|
||||
def edit_game(game_id: str, changes: list[str]) -> dict:
|
||||
"""Update a game's PPDB through PortProton and verify the saved settings."""
|
||||
import subprocess
|
||||
|
||||
from portprotonqt.config.portproton import WINDOWS_LAUNCH_EXTENSIONS, get_portproton_start_command
|
||||
from portprotonqt.settings_manager import ADVANCED_SETTING_KEYS, get_toggle_settings
|
||||
|
||||
game = next((game for game in list_games() if game["id"] == game_id), None)
|
||||
if game is None:
|
||||
raise ValueError(f"Game not found: {game_id}")
|
||||
executable = game.get("executable")
|
||||
if (game.get("source") not in {"portproton", "gog", "egs"} or not executable
|
||||
or not Path(executable).is_file() or Path(executable).suffix.lower() not in WINDOWS_LAUNCH_EXTENSIONS):
|
||||
raise ValueError("Editing requires an installed Windows game managed by PortProton, GOG or EGS")
|
||||
toggles = set(get_toggle_settings()) | {"PW_MANGOHUD", "PW_VKBASALT", "PW_GAMESCOPE"}
|
||||
allowed = set(ADVANCED_SETTING_KEYS) | toggles | set((game.get("runtime") or {}).get("settings", {}))
|
||||
values: dict[str, str] = {}
|
||||
for change in changes:
|
||||
key, separator, value = change.partition("=")
|
||||
if not separator or key not in allowed or not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key):
|
||||
raise ValueError(f"Unknown or invalid game setting: {key}")
|
||||
if any(character in value for character in ("$", "`", "|", "\n", "\r", "\0")):
|
||||
raise ValueError(f"Unsupported shell syntax in setting: {key}")
|
||||
if key in toggles and value not in {"0", "1"}:
|
||||
raise ValueError(f"{key} must be 0 or 1")
|
||||
if key == "PW_PREFIX_NAME" and (not value or value in {".", ".."} or "/" in value or "\\" in value):
|
||||
raise ValueError("PW_PREFIX_NAME must be a prefix name, not a path")
|
||||
values[key] = value
|
||||
if not values:
|
||||
raise ValueError("At least one setting is required")
|
||||
command = get_portproton_start_command()
|
||||
if not command:
|
||||
raise ValueError("PortProton start.sh was not found")
|
||||
result = subprocess.run(
|
||||
[*command, "cli", "--edit-db", executable, *[f"{key}={value}" for key, value in values.items()]],
|
||||
capture_output=True, text=True, errors="replace", check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"PortProton failed to edit game settings: {result.stderr.strip()}")
|
||||
saved = _read_game_settings(Path(f"{executable}.ppdb"))
|
||||
if any(saved.get(key) != value for key, value in values.items()):
|
||||
raise RuntimeError("PortProton did not save the requested settings; check PPDB permissions")
|
||||
return next(game for game in list_games() if game["id"] == game_id)
|
||||
|
||||
|
||||
def add_steam_compat_tool(force_install: bool = False) -> bool:
|
||||
"""
|
||||
Add PortProtonQt as a Steam compatibility tool.
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any
|
||||
|
||||
import orjson
|
||||
import pefile
|
||||
import psutil
|
||||
|
||||
from portprotonqt.debug_utils import (
|
||||
get_portproton_env,
|
||||
@@ -28,7 +29,7 @@ from portprotonqt.scripts_utils.graphics_detector import analyze_executable
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CRASH_THRESHOLD_SECONDS = 5
|
||||
CRASH_THRESHOLD_SECONDS = 7
|
||||
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
|
||||
PE_MACHINE_AMD64 = 0x8664
|
||||
COMPATIBILITY_SCAN_BYTES = 4_194_304
|
||||
@@ -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", [])
|
||||
@@ -428,6 +464,10 @@ def _compatibility_suggestions(
|
||||
needs_dotnet = needs_dotnet or "WPF" in findings.get("Frameworks", [])
|
||||
if needs_dotnet and prefix_name != "DOTNET":
|
||||
suggestions.append("Use the DOTNET prefix.")
|
||||
installers = findings.get("Installer", [])
|
||||
wine_version = environment.get("PW_WINE_USE", "")
|
||||
if installers and not wine_version.startswith("WINE_LG"):
|
||||
suggestions.append("Use WINE_LG for installers.")
|
||||
winetricks_log = environment.get("PW_WINETRICKS_LOG", "").lower()
|
||||
missing_vcrun = [
|
||||
components[-1]
|
||||
@@ -500,8 +540,6 @@ def analyze_launch(launch: CompatibilityLaunch, portproton_path: str) -> str:
|
||||
glibc_32_compatibility, glibc_32_suggestions = _glibc_32_compatibility()
|
||||
suggestions.extend(glibc_32_suggestions)
|
||||
lines = [
|
||||
"Compatibility report",
|
||||
"",
|
||||
f"Executable: {launch.executable}",
|
||||
f"Exit code: {launch.exit_code if launch.exit_code is not None else 'unknown'}",
|
||||
f"Runtime before exit: {launch.duration:.1f} s",
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Cache configuration and management."""
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -118,7 +117,7 @@ class CacheManager:
|
||||
temp_file = cache_file.with_name(f".{cache_file.name}.{time.time_ns()}.tmp")
|
||||
try:
|
||||
if pretty:
|
||||
content = json.dumps(data, indent=2).encode("utf-8")
|
||||
content = orjson.dumps(data, option=orjson.OPT_INDENT_2)
|
||||
else:
|
||||
content = orjson.dumps(data)
|
||||
with _CACHE_FILE_LOCK:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Game-related configuration settings."""
|
||||
from portprotonqt.config.base import BaseConfig
|
||||
from portprotonqt.config.validators import validate_string
|
||||
from portprotonqt.config.validators import validate_bool, validate_string
|
||||
|
||||
|
||||
class GameConfig(BaseConfig):
|
||||
@@ -26,6 +26,15 @@ class GameConfig(BaseConfig):
|
||||
validate_string(filter_value, "display_filter", min_len=1, max_len=50)
|
||||
self._save_value("display_filter", filter_value, "str")
|
||||
|
||||
def get_only_installed(self) -> bool:
|
||||
"""Get whether only installed games are displayed."""
|
||||
return self._get_bool("only_installed", False)
|
||||
|
||||
def set_only_installed(self, enabled: bool) -> None:
|
||||
"""Set whether only installed games are displayed."""
|
||||
validate_bool(enabled, "only_installed")
|
||||
self._save_value("only_installed", enabled, "bool")
|
||||
|
||||
def get_steam_account_id(self) -> str:
|
||||
"""Get selected Steam account ID or automatic selection."""
|
||||
return self._get_str("steam_account_id", "auto")
|
||||
|
||||
@@ -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():
|
||||
@@ -223,7 +229,16 @@ def _extract_launcher_tail(parts: list[str]) -> list[str] | None:
|
||||
return None
|
||||
|
||||
command_name = os.path.basename(parts[0])
|
||||
if parts[0].lower().endswith(".appimage") or command_name == "portprotonqt" or command_name == "start.sh":
|
||||
appimage_name = command_name.casefold()
|
||||
is_portproton_appimage = re.fullmatch(
|
||||
r"portproton(?:qt)?(?:[-_.].*)?\.appimage",
|
||||
appimage_name,
|
||||
) is not None
|
||||
if (
|
||||
is_portproton_appimage
|
||||
or command_name == "portprotonqt"
|
||||
or command_name == "start.sh"
|
||||
):
|
||||
tail = parts[1:]
|
||||
if tail[:1] == ["--silent"]:
|
||||
tail = tail[1:]
|
||||
@@ -274,7 +289,12 @@ def _get_desktop_paths(desktop_dir: str | None) -> tuple[str, ...]:
|
||||
return (os.path.join(os.path.expanduser("~"), "Desktop"),)
|
||||
|
||||
|
||||
def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None) -> int:
|
||||
# Remove after the legacy shortcut migration period.
|
||||
def migrate_legacy_shortcut(
|
||||
portproton_path: str,
|
||||
desktop_dir: str | None = None,
|
||||
launcher_command: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Migrate legacy PortProton shortcuts in known desktop directories."""
|
||||
flatpak_id = os.getenv("FLATPAK_ID", "").strip()
|
||||
user_home = os.path.expanduser("~")
|
||||
@@ -293,7 +313,8 @@ def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read legacy PortProton config %s: %s", PORTPROTON_CONFIG_FILE, error)
|
||||
|
||||
launcher_command = _get_current_launcher_command()
|
||||
if launcher_command is None:
|
||||
launcher_command = _get_current_launcher_command()
|
||||
desktop_paths = (
|
||||
portproton_path,
|
||||
os.path.join(user_home, ".local", "share", "applications"),
|
||||
@@ -434,7 +455,42 @@ def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None
|
||||
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"]
|
||||
@@ -525,6 +581,14 @@ def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
|
||||
return hashed_dir
|
||||
|
||||
|
||||
def get_custom_icon_path(exe_path: str) -> str:
|
||||
"""Return the existing custom icon for an executable."""
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
path = os.path.join(folder, "icon.png")
|
||||
return path if os.path.isfile(path) else ""
|
||||
|
||||
|
||||
def create_desktop_file(
|
||||
exe_path: str,
|
||||
game_name: str | None = None,
|
||||
@@ -532,8 +596,8 @@ def create_desktop_file(
|
||||
) -> tuple[str, str, str] | None:
|
||||
"""Create desktop entry content, destination path and icon path for a game."""
|
||||
portproton_path = get_portproton_location()
|
||||
is_gog_uri = re.fullmatch(r"gog://launch/\d+", exe_path) is not None
|
||||
if not is_gog_uri and not os.path.isfile(exe_path):
|
||||
is_store_uri = re.fullmatch(r"(?:gog|egs)://launch/[^/]+", exe_path) is not None
|
||||
if not is_store_uri and not os.path.isfile(exe_path):
|
||||
logger.error("Executable not found: %s", exe_path)
|
||||
return None
|
||||
if not portproton_path:
|
||||
@@ -542,13 +606,15 @@ 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))
|
||||
icon_path = (
|
||||
"applications-games" if is_gog_uri and not has_icon_source
|
||||
"applications-games" if is_store_uri and not has_icon_source
|
||||
else os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
)
|
||||
icon_path = get_custom_icon_path(exe_path) or icon_path
|
||||
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
|
||||
if icon_path != "applications-games":
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
|
||||
@@ -348,6 +348,31 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(hide, "hide_autoinstall_tab")
|
||||
self._save_value("hide_autoinstall_tab", hide, "bool")
|
||||
|
||||
def get_hide_control_hints(self) -> bool:
|
||||
"""Get hide control hints setting."""
|
||||
return self._get_bool("hide_control_hints", False)
|
||||
|
||||
def set_hide_control_hints(self, hide: bool) -> None:
|
||||
"""Set hide control hints setting."""
|
||||
validate_bool(hide, "hide_control_hints")
|
||||
self._save_value("hide_control_hints", hide, "bool")
|
||||
|
||||
def get_force_english(self) -> bool:
|
||||
"""Get forced English language setting."""
|
||||
cp = self._read_config()
|
||||
if cp is None:
|
||||
return False
|
||||
try:
|
||||
return cp.getboolean(self._section, "force_english", fallback=False)
|
||||
except (configparser.Error, ValueError) as e:
|
||||
logger.warning("Error reading force_english: %s", e)
|
||||
return False
|
||||
|
||||
def set_force_english(self, enabled: bool) -> None:
|
||||
"""Set forced English language setting."""
|
||||
validate_bool(enabled, "force_english")
|
||||
self._save_value("force_english", enabled, "bool")
|
||||
|
||||
def get_badge_view_mode(self) -> str:
|
||||
"""Get badge view mode ('detailed' or 'compact')."""
|
||||
mode = self._get_str("badge_view_mode", "detailed")
|
||||
@@ -358,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)
|
||||
@@ -407,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"))
|
||||
@@ -421,7 +476,7 @@ class UIConfig(BaseConfig):
|
||||
|
||||
def get_auto_download_ppdb(self) -> bool:
|
||||
"""Get PPDB auto-download setting."""
|
||||
return self._get_download_bool("auto_download_ppdb", True)
|
||||
return self._get_download_bool("auto_download_ppdb", False)
|
||||
|
||||
def set_auto_download_ppdb(self, enabled: bool) -> None:
|
||||
"""Set PPDB auto-download setting."""
|
||||
@@ -464,6 +519,24 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "crash_reports_enabled")
|
||||
self._save_value("crash_reports_enabled", enabled, "bool")
|
||||
|
||||
def get_disable_cyrillic_path_check(self) -> bool:
|
||||
"""Get Cyrillic launch path check setting."""
|
||||
return self._get_bool("disable_cyrillic_path_check", False)
|
||||
|
||||
def set_disable_cyrillic_path_check(self, enabled: bool) -> None:
|
||||
"""Set Cyrillic launch path check setting."""
|
||||
validate_bool(enabled, "disable_cyrillic_path_check")
|
||||
self._save_value("disable_cyrillic_path_check", enabled, "bool")
|
||||
|
||||
def get_disable_alt_i586_dependency_check(self) -> bool:
|
||||
"""Get ALT i586 dependency check setting."""
|
||||
return self._get_bool("disable_alt_i586_dependency_check", False)
|
||||
|
||||
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."""
|
||||
|
||||
@@ -2,12 +2,16 @@ import os
|
||||
import glob
|
||||
import shutil
|
||||
import tempfile
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess, QThread
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
get_portproton_location,
|
||||
extract_exec_target_path,
|
||||
favorites_config,
|
||||
favorites_folders_config,
|
||||
@@ -28,12 +32,16 @@ from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, build_native_shortcut_command, quote_native_arguments
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
|
||||
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)
|
||||
@@ -64,6 +72,14 @@ class ContextMenuManager:
|
||||
self.theme = theme
|
||||
self.theme_manager = ThemeManager()
|
||||
self.game_library_manager = game_library_manager
|
||||
self._batch_action = False
|
||||
self.card_selection_mode = False
|
||||
self._card_actions: list[QAction] = []
|
||||
self._card_action_menus: list[QMenu] = []
|
||||
self._card_actions_stopping = False
|
||||
self._card_action_timer = QTimer(parent)
|
||||
self._card_action_timer.setSingleShot(True)
|
||||
self._card_action_timer.timeout.connect(self._run_next_card_action)
|
||||
self.update_game_grid = game_library_manager.update_game_grid
|
||||
self.signals = ContextMenuSignals()
|
||||
self.signals.show_warning_dialog.connect(
|
||||
@@ -115,6 +131,8 @@ class ContextMenuManager:
|
||||
Returns:
|
||||
bool: True if the game is running, False otherwise.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
return getattr(self.parent, "target_exe", None) == os.path.basename(game_card.exec_line.removeprefix("native://"))
|
||||
if game_card.game_source == "steam":
|
||||
return False
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -351,8 +369,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -380,8 +398,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -445,7 +463,92 @@ class ContextMenuManager:
|
||||
return self.theme_manager.get_colored_icon_path(icon_name, color)
|
||||
return None
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
def clear_card_selection(self) -> None:
|
||||
self.card_selection_mode = False
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if card.batch_selected:
|
||||
card.batch_selected = False
|
||||
card.update()
|
||||
|
||||
def start_card_selection(self, game_card: "GameCard") -> None:
|
||||
self.clear_card_selection()
|
||||
self.card_selection_mode = True
|
||||
game_card.batch_selected = True
|
||||
game_card.update()
|
||||
game_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _start_card_actions(self, actions: list[QAction], menus: list[QMenu]) -> None:
|
||||
if self._batch_action or self._card_actions_stopping:
|
||||
return
|
||||
self.clear_card_selection()
|
||||
self._batch_action = True
|
||||
self._card_actions = list(reversed(actions))
|
||||
self._card_action_menus = menus
|
||||
for menu in menus:
|
||||
menu.setParent(self.parent, menu.windowFlags())
|
||||
self._run_next_card_action()
|
||||
|
||||
def _run_next_card_action(self) -> None:
|
||||
if not self._batch_action:
|
||||
return
|
||||
jobs = [getattr(self.parent, name, None) for name in (
|
||||
"egs_process", "gog_process", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker",
|
||||
)]
|
||||
jobs.extend(getattr(self.parent, "gog_support_workers", []))
|
||||
for job in jobs:
|
||||
if job is None or not isinstance(job, (QProcess, QThread)):
|
||||
continue
|
||||
job.finished.connect(lambda *args: self._card_action_timer.start() if not self._card_actions_stopping else None)
|
||||
if ((isinstance(job, QProcess) and job.state() == QProcess.ProcessState.NotRunning)
|
||||
or (isinstance(job, QThread) and not job.isRunning())):
|
||||
self._card_action_timer.start()
|
||||
return
|
||||
if self._card_actions_stopping or not self._card_actions:
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
self._card_actions = []
|
||||
return
|
||||
self._card_actions.pop().trigger()
|
||||
self._card_action_timer.start()
|
||||
|
||||
def stop_card_actions(self) -> None:
|
||||
self._card_actions_stopping = True
|
||||
self._card_action_timer.stop()
|
||||
self._card_actions = []
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
|
||||
def _show_card_selection_menu(self, menu: QMenu, game_card: "GameCard", pos: QPoint) -> None:
|
||||
grouped: dict[str, list[QAction]] = {}
|
||||
menus: list[QMenu] = []
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if not card.batch_selected:
|
||||
continue
|
||||
card_menu = self.show_context_menu(card, pos, build_only=True)
|
||||
if card_menu is None:
|
||||
continue
|
||||
card_menu.setParent(menu, card_menu.windowFlags())
|
||||
menus.append(card_menu)
|
||||
for action in card_menu.actions():
|
||||
if not action.isSeparator() and action.isEnabled():
|
||||
grouped.setdefault(action.text(), []).append(action)
|
||||
for text, actions in grouped.items():
|
||||
action = menu.addAction(actions[0].icon(), text)
|
||||
action.setEnabled(not self._batch_action)
|
||||
action.triggered.connect(
|
||||
lambda checked=False, actions=actions: self._start_card_actions(actions, menus)
|
||||
)
|
||||
menu.addSeparator()
|
||||
cancel_action = menu.addAction(self._get_safe_icon("cancel"), _("Cancel"))
|
||||
cancel_action.triggered.connect(self.clear_card_selection)
|
||||
menu.setActiveAction(next(action for action in menu.actions() if action.isEnabled() and not action.isSeparator()))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint, build_only: bool = False):
|
||||
"""
|
||||
Show the context menu for a game card at the specified position.
|
||||
|
||||
@@ -456,8 +559,19 @@ class ContextMenuManager:
|
||||
menu = QMenu(self.parent)
|
||||
menu.setWindowFlags(menu.windowFlags() | Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
if not build_only:
|
||||
if self.card_selection_mode or game_card.batch_selected:
|
||||
self._show_card_selection_menu(menu, game_card, pos)
|
||||
return
|
||||
self.clear_card_selection()
|
||||
selection_action = menu.addAction(self._get_safe_icon("edit"), _("Select multiple"))
|
||||
selection_action.triggered.connect(lambda: self.start_card_selection(game_card))
|
||||
menu.setActiveAction(selection_action)
|
||||
menu.addSeparator()
|
||||
|
||||
# For non-Steam games, check if exe exists
|
||||
if game_card.game_source == "portproton":
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -466,16 +580,18 @@ class ContextMenuManager:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
if build_only:
|
||||
return menu
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
# Normal menu for games with valid exe or from Steam
|
||||
is_uninstalled_gog = (
|
||||
game_card.game_source == "gog"
|
||||
and game_card.exec_line.startswith("gog://install/")
|
||||
is_uninstalled_store_game = (
|
||||
game_card.game_source in ("gog", "egs")
|
||||
and game_card.exec_line.startswith(("gog://install/", "egs://install/"))
|
||||
)
|
||||
is_running = self._is_game_running(game_card)
|
||||
if not is_uninstalled_gog:
|
||||
if not is_uninstalled_store_game:
|
||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||
action_icon = "stop" if is_running else "play"
|
||||
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
|
||||
@@ -543,6 +659,86 @@ 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)
|
||||
if game_card.exec_line.startswith("egs://launch/"):
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
)
|
||||
desktop_path = self._get_shortcut_path(game_card.name, desktop_dir)
|
||||
desktop_exists = os.path.exists(desktop_path)
|
||||
desktop_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if desktop_exists else "desktop"),
|
||||
_("Remove from Desktop") if desktop_exists else _("Add to Desktop"),
|
||||
)
|
||||
desktop_action.triggered.connect(
|
||||
lambda: self.remove_from_desktop(game_card.name)
|
||||
if desktop_exists
|
||||
else self.add_to_desktop(game_card.name, game_card.exec_line)
|
||||
)
|
||||
menu_path = self._get_menu_shortcut_path(game_card.name)
|
||||
menu_exists = os.path.exists(menu_path)
|
||||
menu_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if menu_exists else "menu"),
|
||||
_("Remove from Menu") if menu_exists else _("Add to Menu"),
|
||||
)
|
||||
menu_action.triggered.connect(
|
||||
lambda: self.remove_from_menu(game_card.name)
|
||||
if menu_exists
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
update_action = menu.addAction(
|
||||
self._get_safe_icon("update"), _("Update")
|
||||
)
|
||||
update_action.triggered.connect(
|
||||
lambda: self.parent._update_egs_game(app_id)
|
||||
)
|
||||
repair_action = menu.addAction(
|
||||
self._get_safe_icon("update"), _("Repair")
|
||||
)
|
||||
repair_action.triggered.connect(
|
||||
lambda: self.parent._repair_egs_game(app_id)
|
||||
)
|
||||
eos_enabled = self.parent._is_egs_overlay_enabled(app_id)
|
||||
eos_action = menu.addAction(
|
||||
self._get_safe_icon("update"),
|
||||
_("Disable EOS Overlay")
|
||||
if eos_enabled else _("Enable EOS Overlay"),
|
||||
)
|
||||
eos_action.triggered.connect(
|
||||
lambda: self.parent._enable_egs_overlay(app_id)
|
||||
)
|
||||
delete_action = menu.addAction(
|
||||
self._get_safe_icon("delete"), _("Delete")
|
||||
)
|
||||
delete_action.triggered.connect(
|
||||
lambda: self.parent._delete_egs_game(app_id)
|
||||
)
|
||||
else:
|
||||
import_action = menu.addAction(
|
||||
self._get_safe_icon("folder"), _("Import")
|
||||
)
|
||||
import_action.triggered.connect(
|
||||
lambda: self.parent._import_egs_game(app_id)
|
||||
)
|
||||
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 == "native":
|
||||
self._add_native_actions(menu, game_card)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
@@ -616,23 +812,121 @@ class ContextMenuManager:
|
||||
if os.path.exists(menu_path)
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
steam_icon_name = "delete" if is_in_steam else "menu_steam"
|
||||
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
if is_in_steam
|
||||
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if (
|
||||
game_card.game_source in ("portproton", "gog", "egs")
|
||||
and not is_uninstalled_store_game
|
||||
and get_steam_home() is not None
|
||||
):
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
steam_icon_name = "delete" if is_in_steam else "menu_steam"
|
||||
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
if is_in_steam
|
||||
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if build_only:
|
||||
return menu
|
||||
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
if actions:
|
||||
menu.setActiveAction(actions[0])
|
||||
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def _add_native_actions(self, menu: QMenu, card: "GameCard") -> None:
|
||||
source = Path(card.exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
managed = str(source.parent) == get_portproton_location() or source.parent == data / "PortProtonQt/native_games"
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
for directory, add_text, remove_text, icon in (
|
||||
(desktop, _("Add to Desktop"), _("Remove from Desktop"), "desktop"),
|
||||
(self._get_applications_dir() if managed else "", _("Add to Menu"), _("Remove from Menu"), "menu"),
|
||||
):
|
||||
if not directory:
|
||||
continue
|
||||
exists = (Path(directory) / source.name).is_file()
|
||||
action = menu.addAction(self._get_safe_icon("delete" if exists else icon), remove_text if exists else add_text)
|
||||
action.triggered.connect(lambda _checked=False, target=directory: self._toggle_native_shortcut(card.exec_line, target))
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(card.name)
|
||||
action = menu.addAction(self._get_safe_icon("delete" if is_in_steam else "menu_steam"),
|
||||
_("Remove from Steam") if is_in_steam else _("Add to Steam"))
|
||||
action.triggered.connect(lambda: self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
if is_in_steam else self.add_to_steam(card.name, card.exec_line, card.cover_path))
|
||||
action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
action.triggered.connect(lambda: self._edit_native_shortcut(card))
|
||||
if managed:
|
||||
action = menu.addAction(self._get_safe_icon("delete"), _("Delete"))
|
||||
action.triggered.connect(lambda: self.delete_game(card.name, card.exec_line))
|
||||
action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
action.triggered.connect(lambda: self.parent.detail_page_manager._open_executable_folder(card.exec_line))
|
||||
|
||||
def _toggle_native_shortcut(self, exec_line: str, directory: str) -> None:
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
target = Path(directory) / source.name
|
||||
try:
|
||||
if target.is_file():
|
||||
if target == source:
|
||||
retained = Path(get_portproton_location() or source.parent) / source.name
|
||||
retained.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, retained)
|
||||
target.unlink()
|
||||
else:
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(source.read_text(encoding="utf-8"))
|
||||
command = quote_native_arguments([exec_line])
|
||||
parser["Desktop Entry"]["Exec"] = build_native_shortcut_command(command)
|
||||
for action, mode in (("RunSilent", "--silent"), ("RunLog", "--log")):
|
||||
section = f"Desktop Action {action}"
|
||||
if parser.has_section(section):
|
||||
parser[section]["Exec"] = build_native_shortcut_command(command, mode)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with target.open("w", encoding="utf-8") as output:
|
||||
parser.write(output, space_around_delimiters=False)
|
||||
target.chmod(0o755)
|
||||
if directory == self._get_applications_dir():
|
||||
self._update_desktop_database()
|
||||
self.parent.refreshGames()
|
||||
except Exception as error:
|
||||
logger.warning("Cannot update native shortcut: %s", error)
|
||||
self.signals.show_warning_dialog.emit(_("Error"), str(error))
|
||||
|
||||
def _edit_native_shortcut(self, card: "GameCard") -> None:
|
||||
source = card.exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=card.name, exe_path=source, cover_path=card.cover_path)
|
||||
dialog.add_to_steam_checkbox.setChecked(is_game_in_steam(card.name))
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
name = dialog.nameEdit.text().strip()
|
||||
if not name:
|
||||
return
|
||||
cover = dialog.coverEdit.text().strip()
|
||||
if not os.path.isfile(cover) and dialog.last_cover_path:
|
||||
cover = dialog.last_cover_path
|
||||
try:
|
||||
self._save_local_shortcut_name(source, name)
|
||||
data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
directory = resolve_custom_data_dir(os.path.join(data, "PortProtonQt/custom_data"), source)
|
||||
if os.path.isfile(cover) and Path(cover).suffix.lower() in COVER_IMAGE_EXTENSIONS:
|
||||
target = Path(directory) / ("cover" + Path(cover).suffix.lower())
|
||||
if os.path.abspath(cover) != str(target):
|
||||
self._remove_old_cover_files(directory)
|
||||
shutil.copyfile(cover, target)
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(card.name):
|
||||
self.add_to_steam(name, card.exec_line, cover)
|
||||
elif is_game_in_steam(card.name):
|
||||
self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
self.parent.refreshGames()
|
||||
except OSError as error:
|
||||
logger.warning("Cannot edit native metadata: %s", error)
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
@@ -641,6 +935,9 @@ class ContextMenuManager:
|
||||
Args:
|
||||
game_card: The GameCard instance containing game data.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
self.parent.toggleGame(game_card.exec_line, game_name=game_card.name)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
@@ -723,7 +1020,9 @@ class ContextMenuManager:
|
||||
if not entry:
|
||||
continue
|
||||
exec_line = entry.get("Exec", entry.get("exec", ""))
|
||||
if script_name.lower() in exec_line.lower():
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if (exe_path and resolve_custom_data_dir(custom_root, exe_path)
|
||||
== os.path.dirname(metadata_path)) or script_name.lower() in exec_line.lower():
|
||||
return file_path
|
||||
except OSError:
|
||||
continue
|
||||
@@ -799,6 +1098,9 @@ class ContextMenuManager:
|
||||
|
||||
def _get_exec_line(self, game_name, exec_line):
|
||||
"""Retrieve and validate exec_line from .desktop file if necessary."""
|
||||
if exec_line and exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
entry = parse_desktop_entry(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
return entry.get("Exec") if entry else None
|
||||
if exec_line and exec_line.strip() != "full":
|
||||
return exec_line
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -897,12 +1199,14 @@ class ContextMenuManager:
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
from portprotonqt.time_utils import get_statistics_path
|
||||
from portprotonqt.time_utils import get_statistics_path, remove_last_launch
|
||||
statistics_file = get_statistics_path()
|
||||
target_path = os.path.normpath(exe_path)
|
||||
exe_name = os.path.splitext(os.path.basename(target_path))[0]
|
||||
remove_last_launch(exe_name)
|
||||
if not os.path.exists(statistics_file):
|
||||
return
|
||||
|
||||
target_path = os.path.normpath(exe_path)
|
||||
kept_lines = []
|
||||
removed = False
|
||||
|
||||
@@ -928,6 +1232,20 @@ class ContextMenuManager:
|
||||
|
||||
def _delete_game_without_confirm(self, game_name: str, exec_line: str) -> None:
|
||||
card_exec_line = exec_line
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
paths = [str(source), str(data / "PortProtonQt/native_games" / source.name),
|
||||
str(Path(self._get_applications_dir()) / source.name)]
|
||||
if desktop:
|
||||
paths.append(str(Path(desktop) / source.name))
|
||||
self._remove_installed_shortcuts(game_name, paths)
|
||||
self._remove_statistics_entry(str(source), game_name)
|
||||
self.game_library_manager.remove_game_incremental(game_name, exec_line)
|
||||
for key in ("all", "native", "favorites"):
|
||||
getattr(self.parent, "_loaded_library_cache", {}).pop(key, None)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -941,7 +1259,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}"),
|
||||
@@ -970,14 +1288,17 @@ class ContextMenuManager:
|
||||
self.game_library_manager.remove_game_incremental(game_name, card_exec_line or resolved_exec_line)
|
||||
|
||||
def delete_game(self, game_name, exec_line):
|
||||
if self._batch_action:
|
||||
self._delete_game_without_confirm(game_name, exec_line)
|
||||
return
|
||||
msg_box = QMessageBox(self.parent)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
msg_box.setWindowTitle(_("Confirm Deletion"))
|
||||
msg_box.setText(_("Are you sure you want to delete '{game_name}'? This will remove the .desktop file and custom data.").format(game_name=game_name))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -994,15 +1315,15 @@ class ContextMenuManager:
|
||||
"""Add the game desktop entry to the XDG applications directory."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_gog_uri and not os.path.exists(desktop_path):
|
||||
if not is_store_uri and not os.path.exists(desktop_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
if not is_gog_uri:
|
||||
if not is_store_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@@ -1010,9 +1331,9 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
if is_store_uri:
|
||||
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
@@ -1065,16 +1386,16 @@ class ContextMenuManager:
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_gog_uri and not os.path.exists(desktop_path):
|
||||
if not is_store_uri and not os.path.exists(desktop_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
# Ensure icon exists
|
||||
if not is_gog_uri:
|
||||
if not is_store_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@@ -1082,9 +1403,9 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
if is_store_uri:
|
||||
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
@@ -1218,29 +1539,35 @@ 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,
|
||||
exe_path=f"{source}://launch/{app_id}" if source in {"gog", "egs"} else None)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
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()
|
||||
@@ -1252,22 +1579,111 @@ 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 _save_local_shortcut_name(self, exe_path: str, name: str) -> None:
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
|
||||
game_dir = resolve_custom_data_dir(custom_root, exe_path)
|
||||
os.makedirs(game_dir, exist_ok=True)
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
metadata_lines = []
|
||||
if os.path.isfile(metadata_path):
|
||||
with open(metadata_path, encoding="utf-8") as metadata_file:
|
||||
metadata_lines = [line for line in metadata_file if not line.startswith(("name=", "name_"))]
|
||||
clean_name = " ".join(name.splitlines()).strip()
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=game_dir, delete=False) as metadata_file:
|
||||
temp_path = metadata_file.name
|
||||
metadata_file.write(f"name={clean_name}\n" + "".join(metadata_lines))
|
||||
os.replace(temp_path, metadata_path)
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
|
||||
"""
|
||||
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
|
||||
@@ -1278,7 +1694,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
|
||||
@@ -1286,7 +1702,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(
|
||||
@@ -1308,7 +1724,12 @@ class ContextMenuManager:
|
||||
_("Game name and executable path are required")
|
||||
)
|
||||
return
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
old_entry = parse_desktop_entry(old_path)
|
||||
original_name = old_entry.get("Name", game_name) if old_entry else game_name
|
||||
dialog.nameEdit.setText(original_name)
|
||||
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
||||
dialog.nameEdit.setText(new_name)
|
||||
if not desktop_entry or not new_desktop_path:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1316,25 +1737,33 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
if game_name != new_name and os.path.exists(old_path):
|
||||
self._remove_file(
|
||||
old_path,
|
||||
_("Failed to delete old .desktop file: {error}"),
|
||||
_("Removed old .desktop file for '{game_name}'"),
|
||||
game_name
|
||||
)
|
||||
|
||||
new_desktop_path = old_path
|
||||
moved_paths = []
|
||||
try:
|
||||
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
|
||||
if exe_path == new_exe_path and os.path.isfile(old_path):
|
||||
with open(old_path, encoding="utf-8") as source:
|
||||
desktop_entry = source.read()
|
||||
if new_name != game_name:
|
||||
self._save_local_shortcut_name(new_exe_path, new_name)
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
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)
|
||||
saved_entry = parse_desktop_entry(new_desktop_path)
|
||||
updated_exec_line = new_exe_path
|
||||
if saved_entry:
|
||||
@@ -1354,11 +1783,9 @@ class ContextMenuManager:
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
get_custom_data_dir_name(new_exe_path),
|
||||
custom_folder = resolve_custom_data_dir(
|
||||
os.path.join(xdg_data_home, "PortProtonQt", "custom_data"),
|
||||
new_exe_path,
|
||||
)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
|
||||
@@ -1395,8 +1822,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):
|
||||
@@ -1407,19 +1834,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:
|
||||
@@ -1443,26 +1873,30 @@ class ContextMenuManager:
|
||||
"""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
if not exec_line.startswith((NATIVE_URI_PREFIX, "gog://launch/", "egs://launch/")):
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Adding game '%s' to Steam", game_name)
|
||||
try:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path, icon_source)
|
||||
else:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1484,27 +1918,25 @@ class ContextMenuManager:
|
||||
def on_remove_from_steam_result(result: tuple[bool, str]):
|
||||
success, message = result
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam.").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_(message).format(game_name=game_name)
|
||||
)
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
if not exec_line.startswith(NATIVE_URI_PREFIX) and not extract_exec_target_path(exec_line):
|
||||
return
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
try:
|
||||
|
||||
+166
-14
@@ -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.QtCore import QObject, Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QMouseEvent, QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -17,6 +18,19 @@ class CustomComboBox(QComboBox):
|
||||
super().__init__(parent)
|
||||
self.theme = theme
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
line_edit = self.lineEdit()
|
||||
if (
|
||||
watched is line_edit and line_edit is not None and line_edit.isReadOnly()
|
||||
and isinstance(event, QMouseEvent) and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
if event.type() == QEvent.Type.MouseButtonPress:
|
||||
return True
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
self.showPopup()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
line_edit = self.lineEdit()
|
||||
if line_edit is None:
|
||||
@@ -40,6 +54,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
result = [[0, 0, 0, 0] for _ in range(N)]
|
||||
min_margin = 20
|
||||
available_width = rect_width - 2 * min_margin
|
||||
width_sums = list(accumulate((size[0] for size in nat_sizes), initial=0))
|
||||
|
||||
# Fast search for max items per row
|
||||
max_items_per_row = 1
|
||||
@@ -55,7 +70,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
end_idx = min(i + mid, N)
|
||||
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
|
||||
sum_w = width_sums[end_idx] - width_sums[i]
|
||||
needed_width = sum_w + spacing * (mid - 1)
|
||||
|
||||
if needed_width <= available_width:
|
||||
@@ -65,7 +80,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
right = mid - 1
|
||||
|
||||
count = best_count
|
||||
sum_width = sum(nat_sizes[j][0] for j in range(i, i + count))
|
||||
sum_width = width_sums[i + count] - width_sums[i]
|
||||
|
||||
if count > max_items_per_row:
|
||||
max_items_per_row = count
|
||||
@@ -89,7 +104,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
end_idx = min(i + mid, N)
|
||||
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
|
||||
sum_w = width_sums[end_idx] - width_sums[i]
|
||||
needed_width = sum_w + spacing * (mid - 1)
|
||||
|
||||
if needed_width <= available_width:
|
||||
@@ -135,7 +150,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
y += int(row_max_height * global_scale) + spacing
|
||||
i = j
|
||||
|
||||
return result, y
|
||||
return result, y - spacing
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
@@ -151,22 +166,29 @@ 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 = []
|
||||
|
||||
for i, item in enumerate(self.itemList):
|
||||
widget = item.widget()
|
||||
if widget and widget.isVisible():
|
||||
if widget and not widget.isHidden():
|
||||
visible_items.append(item)
|
||||
visible_indices.append(i)
|
||||
s = item.sizeHint()
|
||||
s = widget.minimumSize()
|
||||
if s != widget.maximumSize():
|
||||
s = item.sizeHint()
|
||||
visible_sizes.append((s.width(), s.height()))
|
||||
|
||||
return visible_items, visible_indices, visible_sizes
|
||||
self._cache_visible_data = (visible_items, visible_indices, visible_sizes)
|
||||
return self._cache_visible_data
|
||||
|
||||
def _make_visible_hash(self, visible_sizes):
|
||||
"""Create hash for change detection"""
|
||||
@@ -176,6 +198,12 @@ class FlowLayout(QLayout):
|
||||
self.itemList.append(item)
|
||||
self._invalidate_cache()
|
||||
|
||||
def insertWidget(self, index: int, widget: QWidget) -> None:
|
||||
self.addWidget(widget)
|
||||
item = self.takeAt(self.count() - 1)
|
||||
self.itemList.insert(index, item)
|
||||
self.invalidate()
|
||||
|
||||
def takeAt(self, index: int) -> QLayoutItem:
|
||||
if 0 <= index < len(self.itemList):
|
||||
self._invalidate_cache()
|
||||
@@ -186,11 +214,18 @@ 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()
|
||||
super().invalidate()
|
||||
|
||||
def setSpacing(self, spacing: int) -> None:
|
||||
self._spacing = spacing
|
||||
self._invalidate_cache()
|
||||
super().setSpacing(spacing)
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self.itemList)
|
||||
|
||||
@@ -242,12 +277,20 @@ class FlowLayout(QLayout):
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self):
|
||||
if self._cache_minimum_size is not None:
|
||||
return QSize(self._cache_minimum_size)
|
||||
size = QSize()
|
||||
for item in self.itemList:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
widget = item.widget()
|
||||
if (widget is not None and not widget.isHidden()
|
||||
and widget.minimumSize() == widget.maximumSize()):
|
||||
size = size.expandedTo(widget.minimumSize())
|
||||
else:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
margins = self.contentsMargins()
|
||||
size += QSize(margins.left() + margins.right(),
|
||||
margins.top() + margins.bottom())
|
||||
self._cache_minimum_size = QSize(size)
|
||||
return size
|
||||
|
||||
def doLayout(self, rect, testOnly):
|
||||
@@ -286,10 +329,18 @@ class FlowLayout(QLayout):
|
||||
|
||||
if not testOnly:
|
||||
rx, ry = rect.x(), rect.y()
|
||||
row_bounds = {}
|
||||
if self.alignment() & Qt.AlignmentFlag.AlignHCenter:
|
||||
for x, y, w, _ in geom_array:
|
||||
left = row_bounds.get(y, (x, x))[0]
|
||||
row_bounds[y] = (left, x + w)
|
||||
|
||||
# Set geometry for visible items
|
||||
for idx, item in enumerate(visible_items):
|
||||
x, y, w, h = geom_array[idx]
|
||||
if y in row_bounds:
|
||||
left, right = row_bounds[y]
|
||||
x += max(0, (rect.width() - (right - left)) // 2) - left
|
||||
item.setGeometry(QRect(x + rx, y + ry, w, h))
|
||||
|
||||
# Hide invisible items
|
||||
@@ -542,7 +593,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)
|
||||
@@ -569,6 +621,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)
|
||||
@@ -671,7 +735,7 @@ class AutoSizeButton(QPushButton):
|
||||
|
||||
if icon_color and self._icon_name:
|
||||
colored_path = self.theme_manager.get_colored_icon_path(
|
||||
self._icon_name,
|
||||
icon_path,
|
||||
icon_color,
|
||||
self.current_theme_name
|
||||
)
|
||||
@@ -822,6 +886,8 @@ class NavLabel(QLabel):
|
||||
super().mousePressEvent(event)
|
||||
|
||||
class AutoHideScrollArea(QScrollArea):
|
||||
viewportResized = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
theme,
|
||||
@@ -837,21 +903,33 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self.fade_duration_ms = fade_duration_ms
|
||||
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||
self.setStyleSheet("background: transparent;")
|
||||
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()
|
||||
|
||||
@@ -859,7 +937,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()
|
||||
@@ -870,6 +953,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:
|
||||
@@ -894,17 +996,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)
|
||||
@@ -913,27 +1026,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:
|
||||
@@ -942,6 +1086,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:
|
||||
|
||||
@@ -6,6 +6,9 @@ import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
from typing import IO
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import get_portproton_location
|
||||
@@ -18,7 +21,7 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DebugLogManager:
|
||||
"""Manages debug log creation with game launch and Wine output capture."""
|
||||
"""Manage debug logs for Wine and native game launches."""
|
||||
|
||||
def __init__(self):
|
||||
self.process: subprocess.Popen | None = None
|
||||
@@ -26,15 +29,19 @@ class DebugLogManager:
|
||||
self.start_command: list[str] | None = None
|
||||
self.wine_output: list[str] = []
|
||||
self.runtime_env: dict[str, str] | None = None
|
||||
self.native_output: IO[str] | None = None
|
||||
self.native_directory: str | None = None
|
||||
self.is_running = False
|
||||
self.output_queue = queue.Queue()
|
||||
self.output_thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self, exe_path: str, start_command: list[str]) -> bool:
|
||||
"""Start game with PW_LOG=1 and capture output."""
|
||||
"""Capture game output, enabling PW_LOG for PortProton launches."""
|
||||
if self.is_running:
|
||||
return False
|
||||
if exe_path.startswith("native://"):
|
||||
return self._start_native(exe_path)
|
||||
|
||||
self.exe_path = exe_path
|
||||
self.start_command = start_command
|
||||
@@ -86,6 +93,52 @@ class DebugLogManager:
|
||||
logger.error("Failed to start debug session: %s", e)
|
||||
return False
|
||||
|
||||
def _start_native(self, uri: str) -> bool:
|
||||
from portprotonqt.native_games import get_native_launch
|
||||
|
||||
try:
|
||||
command, directory = get_native_launch(uri.removeprefix("native://"))
|
||||
self.native_output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace")
|
||||
self.process = subprocess.Popen(
|
||||
command, cwd=directory, stdout=self.native_output,
|
||||
stderr=subprocess.STDOUT, start_new_session=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to start native debug session: %s", error)
|
||||
if self.native_output is not None:
|
||||
self.native_output.close()
|
||||
self.native_output = None
|
||||
return False
|
||||
self.exe_path = uri.removeprefix("native://")
|
||||
self.start_command = command
|
||||
self.native_directory = directory
|
||||
self.is_running = True
|
||||
return True
|
||||
|
||||
def _save_native_log(self) -> str | None:
|
||||
output = self.native_output
|
||||
if output is None:
|
||||
return None
|
||||
from portprotonqt.native_games import load_native_settings, get_native_ppdb_path
|
||||
|
||||
try:
|
||||
settings = load_native_settings(str(self.exe_path))
|
||||
destination = get_native_ppdb_path(str(self.exe_path), settings).with_suffix(".log")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.seek(0)
|
||||
with destination.open("w", encoding="utf-8") as log:
|
||||
log.write(generate_system_info("native://" + str(self.exe_path), self.start_command))
|
||||
log.write(f"\nCommand: {self.start_command!r}\nWorking directory: {self.native_directory}\n\n")
|
||||
shutil.copyfileobj(output, log)
|
||||
return str(destination)
|
||||
except OSError as error:
|
||||
logger.error("Failed to save native debug log: %s", error)
|
||||
return None
|
||||
finally:
|
||||
output.close()
|
||||
self.native_output = None
|
||||
self.native_directory = None
|
||||
|
||||
def _read_output(self):
|
||||
"""Read output from subprocess in a separate thread."""
|
||||
if self.process and self.process.stdout:
|
||||
@@ -98,19 +151,20 @@ class DebugLogManager:
|
||||
logger.debug("Error reading output: %s", e)
|
||||
|
||||
def stop(self) -> str | None:
|
||||
"""Stop game and save debug log with captured Wine output."""
|
||||
self._capture_env_vars()
|
||||
"""Stop the game and save its diagnostic log."""
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
self._stop_event.set()
|
||||
|
||||
if self.is_running and self.process:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
|
||||
if self.process.poll() is None:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -123,7 +177,7 @@ class DebugLogManager:
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
log_file = self._save_log()
|
||||
log_file = self._save_native_log() if self.native_output is not None else self._save_log()
|
||||
|
||||
if self.process and self.process.stdout:
|
||||
try:
|
||||
@@ -146,7 +200,8 @@ class DebugLogManager:
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
self._capture_env_vars()
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
return True
|
||||
|
||||
def _capture_env_vars(self) -> None:
|
||||
|
||||
@@ -19,15 +19,16 @@ def get_cached_vk_gpu_info() -> str:
|
||||
|
||||
if _vk_gpu_info_output is None:
|
||||
try:
|
||||
dev_scripts_path = os.path.join(
|
||||
vk_gpu_info_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
"dev-scripts",
|
||||
"build-aux",
|
||||
"bin",
|
||||
"vk_gpu_info"
|
||||
)
|
||||
|
||||
if os.path.exists(dev_scripts_path):
|
||||
if os.path.exists(vk_gpu_info_path):
|
||||
result = subprocess.run(
|
||||
[dev_scripts_path],
|
||||
[vk_gpu_info_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
|
||||
@@ -290,7 +290,8 @@ def get_filesystem_info(exe_path: str | None, portproton_path: str) -> str:
|
||||
game_dir = os.path.dirname(exe_path)
|
||||
lines.append(f"Filesystem {game_dir} - {get_fs_type(game_dir)}")
|
||||
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
if portproton_path:
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
|
||||
tmp_dir = f"/tmp/PortProton_{os.environ.get('USER', 'user')}"
|
||||
if os.path.exists(tmp_dir):
|
||||
@@ -313,8 +314,17 @@ def generate_system_info(
|
||||
get_d3d_extras_status,
|
||||
)
|
||||
|
||||
portproton_path = get_portproton_location()
|
||||
if not portproton_path:
|
||||
from portprotonqt.native_games import load_native_settings, get_native_launch, get_native_ppdb_path
|
||||
|
||||
native = bool(exe_path and exe_path.startswith("native://"))
|
||||
desktop_path = exe_path.removeprefix("native://") if native and exe_path else None
|
||||
if desktop_path:
|
||||
runtime_env = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
exe_path = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), directory)
|
||||
portproton_path = get_portproton_location() or ""
|
||||
if not portproton_path and not native:
|
||||
return "Error: PortProton location not found"
|
||||
|
||||
lines = []
|
||||
@@ -322,7 +332,7 @@ def generate_system_info(
|
||||
lines.append(_("Debug log mode was launched"))
|
||||
lines.append(
|
||||
_("To diagnose the problem, copy the ENTIRE log to the site:") +
|
||||
" https://linux-gaming.ru/t/opisanie-kategorii-portproton-pomoshh/1642"
|
||||
" https://linux-gaming.ru/forum/help/portprotonqt-pomosch"
|
||||
)
|
||||
lines.append("-" * 61)
|
||||
|
||||
@@ -344,16 +354,17 @@ def generate_system_info(
|
||||
lines.append(get_libc_version())
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
if not native:
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Date and time of start debug for PortProton:")
|
||||
lines.append(datetime.now().strftime("%c %z"))
|
||||
@@ -419,26 +430,28 @@ def generate_system_info(
|
||||
lines.append(locale_avail)
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
if not native:
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
if exe_path:
|
||||
ppdb_content = get_ppdb_content(exe_path, start_cmd)
|
||||
ppdb_path = str(get_native_ppdb_path(desktop_path, runtime_env)).removesuffix(".ppdb") if desktop_path and runtime_env else exe_path
|
||||
ppdb_content = get_file_content(ppdb_path + ".ppdb") if native else get_ppdb_content(exe_path, start_cmd)
|
||||
if ppdb_content:
|
||||
lines.append(f"Use {exe_path}.ppdb db file:")
|
||||
lines.append(f"Use {ppdb_path}.ppdb db file:")
|
||||
if ppdb_log_vars:
|
||||
lines.append("\n".join(ppdb_log_vars))
|
||||
lines.append(ppdb_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
user_overrides = get_user_overrides(portproton_path)
|
||||
user_overrides = "" if native else get_user_overrides(portproton_path)
|
||||
if user_overrides:
|
||||
lines.append(user_overrides)
|
||||
lines.append("-" * 61)
|
||||
|
||||
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from portprotonqt.native_games import get_native_launch, load_native_settings
|
||||
|
||||
from portprotonqt.detail_pages.widgets import (
|
||||
create_scroll_area,
|
||||
create_back_button,
|
||||
@@ -86,10 +88,16 @@ class DetailPageManager:
|
||||
self._gog_size_requests: dict[
|
||||
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
|
||||
] = {}
|
||||
self._egs_size_requests: dict[
|
||||
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
|
||||
] = {}
|
||||
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open detailed game information page."""
|
||||
self._current_detail_source = ("game", dict(game_data))
|
||||
self._return_to_library_card_key = (
|
||||
game_data.get("name", ""), game_data.get("exec_line", "")
|
||||
)
|
||||
detail_page = QWidget()
|
||||
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
|
||||
@@ -310,6 +318,7 @@ class DetailPageManager:
|
||||
game_info_layout.addLayout(first_row)
|
||||
|
||||
self._setup_gog_size_data(game_data, game_info_layout)
|
||||
self._setup_egs_size_data(game_data, game_info_layout)
|
||||
|
||||
hltb_layout = QHBoxLayout()
|
||||
hltb_layout.setSpacing(10)
|
||||
@@ -365,6 +374,75 @@ class DetailPageManager:
|
||||
)
|
||||
process.start()
|
||||
|
||||
def _setup_egs_size_data(
|
||||
self, game_data: dict, game_info_layout: QVBoxLayout
|
||||
) -> None:
|
||||
exec_line = str(game_data.get("exec_line", ""))
|
||||
if str(game_data.get("game_source", "")).lower() != "egs":
|
||||
return
|
||||
if not exec_line.startswith("egs://install/"):
|
||||
return
|
||||
row = QHBoxLayout()
|
||||
values = []
|
||||
for title_text in (_("Download"), _("Size")):
|
||||
title = QLabel(title_text.upper())
|
||||
title.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||
value = QLabel("—")
|
||||
value.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||
row.addWidget(title)
|
||||
row.addWidget(value)
|
||||
row.addSpacing(30)
|
||||
values.append(weakref.ref(value))
|
||||
game_info_layout.addLayout(row)
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
api = self.main_window.egs_api
|
||||
cached = api.get_cached_download_sizes(app_id)
|
||||
if cached is not None:
|
||||
self._set_gog_size_values(values, cached)
|
||||
return
|
||||
request = self._egs_size_requests.get(app_id)
|
||||
if request is not None:
|
||||
request[1].append(values)
|
||||
return
|
||||
try:
|
||||
command = api.build_command(["info", app_id, "--json", "--platform", "Windows"])
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.warning("Failed to get EGS download size: %s", error)
|
||||
return
|
||||
process = QProcess(self.main_window)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
environment.insert("LEGENDARY_CONFIG_PATH", str(api.config_dir))
|
||||
process.setProcessEnvironment(environment)
|
||||
self._egs_size_requests[app_id] = (process, [values])
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._finish_egs_size_request(app_id, code)
|
||||
)
|
||||
process.start()
|
||||
|
||||
def _finish_egs_size_request(self, app_id: str, code: int) -> None:
|
||||
request = self._egs_size_requests.pop(app_id, None)
|
||||
if request is None:
|
||||
return
|
||||
process, value_groups = request
|
||||
if code != 0:
|
||||
logger.warning("legendary info exited with code %s", code)
|
||||
process.deleteLater()
|
||||
return
|
||||
try:
|
||||
sizes = self.main_window.egs_api.parse_download_sizes(
|
||||
bytes(process.readAllStandardOutput().data())
|
||||
)
|
||||
except (ValueError, TypeError) as error:
|
||||
logger.warning("Failed to parse EGS download size: %s", error)
|
||||
process.deleteLater()
|
||||
return
|
||||
self.main_window.egs_api.save_download_sizes(app_id, sizes)
|
||||
for value_refs in value_groups:
|
||||
self._set_gog_size_values(value_refs, sizes)
|
||||
process.deleteLater()
|
||||
|
||||
def _finish_gog_size_request(self, app_id: str, code: int) -> None:
|
||||
request = self._gog_size_requests.pop(app_id, None)
|
||||
if request is None:
|
||||
@@ -531,23 +609,32 @@ class DetailPageManager:
|
||||
|
||||
current_exe = self._get_current_exe(exec_line)
|
||||
play_button = self._create_play_button(exec_line, current_exe)
|
||||
if str(game_source).lower() == "gog" and exec_line.startswith("gog://install/"):
|
||||
source = str(game_source).lower()
|
||||
install_uri = exec_line.startswith(f"{source}://install/")
|
||||
if source in {"gog", "egs"} and install_uri:
|
||||
play_button.setText(_("Install"))
|
||||
play_button.setIcon(
|
||||
self.main_window.theme_manager.get_icon("update", as_path=True)
|
||||
)
|
||||
buttons_layout.addWidget(play_button)
|
||||
|
||||
if str(game_source).lower() == "steam" and appid:
|
||||
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(
|
||||
@@ -570,7 +657,7 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() != "gog"
|
||||
str(game_source).lower() not in {"gog", "egs", "native"}
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
@@ -585,24 +672,46 @@ class DetailPageManager:
|
||||
buttons_layout.addWidget(add_button)
|
||||
|
||||
# Show settings button for PortProton games or Steam games using PortProtonQt
|
||||
if str(game_source).lower() == "portproton":
|
||||
if source == "native":
|
||||
settings_button = self._make_action_button(
|
||||
_("Settings"), self.main_window.theme_manager.get_icon("settings", as_path=True),
|
||||
)
|
||||
settings_button.clicked.connect(lambda: self.main_window.open_native_settings(exec_line))
|
||||
buttons_layout.addWidget(settings_button)
|
||||
log_button = self._make_action_button(_("Create Log"), self.main_window.theme_manager.get_icon("edit", as_path=True))
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(exec_line, log_button))
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
if os.path.dirname(exec_line.removeprefix("native://")) in (
|
||||
getattr(self.main_window, "portproton_location", None), os.path.join(data_home, "PortProtonQt/native_games")
|
||||
):
|
||||
folder_button = self._make_action_button(_("Open Folder"), self.main_window.theme_manager.get_icon("search", as_path=True))
|
||||
folder_button.clicked.connect(lambda: self._open_executable_folder(exec_line))
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
str(game_source).lower() == "gog"
|
||||
source in {"gog", "egs"}
|
||||
and appid
|
||||
and exec_line.startswith("gog://launch/")
|
||||
and exec_line.startswith(f"{source}://launch/")
|
||||
):
|
||||
gog_exe = self.main_window.gog_api.get_launch_target(str(appid))
|
||||
if not gog_exe:
|
||||
store_api = (
|
||||
self.main_window.gog_api if source == "gog"
|
||||
else self.main_window.egs_api
|
||||
)
|
||||
store_exe = store_api.get_launch_target(str(appid))
|
||||
if not store_exe:
|
||||
return buttons_layout
|
||||
self.main_window.gog_api.ensure_launch_parameters(str(appid))
|
||||
if source == "gog":
|
||||
store_api.ensure_launch_parameters(str(appid))
|
||||
settings_button = self._make_action_button(
|
||||
_("Settings"),
|
||||
self.main_window.theme_manager.get_icon("settings", as_path=True),
|
||||
)
|
||||
settings_button.clicked.connect(
|
||||
lambda: self.main_window.open_exe_settings(
|
||||
gog_exe, game_source="gog",
|
||||
store_exe, game_source=source,
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(settings_button)
|
||||
@@ -610,7 +719,9 @@ class DetailPageManager:
|
||||
_("Create Log"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
)
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(gog_exe, log_button))
|
||||
log_button.clicked.connect(
|
||||
lambda: self.toggleDebugLog(store_exe, log_button)
|
||||
)
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
folder_button = self._make_action_button(
|
||||
@@ -618,7 +729,7 @@ class DetailPageManager:
|
||||
self.main_window.theme_manager.get_icon("search", as_path=True),
|
||||
)
|
||||
folder_button.clicked.connect(
|
||||
lambda: self._open_executable_folder(gog_exe)
|
||||
lambda: self._open_executable_folder(store_exe)
|
||||
)
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "steam" and appid:
|
||||
@@ -633,6 +744,8 @@ class DetailPageManager:
|
||||
|
||||
def _get_current_exe(self, exec_line: str) -> str | None:
|
||||
"""Extract current executable from exec line."""
|
||||
if exec_line.startswith("native://"):
|
||||
return os.path.basename(exec_line.removeprefix("native://"))
|
||||
file_to_check = extract_exec_target_path(exec_line)
|
||||
return os.path.basename(file_to_check) if file_to_check else None
|
||||
|
||||
@@ -730,9 +843,26 @@ class DetailPageManager:
|
||||
|
||||
def _open_executable_folder(self, exe_path: str | None) -> None:
|
||||
"""Open the folder containing an executable."""
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
if not exe_path:
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
if exe_path.startswith("native://"):
|
||||
try:
|
||||
desktop_path = exe_path.removeprefix("native://")
|
||||
settings = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
executable = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), None)
|
||||
folder_path = os.path.dirname(executable) if executable else settings.get("Path", "")
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
raise ValueError("Game directory is not available")
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native game folder: %s", error)
|
||||
QMessageBox.warning(self.main_window, _("Error"), str(error))
|
||||
return
|
||||
else:
|
||||
if not os.path.exists(exe_path):
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
|
||||
def _has_game_shortcut(self, game_name: str) -> bool:
|
||||
@@ -833,17 +963,32 @@ class DetailPageManager:
|
||||
return_tab_index = getattr(self, "_return_to_tab_index", 0)
|
||||
current_page = self._current_detail_page
|
||||
running_button = self.main_window.current_running_button
|
||||
debug_log_button = self._debug_log_button
|
||||
replace_running_button = (
|
||||
current_page is not None
|
||||
and running_button is not None
|
||||
and current_page.isAncestorOf(running_button)
|
||||
)
|
||||
replace_debug_log_button = (
|
||||
current_page is not None
|
||||
and debug_log_button is not None
|
||||
and current_page.isAncestorOf(debug_log_button)
|
||||
)
|
||||
self._remove_current_detail_page()
|
||||
if source_kind == "autoinstall":
|
||||
self.openAutoInstallDetailPage(source_data)
|
||||
else:
|
||||
self.openGameDetailPage(source_data)
|
||||
self._return_to_tab_index = return_tab_index
|
||||
if (
|
||||
replace_debug_log_button
|
||||
and self.debug_log_manager.is_running
|
||||
and self._debug_log_button is not None
|
||||
):
|
||||
self._debug_log_button.setText(_("Stop Log"))
|
||||
icon = self.main_window.theme_manager.get_icon("stop", as_path=True)
|
||||
if icon:
|
||||
self._debug_log_button.setIcon(icon)
|
||||
if replace_running_button and self._current_detail_page is not None:
|
||||
play_button = self._find_play_button(self._current_detail_page)
|
||||
if play_button is not None:
|
||||
@@ -919,6 +1064,9 @@ class DetailPageManager:
|
||||
)
|
||||
)
|
||||
content_frame_layout.addLayout(page_data["buttons_layout"])
|
||||
content_frame_layout.addSpacing(
|
||||
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
|
||||
)
|
||||
|
||||
def _get_compact_content_spacing(self) -> int:
|
||||
return getattr(
|
||||
@@ -1094,6 +1242,9 @@ class DetailPageManager:
|
||||
)
|
||||
)
|
||||
content_frame_layout.addLayout(page_data["buttons_layout"])
|
||||
content_frame_layout.addSpacing(
|
||||
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
|
||||
)
|
||||
|
||||
main_layout = self._get_main_layout(detail_page)
|
||||
if main_layout:
|
||||
@@ -1341,12 +1492,15 @@ class DetailPageManager:
|
||||
"""Start debug logging session."""
|
||||
if exe_path is None:
|
||||
return
|
||||
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
|
||||
native = exe_path.startswith("native://")
|
||||
resolved_exe_path = exe_path if native else self.main_window.resolve_launch_file_path(exe_path)
|
||||
if resolved_exe_path is None:
|
||||
return
|
||||
if not native and not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
|
||||
start_command = get_portproton_start_command()
|
||||
if not start_command:
|
||||
start_command = [] if native else get_portproton_start_command()
|
||||
if start_command is None:
|
||||
return
|
||||
|
||||
if self.debug_log_manager.start(resolved_exe_path, start_command):
|
||||
@@ -1368,7 +1522,7 @@ class DetailPageManager:
|
||||
msg_box.setWindowTitle(_("Log Saved"))
|
||||
msg_box.setText(_("Debug log saved to:") + f"\n{log_file}")
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
|
||||
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
|
||||
|
||||
result = msg_box.exec()
|
||||
|
||||
@@ -1526,10 +1680,15 @@ class DetailPageManager:
|
||||
container = getattr(self.main_window, "gamesListWidget", None)
|
||||
if container is None:
|
||||
return
|
||||
manager = getattr(self.main_window, "game_library_manager", None)
|
||||
card_key = getattr(self, "_return_to_library_card_key", None)
|
||||
card = getattr(manager, "game_card_cache", {}).get(card_key)
|
||||
cards = [
|
||||
widget for widget in container.findChildren(QWidget)
|
||||
if widget.__class__.__name__ == "GameCard"
|
||||
]
|
||||
if card is not None and card.isVisible():
|
||||
cards = [card]
|
||||
if not cards:
|
||||
return
|
||||
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
|
||||
|
||||
@@ -4,9 +4,9 @@ import os
|
||||
import re
|
||||
from weakref import WeakKeyDictionary
|
||||
from collections.abc import Callable
|
||||
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsOpacityEffect
|
||||
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsBlurEffect, QGraphicsOpacityEffect, QGraphicsPixmapItem, QGraphicsScene
|
||||
from PySide6.QtCore import QTimer, Qt, QObject, Signal, QPropertyAnimation, QByteArray
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtGui import QBrush, QImage, QPainter, QPalette, QPixmap
|
||||
|
||||
from portprotonqt.image_utils import get_animated_cover_pixmap
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners, set_animated_cover
|
||||
@@ -85,7 +85,7 @@ def _set_animated_cover(
|
||||
detail_page, image_label = cover_widgets
|
||||
if not set_animated_cover(image_label, cover_path, cover_size[0], cover_size[1], 10):
|
||||
return False
|
||||
_setup_palette_stylesheet(detail_page, get_animated_cover_pixmap(image_label), main_window)
|
||||
setup_cover_background(detail_page, get_animated_cover_pixmap(image_label), main_window)
|
||||
_reveal_cover(image_label, main_window.theme)
|
||||
return True
|
||||
|
||||
@@ -99,7 +99,7 @@ def _on_pixmap_ready(pixmap: QPixmap, detail_page: QWidget, image_label: QLabel,
|
||||
image_label.setPixmap(rounded)
|
||||
_reveal_cover(image_label, main_window.theme)
|
||||
logger.debug("Pixmap set for imageLabel")
|
||||
_setup_palette_stylesheet(detail_page, pixmap, main_window)
|
||||
setup_cover_background(detail_page, pixmap, main_window)
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping pixmap update")
|
||||
|
||||
@@ -118,7 +118,16 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
|
||||
effect = QGraphicsOpacityEffect(image_label)
|
||||
image_label.setGraphicsEffect(effect)
|
||||
|
||||
duration = theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
||||
animation_type = theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||
duration_key = "detail_page_fade_duration"
|
||||
default_duration = 350
|
||||
if animation_type.startswith("slide_"):
|
||||
duration_key = "detail_page_slide_duration"
|
||||
default_duration = 500
|
||||
elif animation_type == "bounce":
|
||||
duration_key = "detail_page_bounce_duration"
|
||||
default_duration = 400
|
||||
duration = theme.GAME_CARD_ANIMATION.get(duration_key, default_duration)
|
||||
animation = QPropertyAnimation(effect, QByteArray(b"opacity"), image_label)
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(effect.opacity())
|
||||
@@ -129,25 +138,75 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
|
||||
animation.start()
|
||||
|
||||
|
||||
def _setup_palette_stylesheet(detail_page: QWidget, pixmap: QPixmap | None, main_window) -> None:
|
||||
def setup_cover_background(
|
||||
detail_page: QWidget, pixmap: QPixmap | None, main_window, theme=None
|
||||
) -> None:
|
||||
"""Setup palette-based stylesheet after image load."""
|
||||
if pixmap is None:
|
||||
return
|
||||
active_theme = theme or main_window.theme
|
||||
if getattr(active_theme, "DETAIL_PAGE_BG_MODE", "gradient") == "blur":
|
||||
_apply_blurred_cover(detail_page, pixmap, active_theme)
|
||||
return
|
||||
|
||||
def on_palette_ready(palette: list) -> None:
|
||||
_on_palette_ready(palette, detail_page, main_window)
|
||||
_on_palette_ready(palette, detail_page, main_window, active_theme)
|
||||
|
||||
num_colors = getattr(main_window.theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
|
||||
num_colors = getattr(active_theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
|
||||
if num_colors < 2:
|
||||
num_colors = 2
|
||||
main_window.getColorPalette_from_pixmap(pixmap, num_colors=num_colors, callback=on_palette_ready)
|
||||
|
||||
|
||||
def _on_palette_ready(palette: list, detail_page: QWidget, main_window) -> None:
|
||||
def remove_cover_background(widget: QWidget) -> None:
|
||||
"""Remove a shared cover-driven background and stop its animation."""
|
||||
_remove_wave_background(widget)
|
||||
_detail_backgrounds.remove(widget)
|
||||
widget.setStyleSheet("")
|
||||
widget.setAutoFillBackground(False)
|
||||
if isinstance(widget, QLabel):
|
||||
widget.clear()
|
||||
|
||||
|
||||
def _on_palette_ready(
|
||||
palette: list, detail_page: QWidget, main_window, theme
|
||||
) -> None:
|
||||
"""Handle palette ready callback."""
|
||||
if not _is_detail_page_valid(detail_page):
|
||||
return
|
||||
_apply_palette_stylesheet(detail_page, palette, main_window)
|
||||
_apply_palette_stylesheet(detail_page, palette, main_window, theme)
|
||||
|
||||
|
||||
def _apply_blurred_cover(widget: QWidget, pixmap: QPixmap, theme) -> None:
|
||||
config = theme.DETAIL_PAGE_BACKGROUNDS.get("blur", {})
|
||||
width, height = config.get("render_resolution", (1280, 720))
|
||||
scaled = pixmap.scaled(
|
||||
width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
source = scaled.copy(
|
||||
(scaled.width() - width) // 2, (scaled.height() - height) // 2,
|
||||
width, height,
|
||||
)
|
||||
item = QGraphicsPixmapItem(source)
|
||||
effect = QGraphicsBlurEffect()
|
||||
effect.setBlurRadius(config.get("radius", 64))
|
||||
item.setGraphicsEffect(effect)
|
||||
scene = QGraphicsScene()
|
||||
scene.addItem(item)
|
||||
image = QImage(source.size(), QImage.Format.Format_ARGB32_Premultiplied)
|
||||
image.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(image)
|
||||
scene.render(painter)
|
||||
painter.end()
|
||||
background = QPixmap.fromImage(image).scaled(widget.size())
|
||||
if isinstance(widget, QLabel):
|
||||
widget.setPixmap(background)
|
||||
return
|
||||
palette = widget.palette()
|
||||
palette.setBrush(QPalette.ColorRole.Window, QBrush(background))
|
||||
widget.setPalette(palette)
|
||||
widget.setAutoFillBackground(True)
|
||||
|
||||
|
||||
def _build_palette_stops(dark_palette: list) -> str:
|
||||
@@ -219,14 +278,16 @@ def _resolve_gradient_config(theme: object) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window) -> None:
|
||||
def _apply_palette_stylesheet(
|
||||
detail_page: QWidget, palette: list, main_window, theme
|
||||
) -> None:
|
||||
"""Apply palette-based stylesheet to detail page."""
|
||||
try:
|
||||
dark_palette = [
|
||||
main_window.darkenColor(color, factor=200) for color in palette
|
||||
]
|
||||
gradient_config = _resolve_gradient_config(main_window.theme)
|
||||
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
|
||||
gradient_config = _resolve_gradient_config(theme)
|
||||
stops = _resolve_gradient_stops(theme, dark_palette)
|
||||
gradient_type = gradient_config.get("type", "linear")
|
||||
|
||||
if gradient_type == 'radial':
|
||||
@@ -259,7 +320,7 @@ def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window)
|
||||
"""
|
||||
|
||||
detail_page.setStyleSheet(stylesheet)
|
||||
_setup_wave_background(detail_page, dark_palette, main_window.theme)
|
||||
_setup_wave_background(detail_page, dark_palette, theme)
|
||||
detail_page.update()
|
||||
logger.debug("Stylesheet updated with palette")
|
||||
except RuntimeError:
|
||||
@@ -278,7 +339,7 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
|
||||
return
|
||||
_detail_backgrounds.remove(detail_page)
|
||||
|
||||
if bg_mode == "gradient":
|
||||
if bg_mode not in {"waves", "static_waves"}:
|
||||
_remove_wave_background(detail_page)
|
||||
return
|
||||
|
||||
@@ -644,11 +705,6 @@ def _check_desktop_file(
|
||||
return autoinstall_exe.lower() in (exec_name, real_exec_name)
|
||||
|
||||
|
||||
def _get_autoinstall_exe_name(script_name: str) -> str:
|
||||
autoinstall_exe, _autoinstall_path = _get_autoinstall_exe_target(script_name)
|
||||
return autoinstall_exe
|
||||
|
||||
|
||||
def _get_autoinstall_exe_target(script_name: str) -> tuple[str, str]:
|
||||
script_path = script_name
|
||||
if not os.path.isfile(script_path):
|
||||
@@ -689,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:
|
||||
|
||||
@@ -84,6 +84,7 @@ def create_content_frame(
|
||||
) -> tuple[QFrame, QBoxLayout]:
|
||||
"""Create content frame."""
|
||||
content_frame = QFrame()
|
||||
content_frame.setProperty("theme_style_name", "DETAIL_CONTENT_FRAME_STYLE")
|
||||
content_frame.setStyleSheet(theme.DETAIL_CONTENT_FRAME_STYLE)
|
||||
content_frame_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, content_frame)
|
||||
content_frame_layout.setContentsMargins(20, 20, 20, 20)
|
||||
@@ -139,7 +140,7 @@ def _add_source_corner(
|
||||
) -> None:
|
||||
"""Add source corner badge to cover frame."""
|
||||
source = game_source.lower()
|
||||
if source not in ("steam", "gog", "portproton") or theme_manager is None:
|
||||
if source not in ("steam", "gog", "egs", "portproton") or theme_manager is None:
|
||||
return
|
||||
|
||||
icon_name = f"badge_{source}"
|
||||
@@ -182,6 +183,7 @@ def create_compact_detail_header(
|
||||
header_widget.setFixedHeight(cover_frame.height())
|
||||
|
||||
title_frame = QFrame(header_widget)
|
||||
title_frame.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
|
||||
title_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
title_frame.setMaximumHeight(cover_frame.height())
|
||||
title_frame.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
|
||||
@@ -192,6 +194,7 @@ def create_compact_detail_header(
|
||||
title_layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
||||
title_label = QLabel(title)
|
||||
title_label.setWordWrap(True)
|
||||
title_label.setProperty("theme_style_name", "COMPACT_DETAIL_PAGE_TITLE_STYLE")
|
||||
title_label.setStyleSheet(theme.COMPACT_DETAIL_PAGE_TITLE_STYLE)
|
||||
title_layout.addWidget(title_label, alignment=Qt.AlignmentFlag.AlignVCenter)
|
||||
header_layout.addWidget(title_frame, stretch=1)
|
||||
@@ -201,6 +204,7 @@ def create_compact_detail_header(
|
||||
def create_compact_layout_panel(parent: QWidget, theme, content_layout: QLayout) -> QWidget:
|
||||
"""Create compact panel for an existing layout."""
|
||||
panel = QWidget(parent)
|
||||
panel.setProperty("theme_style_name", "COMPACT_PLAYTIME_WIDGET_STYLE")
|
||||
panel.setStyleSheet(theme.COMPACT_PLAYTIME_WIDGET_STYLE)
|
||||
panel.setLayout(content_layout)
|
||||
content_layout.setContentsMargins(
|
||||
@@ -218,6 +222,7 @@ def create_compact_description_panel(
|
||||
) -> QWidget:
|
||||
"""Create compact description block."""
|
||||
desc_widget = QWidget(parent)
|
||||
desc_widget.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
|
||||
desc_widget.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
|
||||
desc_layout = QVBoxLayout(desc_widget)
|
||||
desc_layout.setContentsMargins(
|
||||
@@ -323,28 +328,6 @@ def create_protondb_badge(
|
||||
return badge, True
|
||||
|
||||
|
||||
def create_steam_badge(
|
||||
parent: QWidget,
|
||||
appid: str,
|
||||
main_window,
|
||||
) -> ClickableLabel:
|
||||
"""Create Steam badge."""
|
||||
steam_icon = main_window.theme_manager.get_icon("badge_steam", as_path=True)
|
||||
badge = ClickableLabel(
|
||||
"Steam",
|
||||
icon=steam_icon,
|
||||
parent=parent,
|
||||
icon_size=getattr(main_window.theme, "BADGE", {}).get("icon_size", 16),
|
||||
icon_space=5,
|
||||
)
|
||||
badge.setStyleSheet(main_window.theme.STEAM_BADGE_STYLE)
|
||||
_apply_badge_view_mode(badge, main_window.theme)
|
||||
badge.clicked.connect(
|
||||
lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}"))
|
||||
)
|
||||
return badge
|
||||
|
||||
|
||||
def create_portproton_badge(
|
||||
parent: QWidget,
|
||||
main_window,
|
||||
@@ -419,6 +402,7 @@ def create_details_widget(
|
||||
) -> QWidget:
|
||||
"""Create details widget with title, description, and optional content."""
|
||||
details_widget = QWidget()
|
||||
details_widget.setProperty("theme_style_name", "DETAILS_WIDGET_STYLE")
|
||||
details_widget.setStyleSheet(main_window.theme.DETAILS_WIDGET_STYLE)
|
||||
|
||||
details_layout = QVBoxLayout(details_widget)
|
||||
@@ -461,6 +445,7 @@ def create_details_widget(
|
||||
def _add_details_header(layout: QVBoxLayout, theme, title: str) -> None:
|
||||
"""Add title and divider line to details layout."""
|
||||
title_label = QLabel(title)
|
||||
title_label.setProperty("theme_style_name", "DETAIL_PAGE_TITLE_STYLE")
|
||||
title_label.setStyleSheet(theme.DETAIL_PAGE_TITLE_STYLE)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ class AppImageUpdateDialog(DraggableDialog):
|
||||
|
||||
if self.input_manager:
|
||||
self.input_manager.enable_appimage_update_mode(self)
|
||||
self.finished.connect(self.input_manager.disable_appimage_update_mode)
|
||||
|
||||
def _make_button(self, text: str) -> AutoSizeButton:
|
||||
button = AutoSizeButton(text)
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
"""Base dialogs for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
import tempfile
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
import psutil
|
||||
import orjson
|
||||
import pefile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
from portprotonqt.config import (
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
THEMED_LAUNCH_ICON_NAMES,
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
get_portproton_location,
|
||||
parse_desktop_entry,
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.native_games import create_native_desktop_file, is_native_executable
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
@@ -28,6 +40,7 @@ from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS, set_animated_cover
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
|
||||
logger = get_logger(__name__)
|
||||
theme_manager = ThemeManager()
|
||||
@@ -125,16 +138,241 @@ class GameLaunchDialog(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
class GameFolderScanWorker(QThread):
|
||||
"""Find executable files recursively and report scanned directory counts."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, folder: str, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.folder = folder
|
||||
self.games: list[tuple[str, str]] = []
|
||||
self.errors: list[str] = []
|
||||
|
||||
MAX_SCAN_DEPTH = 5
|
||||
MAX_SCAN_DIRECTORIES = 400
|
||||
GAME_SUBDIRECTORIES = frozenset((
|
||||
"bin", "bin32", "bin64", "binaries", "win32", "win64", "x64", "x86", "game", "disccontentpc", "api",
|
||||
))
|
||||
|
||||
def run(self) -> None:
|
||||
for directory, paths in self._find_executables(self.folder).items():
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
executable = max(paths, key=lambda path: self._executable_score(path, directory))
|
||||
self.games.append((self._game_name(executable, directory), executable))
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
self.games.sort(key=lambda game: game[1].casefold())
|
||||
|
||||
def _executable_score(self, executable: str, directory: str) -> tuple[int, int, int]:
|
||||
name = Path(executable).stem.casefold()
|
||||
folder = re.sub(r"[\W_]", "", os.path.basename(directory).casefold())
|
||||
key = re.sub(r"[\W_]", "", name)
|
||||
match = int(key == folder) + int(bool(key and folder and (key in folder or folder in key)))
|
||||
launcher = name.endswith(("launcher", "redirector")) or name in ("play", "start", "run")
|
||||
depth = len(Path(executable).relative_to(directory).parts)
|
||||
return match - int(launcher), -depth, os.path.getsize(executable)
|
||||
|
||||
def _executable_name(self, executable: str) -> str:
|
||||
try:
|
||||
with pefile.PE(executable, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
tables = [table for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", [])]
|
||||
metadata = {key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for table in tables for key, value in table.entries.items()}
|
||||
except pefile.PEFormatError:
|
||||
return ""
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", executable, error)
|
||||
return ""
|
||||
for field in ("ProductName", "FileDescription"):
|
||||
value = metadata.get(field, "").strip()
|
||||
if value and not re.fullmatch(
|
||||
r"(?i)(?:BootstrapPackagedGame|game|application|launcher|.*launcher.*|.*redistributable.*)", value,
|
||||
):
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _game_name(self, executable: str, directory: str) -> str:
|
||||
name = ""
|
||||
for info in sorted(Path(directory).glob("goggame-*.info")):
|
||||
try:
|
||||
data = orjson.loads(info.read_bytes().removeprefix(b"\xef\xbb\xbf"))
|
||||
if (isinstance(data, dict) and isinstance(data.get("name"), str)
|
||||
and data.get("gameId") == data.get("rootGameId")):
|
||||
name = data["name"]
|
||||
break
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read game metadata %s: %s", info, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid game metadata %s: %s", info, error)
|
||||
if not name:
|
||||
name = self._executable_name(executable) or Path(directory).name
|
||||
name = re.sub(r"(?i)[._ ]+(?:v?\d+(?:[._]\d+)+|build[._ ]?\d+).*$", "", name)
|
||||
name = name.replace(".", " ")
|
||||
name = re.sub(r"[\\/\r\n]", " ", name).replace(":", " - ")
|
||||
return " ".join(name.replace("_", " ").split()).strip(" .") or Path(executable).stem
|
||||
|
||||
def _record_error(self, error: OSError) -> None:
|
||||
logger.warning("Failed to scan game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _is_runtime_directory(self, path: str) -> bool:
|
||||
name = os.path.basename(path).casefold()
|
||||
parent = os.path.basename(os.path.dirname(path)).casefold()
|
||||
runtime = r"(?:i386-windows|x86_64-windows|mono|monobleedingedge|wine-mono.*|jre[\d._-]*|jdk[\d._-]*|java-runtime.*|_?commonredist|__installer)"
|
||||
if re.fullmatch(runtime, name) or name in ("$recycle.bin", "system volume information", "config.msi", "msdownld.tmp"):
|
||||
return True
|
||||
if name == "windows" and parent == "drive_c":
|
||||
return True
|
||||
if name in ("windows media player", "internet explorer", "windows nt") and parent.startswith("program files"):
|
||||
return True
|
||||
if "proton" in name or name.startswith("wine"):
|
||||
return os.path.isfile(os.path.join(path, "proton")) or os.path.isdir(os.path.join(path, "lib", "wine"))
|
||||
return False
|
||||
|
||||
def _find_executables(self, folder: str) -> dict[str, list[str]]:
|
||||
games: dict[str, list[str]] = {}
|
||||
pending = deque([] if self._is_runtime_directory(folder) else [(folder, 0, folder)])
|
||||
visited: dict[str, int] = {}
|
||||
completed, total = 0, len(pending)
|
||||
ignored = r"(?i)(?:unins.*|setup.*|install.*|.*redist.*|dxsetup.*|directx.*|dotnet.*|.*crash.*|.*report.*|.*prereq.*|.*updater?.*|.*config.*|.*settings.*|.*helper.*|.*benchmark.*|.*-win(?:32|64)-shipping|.*easyanticheat.*|.*battleye.*|oalinst|overlay_test.*|.*[-_](?:setup|installer).*|.*errormonitor|pluginhost|.*_api)"
|
||||
skipped = {"engine", "melonloader", "mods", "plugins", "userdata", "userlibs", "reshade_shaders",
|
||||
"assets", "data", "resources", "res", "res_mods", "replays", "movies", "cache", "logs"}
|
||||
while pending:
|
||||
if self.isInterruptionRequested():
|
||||
return {}
|
||||
root, depth, branch = pending.popleft()
|
||||
label = _("Scanning: {folder}").format(folder=root)
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
try:
|
||||
with os.scandir(root) as entries:
|
||||
entries_list = sorted(entries, key=lambda entry: entry.name.casefold())
|
||||
files = [entry.path for entry in entries_list if entry.name.casefold().endswith(".exe")
|
||||
and not re.fullmatch(ignored, entry.name[:-len(".exe")])
|
||||
and not entry.is_symlink() and entry.is_file(follow_symlinks=False)]
|
||||
directory = root
|
||||
while directory != folder and Path(directory).name.casefold() in self.GAME_SUBDIRECTORIES:
|
||||
directory = os.path.dirname(directory)
|
||||
if files:
|
||||
games.setdefault(directory, []).extend(files)
|
||||
for entry in entries_list:
|
||||
name = entry.name.casefold()
|
||||
if (depth >= self.MAX_SCAN_DEPTH or entry.is_symlink() or not entry.is_dir(follow_symlinks=False)
|
||||
or name.startswith((".", "$")) or name in skipped or name.endswith("_data")
|
||||
or re.fullmatch(ignored, name) or self._is_runtime_directory(entry.path)):
|
||||
continue
|
||||
if files and root != folder and name not in self.GAME_SUBDIRECTORIES:
|
||||
continue
|
||||
owner = entry.path if root == folder or Path(root).name.casefold() == "common" else branch
|
||||
if visited.get(owner, 0) >= self.MAX_SCAN_DIRECTORIES:
|
||||
self._record_error(OSError(f"Game folder scan directory limit reached: {owner}"))
|
||||
break
|
||||
visited[owner] = visited.get(owner, 0) + 1
|
||||
next_depth = 0 if owner == entry.path or name in ("steamapps", "common") else depth + 1
|
||||
pending.append((entry.path, next_depth, owner))
|
||||
total += 1
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
completed += 1
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
return games
|
||||
|
||||
|
||||
class GameFolderImportWorker(QThread):
|
||||
"""Create selected shortcuts without overwriting existing entries."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, games: list[tuple[str, str]], parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.games = games
|
||||
self.added = 0
|
||||
self.skipped = 0
|
||||
self.errors: list[str] = []
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
raise FileNotFoundError("PortProton directory not found")
|
||||
existing = set()
|
||||
with os.scandir(location) as entries:
|
||||
paths = [entry.path for entry in entries if entry.name.endswith(".desktop") and entry.is_file()]
|
||||
for index, path in enumerate(paths):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(paths), _("Checking existing shortcuts: {path}").format(path=path)))
|
||||
entry = parse_desktop_entry(path)
|
||||
target = extract_exec_target_path(entry.get("Exec", "")) if entry else None
|
||||
if target:
|
||||
existing.add(os.path.realpath(target))
|
||||
for index, (name, executable) in enumerate(self.games):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
self._import_game(name, executable, existing)
|
||||
self.progressChanged.emit((index + 1, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _import_game(self, name: str, executable: str, existing: set[str]) -> None:
|
||||
target = os.path.realpath(executable)
|
||||
if target in existing:
|
||||
self.skipped += 1
|
||||
return
|
||||
created = False
|
||||
desktop_path = ""
|
||||
try:
|
||||
name = " ".join(re.sub(r"[\\/]", " ", name).split()).strip(" .")
|
||||
result = create_desktop_file(executable, name)
|
||||
if result is None:
|
||||
raise OSError(_("Failed to create shortcut: {name}").format(name=name))
|
||||
content, desktop_path, icon_path = result
|
||||
if os.path.exists(desktop_path):
|
||||
self.skipped += 1
|
||||
return
|
||||
if not os.path.exists(icon_path) and not generate_thumbnail(executable, icon_path, size=128):
|
||||
content = content.replace(f"Icon={icon_path}\n", "Icon=\n")
|
||||
with open(desktop_path, "x", encoding="utf-8") as desktop:
|
||||
created = True
|
||||
desktop.write(content)
|
||||
os.chmod(desktop_path, 0o755)
|
||||
existing.add(target)
|
||||
self.added += 1
|
||||
except FileExistsError:
|
||||
self.skipped += 1
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import '%s': %s", executable, error)
|
||||
self.errors.append(f"{name}: {error}")
|
||||
if created:
|
||||
try:
|
||||
os.unlink(desktop_path)
|
||||
except OSError as cleanup_error:
|
||||
logger.warning("Failed to remove incomplete shortcut: %s", cleanup_error)
|
||||
|
||||
|
||||
class AddGameDialog(DraggableDialog):
|
||||
"""Dialog for adding or editing a game."""
|
||||
|
||||
gamesAdded = Signal()
|
||||
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None, steam_appid=None):
|
||||
super().__init__(parent)
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
self.theme = theme if theme else theme_manager.apply_theme(ui_config.get_theme())
|
||||
self.edit_mode = edit_mode
|
||||
if exe_path is None and steam_appid is not None:
|
||||
exe_path = f"steam://rungameid/{steam_appid}"
|
||||
self.last_exe_path = exe_path
|
||||
self.last_cover_path = cover_path
|
||||
refresh_games = getattr(parent, "refreshGames", None)
|
||||
if callable(refresh_games):
|
||||
self.gamesAdded.connect(refresh_games)
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
|
||||
self.setWindowTitle(_("Edit Shortcut") if edit_mode else _("Add a shortcut"))
|
||||
@@ -195,6 +433,19 @@ class AddGameDialog(DraggableDialog):
|
||||
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
|
||||
layout.addRow(cover_button_label, coverBrowseButton)
|
||||
|
||||
self.iconEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.iconEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.iconEdit.setText(get_custom_icon_path(exe_path) if exe_path else "")
|
||||
icon_label = QLabel(_("Custom Icon:"))
|
||||
icon_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
icon_row = QHBoxLayout()
|
||||
icon_row.addWidget(self.iconEdit)
|
||||
icon_browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("search", as_path=True))
|
||||
icon_browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
icon_browse.clicked.connect(self.browseIcon)
|
||||
icon_row.addWidget(icon_browse)
|
||||
layout.addRow(icon_label, icon_row)
|
||||
|
||||
self.add_to_steam_checkbox = QCheckBox()
|
||||
self.add_to_menu_checkbox = QCheckBox()
|
||||
self.add_to_desktop_checkbox = QCheckBox()
|
||||
@@ -233,6 +484,9 @@ class AddGameDialog(DraggableDialog):
|
||||
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
|
||||
self.add_to_menu_checkbox.setChecked(self._menu_shortcut_exists(current_name))
|
||||
self.add_to_desktop_checkbox.setChecked(self._desktop_shortcut_exists(current_name))
|
||||
if not edit_mode and is_native_executable(self.exeEdit.text().strip()):
|
||||
self.add_to_menu_checkbox.setChecked(True)
|
||||
self.add_to_desktop_checkbox.setChecked(True)
|
||||
|
||||
self.coverPreview = QLabel(self)
|
||||
self.coverPreview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -327,17 +581,60 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
def accept(self):
|
||||
"""Handle Apply button."""
|
||||
icon_path = self.iconEdit.text().strip()
|
||||
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
|
||||
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
image = QPixmap(icon_path)
|
||||
if image.isNull():
|
||||
QMessageBox.warning(self, _("Error"), _("Invalid image"))
|
||||
return
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
temporary_path = None
|
||||
try:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
|
||||
temporary_path = temporary.name
|
||||
if not image.save(temporary_path, "PNG"):
|
||||
raise OSError("Failed to save icon")
|
||||
os.replace(temporary_path, os.path.join(folder, "icon.png"))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save custom icon: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
finally:
|
||||
if temporary_path:
|
||||
Path(temporary_path).unlink(missing_ok=True)
|
||||
if hasattr(self, 'keyboard'):
|
||||
self.keyboard.hide()
|
||||
super().accept()
|
||||
|
||||
def browseIcon(self) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
|
||||
initial_path=os.path.dirname(self.iconEdit.text()) or None)
|
||||
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
|
||||
explorer.show()
|
||||
|
||||
def browseExe(self):
|
||||
"""Open file manager to select executable or disc image."""
|
||||
try:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
|
||||
def file_filter(path: str) -> bool:
|
||||
return path.lower().endswith(LAUNCH_FILE_EXTENSIONS) or is_native_executable(path)
|
||||
|
||||
file_explorer = FileExplorer(self, file_filter=file_filter, initial_path=initial_path)
|
||||
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
|
||||
if not self.edit_mode:
|
||||
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
folder_button.setObjectName("gamepadAddButton")
|
||||
folder_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
folder_button.clicked.connect(partial(self._select_game_folder, file_explorer))
|
||||
file_explorer.button_layout.insertWidget(0, folder_button)
|
||||
|
||||
parent_widget = self.parentWidget()
|
||||
if parent_widget:
|
||||
@@ -351,6 +648,195 @@ class AddGameDialog(DraggableDialog):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in browseExe: {e}")
|
||||
|
||||
def _select_game_folder(self, explorer: "FileExplorer") -> None:
|
||||
folder = explorer.current_path
|
||||
item = explorer.file_list.currentItem()
|
||||
if item and item.text() != "../":
|
||||
selected = os.path.normpath(os.path.join(folder, item.text()))
|
||||
if os.path.isdir(selected):
|
||||
folder = selected
|
||||
self._game_folder_root = folder
|
||||
explorer.accept()
|
||||
self._start_folder_worker(GameFolderScanWorker(folder, self.parentWidget() or self))
|
||||
|
||||
def _start_folder_worker(self, worker: GameFolderScanWorker | GameFolderImportWorker) -> None:
|
||||
owner = cast(Any, self.parentWidget() or self)
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
owner._gameFolderWorkers = workers
|
||||
workers.append(worker)
|
||||
progress = QProgressDialog(_("Processing games…"), _("Cancel"), 0, 1, self)
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
progress.setValue(0)
|
||||
progress.canceled.connect(worker.requestInterruption)
|
||||
worker.progressChanged.connect(partial(self._update_folder_progress, progress), Qt.ConnectionType.QueuedConnection)
|
||||
worker.finished.connect(partial(self._finish_folder_worker, worker, progress), Qt.ConnectionType.QueuedConnection)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _update_folder_progress(self, progress: QProgressDialog, state: tuple[int, int, str]) -> None:
|
||||
if progress.wasCanceled():
|
||||
return
|
||||
completed, total, current = state
|
||||
progress.setLabelText(_("Processed {completed}/{total}\n{current}").format(
|
||||
completed=completed, total=total, current=current,
|
||||
))
|
||||
progress.setRange(0, max(total, 1))
|
||||
progress.setValue(completed)
|
||||
|
||||
def _finish_folder_worker(self, worker: QThread, progress: QProgressDialog) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
owner = self.parentWidget() or self
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
cancelled = progress.wasCanceled()
|
||||
progress.close()
|
||||
progress.deleteLater()
|
||||
worker.deleteLater()
|
||||
if worker not in workers:
|
||||
return
|
||||
workers.remove(worker)
|
||||
if isinstance(worker, GameFolderImportWorker):
|
||||
if worker.added:
|
||||
self.gamesAdded.emit()
|
||||
if cancelled:
|
||||
return
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
self.reject()
|
||||
elif isinstance(worker, GameFolderScanWorker) and not cancelled:
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
if worker.games:
|
||||
self._show_folder_games(worker.games)
|
||||
else:
|
||||
QMessageBox.information(self, _("Information"), _("No games found in the selected folder."))
|
||||
|
||||
def _create_folder_game_row(
|
||||
self, layout: QVBoxLayout, game: tuple[str, str],
|
||||
) -> tuple[QLineEdit, QLineEdit, QCheckBox]:
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
|
||||
name, executable = game
|
||||
content = layout.parentWidget()
|
||||
row = QHBoxLayout()
|
||||
checkbox = QCheckBox(content)
|
||||
checkbox.setObjectName("gameFolderExecutable")
|
||||
checkbox.setAccessibleName(executable)
|
||||
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
checkbox.setChecked(True)
|
||||
title = CustomLineEdit(name, content, theme=self.theme)
|
||||
title.setAccessibleName(_("Name:"))
|
||||
title.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
path = CustomLineEdit(executable, content, theme=self.theme)
|
||||
path.setObjectName("gameFolderPath")
|
||||
path.setAccessibleName(_("Path to Executable:"))
|
||||
path.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
browse.setAutoDefault(False)
|
||||
browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
browse.clicked.connect(partial(self._browse_folder_exe, path))
|
||||
path_row = QHBoxLayout()
|
||||
path_row.addWidget(path)
|
||||
path_row.addWidget(browse)
|
||||
details = QVBoxLayout()
|
||||
details.addWidget(title)
|
||||
details.addLayout(path_row)
|
||||
row.addWidget(checkbox)
|
||||
row.addLayout(details)
|
||||
layout.addLayout(row)
|
||||
QWidget.setTabOrder(checkbox, title)
|
||||
QWidget.setTabOrder(title, path)
|
||||
QWidget.setTabOrder(path, browse)
|
||||
return title, path, checkbox
|
||||
|
||||
def _browse_folder_exe(self, field: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(
|
||||
field.window(), theme=self.theme, file_filter=(".exe",),
|
||||
initial_path=os.path.dirname(os.path.expanduser(field.text().strip())),
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(field.setText)
|
||||
explorer.exec()
|
||||
field.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
explorer.deleteLater()
|
||||
|
||||
def _accept_folder_games(
|
||||
self, dialog: QDialog, rows: list[tuple[QLineEdit, QLineEdit, QCheckBox]],
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
for title, path, checkbox in rows:
|
||||
if not checkbox.isChecked():
|
||||
continue
|
||||
if not title.text().strip():
|
||||
QMessageBox.warning(dialog, _("Error"), _("Game name and executable path are required"))
|
||||
title.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
executable = os.path.expanduser(path.text().strip())
|
||||
if not os.path.isfile(executable):
|
||||
QMessageBox.warning(dialog, _("Error"), _("Executable not found: {0}").format(executable))
|
||||
path.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
path.setText(executable)
|
||||
dialog.accept()
|
||||
|
||||
def _scroll_folder_focus(self, scroll: QScrollArea, _old: QWidget | None, focused: QWidget | None) -> None:
|
||||
if focused is not None and scroll.isAncestorOf(focused):
|
||||
scroll.ensureWidgetVisible(focused)
|
||||
|
||||
def _show_folder_games(self, games: list[tuple[str, str]]) -> None:
|
||||
dialog = cast(Any, DraggableDialog(self))
|
||||
dialog.setWindowTitle(_("Select games"))
|
||||
dialog.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
dialog.resize(*self.theme.GAME_FOLDER_IMPORT_DIALOG_SIZE)
|
||||
layout = QVBoxLayout(dialog)
|
||||
scroll = QScrollArea(dialog)
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget(scroll)
|
||||
rows_layout = QVBoxLayout(content)
|
||||
rows = [self._create_folder_game_row(rows_layout, game) for game in games]
|
||||
rows_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
buttons = QHBoxLayout()
|
||||
apply_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply", as_path=True))
|
||||
apply_button.setObjectName("gamepadAddButton")
|
||||
cancel_button = AutoSizeButton(_("Close"), icon=theme_manager.get_icon("cancel", as_path=True))
|
||||
for button in (apply_button, cancel_button):
|
||||
button.setAutoDefault(False)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
buttons.addWidget(button)
|
||||
apply_button.clicked.connect(partial(self._accept_folder_games, dialog, rows))
|
||||
cancel_button.clicked.connect(dialog.reject)
|
||||
layout.addLayout(buttons)
|
||||
main_window = self.parentWidget()
|
||||
input_manager = getattr(main_window, "input_manager", None)
|
||||
hints, labels = create_dialog_hints_widget(self.theme, main_window, input_manager, context='folder_import')
|
||||
layout.addWidget(hints)
|
||||
update_dialog_hints(labels, main_window, input_manager, theme_manager, ui_config.get_theme())
|
||||
dialog.keyboard = VirtualKeyboard(dialog, theme=self.theme)
|
||||
dialog.finished.connect(dialog.keyboard.hide)
|
||||
if rows:
|
||||
QTimer.singleShot(0, rows[0][2].setFocus)
|
||||
app = cast(QApplication, QApplication.instance())
|
||||
scroll_focus = partial(self._scroll_folder_focus, scroll)
|
||||
app.focusChanged.connect(scroll_focus)
|
||||
try:
|
||||
accepted = dialog.exec() == QDialog.DialogCode.Accepted
|
||||
finally:
|
||||
app.focusChanged.disconnect(scroll_focus)
|
||||
if accepted:
|
||||
selected = [(title.text().strip(), path.text().strip()) for title, path, checkbox in rows
|
||||
if checkbox.isChecked()]
|
||||
if selected:
|
||||
self._start_folder_worker(GameFolderImportWorker(selected, self.parentWidget() or self))
|
||||
dialog.deleteLater()
|
||||
|
||||
def onExeSelected(self, file_path):
|
||||
"""Handle file selection in FileExplorer."""
|
||||
self.exeEdit.setText(file_path)
|
||||
@@ -481,6 +967,13 @@ class AddGameDialog(DraggableDialog):
|
||||
if not name:
|
||||
return None, None
|
||||
|
||||
if is_native_executable(exe_path):
|
||||
cover = self.last_cover_path or self.coverEdit.text().strip()
|
||||
result = create_native_desktop_file(exe_path, name, cover)
|
||||
if result is None:
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to create shortcut: {name}").format(name=name))
|
||||
return result or (None, None)
|
||||
|
||||
if exe_path.lower().endswith(DISC_IMAGE_EXTENSIONS):
|
||||
parent_widget = cast("MainWindow | None", self.parentWidget())
|
||||
if not parent_widget or not hasattr(parent_widget, "resolve_launch_file_path"):
|
||||
@@ -497,6 +990,8 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
file_icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(launch_path)[1].lower())
|
||||
file_icon_path = theme_manager.get_icon(file_icon_name, as_path=True) if file_icon_name else ""
|
||||
if get_custom_icon_path(exe_path):
|
||||
return desktop_entry, desktop_path
|
||||
if not self.last_cover_path and file_icon_path:
|
||||
desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", f"Icon={file_icon_path}\n")
|
||||
elif not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128):
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
import orjson
|
||||
|
||||
from PySide6.QtCore import QIODevice, QPoint, QSaveFile, Qt, QUrl
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager, QNetworkDiskCache, QNetworkProxy, QNetworkReply, QNetworkRequest,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -14,8 +20,12 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.config import CACHE_DIR, proxy_config, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CompatibilityReportDialog(DraggableDialog):
|
||||
@@ -70,6 +80,11 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
def _create_buttons(self, theme) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
layout.addStretch()
|
||||
self.ppdb_button = AutoSizeButton(_("Download") + " PPDB")
|
||||
self.ppdb_button.setStyleSheet(theme.ACTION_BUTTON_STYLE)
|
||||
self.ppdb_button.hide()
|
||||
self.ppdb_button.clicked.connect(self._download_ppdb)
|
||||
layout.addWidget(self.ppdb_button)
|
||||
close_button = AutoSizeButton(_("Close"))
|
||||
close_button.setStyleSheet(theme.ACTION_BUTTON_ACTIVE_STYLE)
|
||||
close_button.clicked.connect(self.accept)
|
||||
@@ -77,6 +92,81 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
layout.addWidget(close_button)
|
||||
return layout
|
||||
|
||||
def check_ppdb(self, executable: str) -> None:
|
||||
if ui_config.get_auto_download_ppdb() or not os.path.isfile(executable):
|
||||
return
|
||||
self.ppdb_path = executable + ".ppdb"
|
||||
self.ppdb_network = QNetworkAccessManager(self)
|
||||
self.finished.connect(self.ppdb_network.deleteLater)
|
||||
self.ppdb_network.setTransferTimeout(
|
||||
QNetworkRequest.TransferTimeoutConstant.DefaultTransferTimeoutConstant.value
|
||||
)
|
||||
cache = QNetworkDiskCache(self.ppdb_network)
|
||||
cache.setCacheDirectory(str(CACHE_DIR / "ppdb_lookup"))
|
||||
self.ppdb_network.setCache(cache)
|
||||
proxies = proxy_config.get_proxy() or {}
|
||||
proxy_url = QUrl(proxies.get("https") or proxies.get("http") or "")
|
||||
if proxy_url.host():
|
||||
proxy_type = (QNetworkProxy.ProxyType.Socks5Proxy
|
||||
if proxy_url.scheme().startswith("socks")
|
||||
else QNetworkProxy.ProxyType.HttpProxy)
|
||||
self.ppdb_network.setProxy(QNetworkProxy(
|
||||
proxy_type, proxy_url.host(), max(proxy_url.port(), 0),
|
||||
proxy_url.userName(), proxy_url.password(),
|
||||
))
|
||||
url = QUrl("https://linux-gaming.ru/api/lookup/exe/")
|
||||
url.setPath(url.path() + os.path.basename(executable))
|
||||
reply = self.ppdb_network.get(QNetworkRequest(url))
|
||||
reply.finished.connect(lambda: self._ppdb_lookup_finished(reply))
|
||||
|
||||
def _ppdb_lookup_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB lookup failed: %s", reply.errorString())
|
||||
return
|
||||
try:
|
||||
metadata = orjson.loads(bytes(reply.readAll().data()))
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid PPDB metadata: %s", error)
|
||||
return
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
url = metadata.get("ppdb_url")
|
||||
if not isinstance(url, str):
|
||||
return
|
||||
self.ppdb_url = QUrl(url)
|
||||
if self.ppdb_url.scheme() in {"https", "http"} and self.ppdb_url.host():
|
||||
self.ppdb_button.show()
|
||||
|
||||
def _download_ppdb(self) -> None:
|
||||
self.ppdb_button.setEnabled(False)
|
||||
self.ppdb_button.setText(_("Downloading…"))
|
||||
request = QNetworkRequest(self.ppdb_url)
|
||||
request.setAttribute(
|
||||
QNetworkRequest.Attribute.CacheLoadControlAttribute,
|
||||
QNetworkRequest.CacheLoadControl.AlwaysNetwork,
|
||||
)
|
||||
reply = self.ppdb_network.get(request)
|
||||
reply.finished.connect(lambda: self._ppdb_download_finished(reply))
|
||||
|
||||
def _ppdb_download_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
self.ppdb_button.setText(_("Download") + " PPDB")
|
||||
self.ppdb_button.setEnabled(True)
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB download failed: %s", reply.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
destination = QSaveFile(self.ppdb_path)
|
||||
data = reply.readAll()
|
||||
if (data.isEmpty() or not destination.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
or destination.write(data) != data.size() or not destination.commit()):
|
||||
logger.warning("Failed to save PPDB: %s", destination.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
self.ppdb_button.setText(_("Downloading Complete"))
|
||||
self.ppdb_button.setEnabled(False)
|
||||
|
||||
def _show_context_menu(
|
||||
self, report_view: QPlainTextEdit, theme: Any, position: QPoint
|
||||
) -> None:
|
||||
|
||||
@@ -25,7 +25,12 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
|
||||
dialog_actions = []
|
||||
|
||||
if context == 'file_explorer':
|
||||
if context == 'folder_import':
|
||||
dialog_actions = [
|
||||
("add_game", _("Apply")),
|
||||
("back", _("Close")),
|
||||
]
|
||||
elif context == 'file_explorer':
|
||||
dialog_actions = [
|
||||
("confirm", _("Open")),
|
||||
("add_game", _("Select Dir")),
|
||||
@@ -45,10 +50,9 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
elif context == 'settings':
|
||||
dialog_actions = [
|
||||
("confirm", _("Toggle")),
|
||||
("add_game", _("Save")),
|
||||
("prev_dir", _("Search")),
|
||||
("context_menu", _("Menu")),
|
||||
("back", _("Cancel")),
|
||||
("back", _("Close")),
|
||||
("prev_tab", _("Prev Tab")),
|
||||
("next_tab", _("Next Tab")),
|
||||
]
|
||||
|
||||
@@ -8,9 +8,13 @@ from PIL import Image, ImageQt
|
||||
from PySide6.QtGui import QPixmap, QIcon, QImage, QImageReader
|
||||
from PySide6.QtWidgets import (
|
||||
QVBoxLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy
|
||||
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy, QStyle,
|
||||
QStyledItemDelegate, QStyleOptionViewItem
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
Qt, QObject, Signal, QMimeDatabase, QModelIndex, QPersistentModelIndex,
|
||||
QThreadPool, QRunnable, Slot, QSize, QTimer
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QThreadPool, QRunnable, Slot, QSize, QTimer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
@@ -21,7 +25,7 @@ from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.scripts_utils.mount_points import list_mounted_drive_paths
|
||||
@@ -32,6 +36,25 @@ DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
|
||||
THUMBNAIL_PRELOAD_ROWS = 5
|
||||
THUMBNAIL_QUEUE_LIMIT = 24
|
||||
THUMBNAIL_SCROLL_DELAY_MS = 80
|
||||
THEMED_ICON_ROLE = Qt.ItemDataRole.UserRole.value + 1
|
||||
|
||||
|
||||
class FileIconDelegate(QStyledItemDelegate):
|
||||
"""Draw themed list icons in their active mode on hover."""
|
||||
|
||||
def initStyleOption(
|
||||
self,
|
||||
option: QStyleOptionViewItem,
|
||||
index: QModelIndex | QPersistentModelIndex,
|
||||
) -> None:
|
||||
super().initStyleOption(option, index)
|
||||
if not index.data(THEMED_ICON_ROLE):
|
||||
return
|
||||
if not option.state & QStyle.StateFlag.State_MouseOver:
|
||||
return
|
||||
pixmap = option.icon.pixmap(option.decorationSize, QIcon.Mode.Active)
|
||||
if not pixmap.isNull():
|
||||
option.icon = QIcon(pixmap)
|
||||
|
||||
|
||||
class FileExplorer(DraggableDialog):
|
||||
@@ -93,7 +116,7 @@ class FileExplorer(DraggableDialog):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
else:
|
||||
logger.warning("Failed to load image: %s", self.file_path)
|
||||
elif self.file_path.lower().endswith((".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
elif self.file_path.lower().endswith((".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
thumbnail_source = self.file_path
|
||||
if self.file_path.lower().endswith(DISC_IMAGE_EXTENSIONS) and callable(self.resolve_launch_file_path):
|
||||
resolved_path = self.resolve_launch_file_path(self.file_path)
|
||||
@@ -102,6 +125,14 @@ class FileExplorer(DraggableDialog):
|
||||
return
|
||||
thumbnail_source = resolved_path
|
||||
|
||||
if thumbnail_source.lower().endswith(".appimage"):
|
||||
icon_path = get_exe_icon_cache_path(thumbnail_source)
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
image = QImage()
|
||||
if os.path.isfile(icon_path) or generate_thumbnail(thumbnail_source, icon_path):
|
||||
image = QImage(icon_path)
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
return
|
||||
if not thumbnail_source.lower().endswith(".exe"):
|
||||
return
|
||||
|
||||
@@ -119,6 +150,8 @@ class FileExplorer(DraggableDialog):
|
||||
os.unlink(tmp_name)
|
||||
except Exception as e:
|
||||
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
|
||||
if self.file_path.lower().endswith(".appimage"):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QImage())
|
||||
|
||||
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
|
||||
super().__init__(parent)
|
||||
@@ -170,12 +203,12 @@ class FileExplorer(DraggableDialog):
|
||||
self.main_layout.addWidget(self.hints_widget)
|
||||
|
||||
if self.input_manager:
|
||||
self.input_manager.button_event.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
self.input_manager.dpad_moved.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('file_explorer', update_hints)
|
||||
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
|
||||
def async_load_thumbnails(self, files, mime_db):
|
||||
@@ -191,7 +224,7 @@ class FileExplorer(DraggableDialog):
|
||||
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
|
||||
continue
|
||||
mime_type = mime_db.mimeTypeForFile(file_path).name()
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
self.pending_thumbnails.add(file_path)
|
||||
resolver = None
|
||||
main_window = cast("MainWindow | None", self.main_window)
|
||||
@@ -223,17 +256,45 @@ class FileExplorer(DraggableDialog):
|
||||
def _set_thumbnail_icon(self, file_path: str, image: QImage) -> None:
|
||||
icon = QIcon(QPixmap.fromImage(image))
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
if image.isNull():
|
||||
return
|
||||
item = self.file_items.get(file_path)
|
||||
if item:
|
||||
item.setData(THEMED_ICON_ROLE, None)
|
||||
item.setIcon(icon)
|
||||
|
||||
def _get_file_type_icon(self, file_path: str) -> QIcon | None:
|
||||
icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(file_path)[1].lower())
|
||||
if not icon_name:
|
||||
return None
|
||||
icon = theme_manager.get_icon(icon_name)
|
||||
icon = self._get_list_icon(icon_name)
|
||||
return icon if isinstance(icon, QIcon) and not icon.isNull() else None
|
||||
|
||||
def _get_list_icon(self, icon_name: str) -> QIcon:
|
||||
icon_path = theme_manager.get_icon(icon_name, as_path=True)
|
||||
if not isinstance(icon_path, str) or not os.path.exists(icon_path):
|
||||
return QIcon()
|
||||
|
||||
icon = QIcon()
|
||||
icon.addFile(icon_path, QSize(), QIcon.Mode.Normal)
|
||||
colors = getattr(self.theme, "ICON_COLORS", {})
|
||||
if not isinstance(colors, dict):
|
||||
return icon
|
||||
|
||||
modes = {
|
||||
"hover": QIcon.Mode.Active,
|
||||
"focused": QIcon.Mode.Selected,
|
||||
"disabled": QIcon.Mode.Disabled,
|
||||
}
|
||||
for state, mode in modes.items():
|
||||
color = colors.get(f"{icon_name}_{state}", colors.get(f"*_{state}"))
|
||||
if not isinstance(color, str):
|
||||
continue
|
||||
colored_path = theme_manager.get_colored_icon_path(icon_name, color)
|
||||
if colored_path:
|
||||
icon.addFile(colored_path, QSize(), mode)
|
||||
return icon
|
||||
|
||||
def schedule_thumbnail_loading(self) -> None:
|
||||
"""Schedule thumbnail loading after scroll events settle."""
|
||||
self.thumbnail_timer.start(THUMBNAIL_SCROLL_DELAY_MS)
|
||||
@@ -309,6 +370,7 @@ class FileExplorer(DraggableDialog):
|
||||
self.main_layout.addWidget(self.path_label)
|
||||
|
||||
self.file_list = QListWidget()
|
||||
self.file_list.setItemDelegate(FileIconDelegate(self.file_list))
|
||||
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE + self.theme.SCROLL_STYLE)
|
||||
self.file_list.itemClicked.connect(self.handle_item_click)
|
||||
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
|
||||
@@ -530,15 +592,17 @@ class FileExplorer(DraggableDialog):
|
||||
try:
|
||||
if self.directory_only:
|
||||
item = QListWidgetItem("./")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
folder_icon = self._get_list_icon("folder")
|
||||
if isinstance(folder_icon, QIcon):
|
||||
item.setIcon(folder_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
self.file_list.addItem(item)
|
||||
if self.current_path != "/":
|
||||
item = QListWidgetItem("../")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
folder_icon = self._get_list_icon("folder")
|
||||
if isinstance(folder_icon, QIcon):
|
||||
item.setIcon(folder_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
items = os.listdir(self.current_path)
|
||||
@@ -546,15 +610,18 @@ class FileExplorer(DraggableDialog):
|
||||
|
||||
for d in sorted(dirs):
|
||||
item = QListWidgetItem(f"{d}/")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
folder_icon = self._get_list_icon("folder")
|
||||
if isinstance(folder_icon, QIcon):
|
||||
item.setIcon(folder_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
if not self.directory_only:
|
||||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||||
if self.file_filter:
|
||||
if isinstance(self.file_filter, str):
|
||||
if callable(self.file_filter):
|
||||
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
|
||||
elif isinstance(self.file_filter, str):
|
||||
files = [f for f in files if f.lower().endswith(self.file_filter)]
|
||||
elif isinstance(self.file_filter, tuple):
|
||||
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
|
||||
@@ -566,6 +633,7 @@ class FileExplorer(DraggableDialog):
|
||||
file_type_icon = self._get_file_type_icon(file_path)
|
||||
if file_type_icon:
|
||||
item.setIcon(file_type_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
if (
|
||||
f.lower().endswith(".ppack")
|
||||
or mime_type.name() == "application/x-portproton-prefix-backup"
|
||||
@@ -573,9 +641,10 @@ class FileExplorer(DraggableDialog):
|
||||
or mime_type.name() in ("application/vnd.squashfs", "application/x-squashfs")
|
||||
or mime_type.inherits("application/vnd.squashfs")
|
||||
):
|
||||
ppack_icon = theme_manager.get_icon("ppack")
|
||||
ppack_icon = self._get_list_icon("ppack")
|
||||
if isinstance(ppack_icon, QIcon):
|
||||
item.setIcon(ppack_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
self.file_items[file_path] = item
|
||||
self.file_list.addItem(item)
|
||||
|
||||
|
||||
@@ -9,19 +9,23 @@ 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 Qt, QTimer, QEvent, QObject
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtCore import QEvent, QMimeData, QObject, Qt, QTimer, QUrl
|
||||
from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QGuiApplication
|
||||
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 _
|
||||
from portprotonqt.version_utils import version_sort_key
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
from portprotonqt.dialogs.wine_loader import WineLoadingThread, get_cpu_level
|
||||
from portprotonqt.dialogs.wine_loader import (
|
||||
InstalledWineSizeThread, WineLoadingThread, get_cpu_level,
|
||||
)
|
||||
from portprotonqt.dialogs.wine_downloader import DownloadThread
|
||||
from portprotonqt.dialogs.wine_extractor import ExtractionThread
|
||||
from portprotonqt.preloader import Preloader
|
||||
@@ -29,6 +33,8 @@ from portprotonqt.steam_api import get_steam_compatibilitytools_dir, get_steam_h
|
||||
|
||||
logger = get_logger(__name__)
|
||||
theme_manager = ThemeManager()
|
||||
WINE_TABLE_ROW_BATCH_SIZE = 8
|
||||
WINE_ARCHIVE_EXTENSIONS = ('.tar.xz', '.tar.gz')
|
||||
|
||||
|
||||
class ProtonManager(DraggableDialog):
|
||||
@@ -47,11 +53,13 @@ class ProtonManager(DraggableDialog):
|
||||
self.input_manager = input_manager
|
||||
self.initial_command_executed = False
|
||||
self.wine_loading_thread = None
|
||||
self._installed_size_tables = {}
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
self.main_window = None
|
||||
parent_widget = self.parent()
|
||||
while parent_widget:
|
||||
if hasattr(parent_widget, 'input_manager'):
|
||||
if hasattr(parent_widget, 'get_button_icon'):
|
||||
self.main_window = parent_widget
|
||||
break
|
||||
parent_widget = parent_widget.parent()
|
||||
@@ -61,6 +69,56 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
if self.input_manager:
|
||||
self.enable_proton_manager_mode()
|
||||
if self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_hints()
|
||||
|
||||
@staticmethod
|
||||
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
|
||||
if not mime_data.hasUrls():
|
||||
return []
|
||||
archive_paths = []
|
||||
for url in mime_data.urls():
|
||||
if not url.isLocalFile():
|
||||
continue
|
||||
archive_path = url.toLocalFile()
|
||||
if os.path.isfile(archive_path) and archive_path.lower().endswith(
|
||||
WINE_ARCHIVE_EXTENSIONS
|
||||
):
|
||||
archive_paths.append(archive_path)
|
||||
return archive_paths
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||||
if self._get_dropped_wine_archives(event.mimeData()):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event: QDropEvent) -> None:
|
||||
archive_paths = self._get_dropped_wine_archives(event.mimeData())
|
||||
if not archive_paths or self.is_downloading:
|
||||
event.ignore()
|
||||
return
|
||||
self.install_local_archives(archive_paths)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def install_local_archives(self, archive_paths: list[str]) -> None:
|
||||
self.assets_to_download = [
|
||||
{
|
||||
'source_name': 'proton_lg',
|
||||
'asset_name': os.path.basename(path),
|
||||
'local_path': path,
|
||||
}
|
||||
for path in archive_paths
|
||||
]
|
||||
self.current_download_index = 0
|
||||
self.is_downloading = True
|
||||
self.start_next_download()
|
||||
|
||||
def _init_gamepad_tooltip(self) -> None:
|
||||
self._gamepad_tooltip_map = {}
|
||||
@@ -190,7 +248,7 @@ class ProtonManager(DraggableDialog):
|
||||
self.download_progress = QProgressBar()
|
||||
self.download_progress.setMinimum(0)
|
||||
self.download_progress.setMaximum(100)
|
||||
self.cancel_btn = QPushButton(_('Cancel'))
|
||||
self.cancel_btn = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
|
||||
self.cancel_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_btn.clicked.connect(self.cancel_current_download)
|
||||
progress_layout.addWidget(self.download_progress, 4)
|
||||
@@ -206,40 +264,39 @@ 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.cancel_btn_dialog = QPushButton(_('Cancel'))
|
||||
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 = 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)
|
||||
button_layout.addWidget(self.clear_btn)
|
||||
button_layout.addWidget(self.open_folder_btn)
|
||||
button_layout.addWidget(self.cancel_btn_dialog)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.tab_widget.currentChanged.connect(self.tab_changed)
|
||||
|
||||
if self.input_manager and self.main_window:
|
||||
self.input_manager.button_event.connect(
|
||||
lambda *args: update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
)
|
||||
self.input_manager.dpad_moved.connect(
|
||||
lambda *args: update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
)
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
def _open_wine_folder(self) -> None:
|
||||
if not self.portproton_location:
|
||||
return
|
||||
wine_folder = os.path.join(self.portproton_location, "data", "dist")
|
||||
try:
|
||||
os.makedirs(wine_folder, exist_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to create Wine folder %s: %s", wine_folder, error)
|
||||
return
|
||||
if not QDesktopServices.openUrl(QUrl.fromLocalFile(wine_folder)):
|
||||
logger.warning("Failed to open Wine folder %s", wine_folder)
|
||||
|
||||
def start_loading_wine_data(self):
|
||||
self.wine_loading_thread = WineLoadingThread()
|
||||
@@ -249,22 +306,55 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
def on_wine_data_loaded(self, metadata):
|
||||
self.wine_metadata = metadata
|
||||
self.refresh_wine_tabs()
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
self._start_incremental_wine_tabs(metadata)
|
||||
|
||||
def _start_incremental_wine_tabs(self, metadata: dict) -> None:
|
||||
self.cpu_level = get_cpu_level()
|
||||
logger.info("Detected CPU level: %s", self.cpu_level)
|
||||
tabs = []
|
||||
for source_name, entries in metadata.items():
|
||||
filtered_entries = self.filter_entries_by_cpu_level(entries, source_name)
|
||||
tabs.append((source_name, 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()
|
||||
self._wine_tab_queue = tabs
|
||||
self._wine_current_tab = None
|
||||
QTimer.singleShot(0, self._add_next_wine_row_batch)
|
||||
|
||||
def _add_next_wine_row_batch(self) -> None:
|
||||
if self._wine_current_tab is None:
|
||||
if not self._wine_tab_queue:
|
||||
self.create_installed_tab()
|
||||
self.update_selection_display()
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
return
|
||||
source_name, entries = self._wine_tab_queue.pop(0)
|
||||
table = self._create_empty_wine_tab(source_name, len(entries))
|
||||
self._wine_current_tab = (source_name, entries, table, 0)
|
||||
|
||||
source_name, entries, table, row_index = self._wine_current_tab
|
||||
batch_end = min(row_index + WINE_TABLE_ROW_BATCH_SIZE, len(entries))
|
||||
table.setUpdatesEnabled(False)
|
||||
try:
|
||||
for index in range(row_index, batch_end):
|
||||
self.add_asset_row_from_json(table, index, entries[index], source_name)
|
||||
finally:
|
||||
table.setUpdatesEnabled(True)
|
||||
if batch_end == len(entries):
|
||||
logger.info("Successfully created tab for %s", source_name)
|
||||
self._wine_current_tab = None
|
||||
else:
|
||||
self._wine_current_tab = (source_name, entries, table, batch_end)
|
||||
QTimer.singleShot(0, self._add_next_wine_row_batch)
|
||||
|
||||
def on_wine_data_load_error(self, error_msg):
|
||||
logger.error(f"Wine data loading failed: {error_msg}")
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
error_label = QLabel(_("Error loading WINE/Proton: {error}").format(error=error_msg))
|
||||
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||||
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if hasattr(self, 'tab_widget'):
|
||||
error_tab = QWidget()
|
||||
error_layout = QVBoxLayout(error_tab)
|
||||
error_layout.addWidget(error_label)
|
||||
self.tab_widget.addTab(error_tab, _("Error"))
|
||||
self.tab_widget.clear()
|
||||
self.create_installed_tab()
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
self.cpu_level = get_cpu_level()
|
||||
@@ -285,15 +375,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
|
||||
@@ -341,12 +430,6 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
def create_tab_from_entries(self, source_name, entries):
|
||||
try:
|
||||
tab = QWidget()
|
||||
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
layout.setSpacing(5)
|
||||
table = self.create_table_widget()
|
||||
all_entries = []
|
||||
for entry in entries:
|
||||
url = entry.get('url', '')
|
||||
@@ -357,18 +440,28 @@ class ProtonManager(DraggableDialog):
|
||||
entry['filename'] = url_filename
|
||||
all_entries.append(entry)
|
||||
all_entries.sort(key=version_sort_key)
|
||||
table.setRowCount(len(all_entries))
|
||||
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)
|
||||
layout.addWidget(table, 1)
|
||||
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper()
|
||||
self.tab_widget.addTab(tab, tab_name)
|
||||
logger.info(f"Successfully created tab for {source_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating tab for {source_name}: {e}")
|
||||
return False
|
||||
|
||||
def _create_empty_wine_tab(self, source_name: str, row_count: int) -> QTableWidget:
|
||||
tab = QWidget()
|
||||
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
layout.setSpacing(5)
|
||||
table = self.create_table_widget()
|
||||
table.setRowCount(row_count)
|
||||
layout.addWidget(table, 1)
|
||||
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper()
|
||||
self.tab_widget.addTab(tab, tab_name)
|
||||
return table
|
||||
|
||||
def get_short_source_name(self, full_name):
|
||||
if full_name is None:
|
||||
return "UNKNOWN"
|
||||
@@ -506,6 +599,24 @@ class ProtonManager(DraggableDialog):
|
||||
self.add_installed_row(table, row_index, version_name, version_path)
|
||||
layout.addWidget(table, 1)
|
||||
self.tab_widget.addTab(tab, _("Installed"))
|
||||
size_thread = InstalledWineSizeThread(installed_versions, self)
|
||||
self._installed_size_tables[size_thread] = table
|
||||
size_thread.sizes_loaded.connect(self._on_installed_sizes_loaded)
|
||||
size_thread.finished.connect(self._on_installed_size_thread_finished)
|
||||
size_thread.finished.connect(size_thread.deleteLater)
|
||||
size_thread.start()
|
||||
|
||||
def _on_installed_sizes_loaded(self, sizes: list[str]) -> None:
|
||||
table = self._installed_size_tables.get(self.sender())
|
||||
if table is None or not isValid(table):
|
||||
return
|
||||
for row_index, size in enumerate(sizes):
|
||||
size_item = table.item(row_index, 2)
|
||||
if size_item is not None and size:
|
||||
size_item.setText(size)
|
||||
|
||||
def _on_installed_size_thread_finished(self) -> None:
|
||||
self._installed_size_tables.pop(self.sender(), None)
|
||||
|
||||
def get_installed_versions(self) -> list[tuple[str, str]]:
|
||||
portproton_location = self.portproton_location
|
||||
@@ -518,15 +629,16 @@ class ProtonManager(DraggableDialog):
|
||||
installed_versions = []
|
||||
seen_paths = set()
|
||||
version_dirs = [dist_path]
|
||||
steam_dir = get_steam_compatibilitytools_dir()
|
||||
if steam_dir is not None:
|
||||
version_dirs.append(str(steam_dir))
|
||||
steam_home = get_steam_home()
|
||||
if steam_home is not None:
|
||||
for steam_lib in get_steam_libs(steam_home):
|
||||
common_dir = steam_lib / "steamapps" / "common"
|
||||
if common_dir.is_dir():
|
||||
version_dirs.append(str(common_dir))
|
||||
if not ui_config.get_disable_steam_proton_scanning():
|
||||
steam_dir = get_steam_compatibilitytools_dir()
|
||||
if steam_dir is not None:
|
||||
version_dirs.append(str(steam_dir))
|
||||
steam_home = get_steam_home()
|
||||
if steam_home is not None:
|
||||
for steam_lib in get_steam_libs(steam_home):
|
||||
common_dir = steam_lib / "steamapps" / "common"
|
||||
if common_dir.is_dir():
|
||||
version_dirs.append(str(common_dir))
|
||||
|
||||
for version_dir in version_dirs:
|
||||
for version_name in os.listdir(version_dir):
|
||||
@@ -562,8 +674,7 @@ class ProtonManager(DraggableDialog):
|
||||
version_item = QTableWidgetItem(version_name)
|
||||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 1, version_item)
|
||||
size_str = self.get_directory_size(version_path)
|
||||
size_item = QTableWidgetItem(size_str)
|
||||
size_item = QTableWidgetItem(_("Loading…"))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
@@ -577,30 +688,6 @@ class ProtonManager(DraggableDialog):
|
||||
def on_installed_version_toggled(self, state):
|
||||
self.update_selection_display()
|
||||
|
||||
def get_directory_size(self, path):
|
||||
try:
|
||||
total_size = 0
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
if total_size == 0:
|
||||
return "0 B"
|
||||
elif total_size < 1024:
|
||||
return f"{total_size}.0 B"
|
||||
elif total_size < 1024 * 1024:
|
||||
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
|
||||
elif total_size < 1024 * 1024 * 1024:
|
||||
return f"{int(total_size / (1024 * 1024))}.{int((total_size / (1024 * 1024) * 10) % 10)} MiB"
|
||||
elif total_size < 1024 * 1024 * 1024 * 1024:
|
||||
return f"{int(total_size / (1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024) * 10) % 10)} GiB"
|
||||
else:
|
||||
return f"{int(total_size / (1024 * 1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024 * 1024) * 10) % 10)} TiB"
|
||||
except Exception as e:
|
||||
logger.debug("Failed to format size: %s", e)
|
||||
return _("Unknown")
|
||||
|
||||
def convert_size_to_bytes(self, size_str):
|
||||
if not size_str or size_str == _("Unknown"):
|
||||
return 0
|
||||
@@ -866,8 +953,8 @@ class ProtonManager(DraggableDialog):
|
||||
msg_box.setText(_("Are you sure you want to delete {} selected WINE/Proton?\n\nThis action cannot be undone.").format(len(versions_to_remove)))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -952,10 +1039,20 @@ class ProtonManager(DraggableDialog):
|
||||
QMessageBox.information(self, _("Downloading Complete"), _("All selected WINE/Proton have been downloaded!"))
|
||||
return
|
||||
asset_data = self.assets_to_download[self.current_download_index]
|
||||
local_path = asset_data.get('local_path')
|
||||
if local_path:
|
||||
self.download_progress.setValue(0)
|
||||
self.download_frame.setVisible(True)
|
||||
self.download_frame.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.clear_btn.setEnabled(False)
|
||||
self.start_extraction_for_asset(asset_data, local_path)
|
||||
return
|
||||
self.download_asset(asset_data)
|
||||
|
||||
def download_asset(self, asset_data):
|
||||
temp_dir = tempfile.mkdtemp(prefix="portproton_wine_")
|
||||
asset_data['cleanup_dir'] = temp_dir
|
||||
filename = os.path.join(temp_dir, asset_data['asset_name'])
|
||||
download_url = asset_data['download_url']
|
||||
download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']}"
|
||||
@@ -1026,12 +1123,13 @@ class ProtonManager(DraggableDialog):
|
||||
else:
|
||||
logger.error(f"Failed to extract: {archive_path}")
|
||||
QMessageBox.critical(self, _("Extraction Error"), _("Failed to extract archive: {0}").format(archive_path))
|
||||
temp_dir = os.path.dirname(filepath)
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.debug(f"Cleaned up temporary directory: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
|
||||
cleanup_dir = asset_data.get('cleanup_dir')
|
||||
if cleanup_dir:
|
||||
try:
|
||||
shutil.rmtree(cleanup_dir)
|
||||
logger.debug(f"Cleaned up temporary directory: {cleanup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Waiting for extraction thread to finish...")
|
||||
if not self.current_extraction_thread.wait(500):
|
||||
@@ -1041,12 +1139,13 @@ class ProtonManager(DraggableDialog):
|
||||
def extraction_error(error_msg):
|
||||
logger.error(f"Extraction error: {error_msg}")
|
||||
QMessageBox.critical(self, "Extraction Error", f"Failed to extract archive: {error_msg}")
|
||||
temp_dir = os.path.dirname(filepath)
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.debug(f"Cleaned up temporary directory after error: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
|
||||
cleanup_dir = asset_data.get('cleanup_dir')
|
||||
if cleanup_dir:
|
||||
try:
|
||||
shutil.rmtree(cleanup_dir)
|
||||
logger.debug(f"Cleaned up temporary directory after error: {cleanup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Waiting for extraction thread to finish after error...")
|
||||
if not self.current_extraction_thread.wait(500):
|
||||
@@ -1060,23 +1159,25 @@ class ProtonManager(DraggableDialog):
|
||||
self.current_extraction_thread.error.connect(extraction_error)
|
||||
self.current_extraction_thread.start()
|
||||
except Exception as e:
|
||||
temp_dir = os.path.dirname(filepath)
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.debug(f"Cleaned up temporary directory after exception: {temp_dir}")
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Could not clean up temporary directory {temp_dir}: {cleanup_error}")
|
||||
cleanup_dir = asset_data.get('cleanup_dir')
|
||||
if cleanup_dir:
|
||||
try:
|
||||
shutil.rmtree(cleanup_dir)
|
||||
logger.debug(f"Cleaned up temporary directory after exception: {cleanup_dir}")
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {cleanup_error}")
|
||||
logger.error(f"Error starting extraction thread for {filepath}: {e}")
|
||||
QMessageBox.critical(self, "Extraction Error", f"Failed to start extraction: {e}")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
else:
|
||||
temp_dir = os.path.dirname(filepath)
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.debug(f"Cleaned up temporary directory: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
|
||||
cleanup_dir = asset_data.get('cleanup_dir')
|
||||
if cleanup_dir:
|
||||
try:
|
||||
shutil.rmtree(cleanup_dir)
|
||||
logger.debug(f"Cleaned up temporary directory: {cleanup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
|
||||
logger.warning("PortProton location not provided, skipping extraction")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
@@ -1154,7 +1255,11 @@ class ProtonManager(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
def show_proton_manager(parent=None, portproton_location=None, input_manager=None):
|
||||
def show_proton_manager(
|
||||
parent=None, portproton_location=None, input_manager=None, local_archives=None
|
||||
):
|
||||
dialog = ProtonManager(parent, portproton_location, input_manager=input_manager)
|
||||
dialog.show()
|
||||
if local_archives:
|
||||
dialog.install_local_archives(local_archives)
|
||||
return dialog
|
||||
File diff suppressed because it is too large.
Load diff
@@ -267,6 +267,7 @@ class GamescopeSettingsMixin:
|
||||
self.gamescope_toggle_widgets = {}
|
||||
self.gamescope_toggle_widget_keys = {}
|
||||
self.gamescope_category_groups = {}
|
||||
self.gamescope_search_widget = None
|
||||
self.gamescope_resolution_widgets = {}
|
||||
self.gamescope_actions_group = None
|
||||
self.gamescope_toggle_group = None
|
||||
@@ -612,7 +613,7 @@ class GamescopeSettingsMixin:
|
||||
selector_layout.addWidget(self.gamescope_category_combo)
|
||||
|
||||
self.gamescope_category_stack = QStackedWidget()
|
||||
self.gamescope_category_stack.setStyleSheet("background: transparent;")
|
||||
self.gamescope_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
selector_layout.addWidget(self.gamescope_category_stack)
|
||||
|
||||
toggle_lookup = dict(GAMESCOPE_TOGGLE_SPECS)
|
||||
@@ -1026,7 +1027,38 @@ class GamescopeSettingsMixin:
|
||||
|
||||
def _filter_gamescope_settings(self, search_text):
|
||||
"""Filter Gamescope groups based on search text."""
|
||||
if not self.gamescope_category_groups:
|
||||
return
|
||||
gamescope_enabled = self.current_settings.get('PW_GAMESCOPE') == '1'
|
||||
for checkbox in self.gamescope_toggle_widgets.values():
|
||||
layout = checkbox.parentWidget().layout()
|
||||
if layout is not None:
|
||||
layout.removeWidget(checkbox)
|
||||
self._remove_gamescope_search_page()
|
||||
matching = []
|
||||
for key, checkbox in self.gamescope_toggle_widgets.items():
|
||||
description = GAMESCOPE_TOGGLE_DESCRIPTIONS.get(key, '')
|
||||
matches = not search_text or search_text in f"{checkbox.text()} {description}".lower()
|
||||
checkbox.setVisible(matches)
|
||||
if matches:
|
||||
matching.append((key, checkbox))
|
||||
if search_text:
|
||||
result_widget, result_layout = self._create_gamescope_search_page()
|
||||
for index, (_key, checkbox) in enumerate(matching):
|
||||
result_layout.addWidget(checkbox, index // 2, (index % 2) * 2 + 1)
|
||||
self.gamescope_category_combo.setVisible(False)
|
||||
self.gamescope_category_stack.setCurrentWidget(result_widget)
|
||||
else:
|
||||
category_positions = {}
|
||||
for key, checkbox in matching:
|
||||
category = next((name for name, keys in GAMESCOPE_TOGGLE_CATEGORIES.items() if key in keys), _("Other"))
|
||||
widget = self.gamescope_category_groups[category]
|
||||
index = category_positions.get(category, 0)
|
||||
cast(QGridLayout, widget.layout()).addWidget(checkbox, index // 2, (index % 2) * 2 + 1)
|
||||
category_positions[category] = index + 1
|
||||
self.gamescope_category_combo.setVisible(True)
|
||||
self.on_gamescope_category_changed(self.gamescope_category_combo.currentText())
|
||||
self._update_gamescope_category_stack_height()
|
||||
for group_box in self.gamescope_tab.findChildren(QGroupBox):
|
||||
if not gamescope_enabled:
|
||||
group_box.setVisible(group_box is self.gamescope_actions_group)
|
||||
@@ -1034,8 +1066,34 @@ class GamescopeSettingsMixin:
|
||||
if not search_text:
|
||||
group_box.setVisible(True)
|
||||
continue
|
||||
group_text = group_box.title().lower()
|
||||
label_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QLabel))
|
||||
checkbox_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QCheckBox))
|
||||
content_text = f"{label_text} {checkbox_text}"
|
||||
group_box.setVisible(search_text in group_text or search_text in content_text)
|
||||
content_widgets = group_box.findChildren(QLabel) + group_box.findChildren(QCheckBox)
|
||||
content_match = any(search_text in widget.text().lower() for widget in content_widgets)
|
||||
group_box.setVisible(
|
||||
search_text in group_box.title().lower() or content_match
|
||||
or group_box is self.gamescope_toggle_group and bool(matching)
|
||||
)
|
||||
|
||||
def _create_gamescope_search_page(self) -> tuple[QWidget, QGridLayout]:
|
||||
source_widget = next(iter(self.gamescope_category_groups.values()))
|
||||
source_layout = cast(QGridLayout, source_widget.layout())
|
||||
widget = QWidget()
|
||||
layout = QGridLayout(widget)
|
||||
margins = source_layout.contentsMargins()
|
||||
layout.setContentsMargins(margins.left(), margins.top(), margins.right(), margins.bottom())
|
||||
layout.setHorizontalSpacing(source_layout.horizontalSpacing())
|
||||
layout.setVerticalSpacing(source_layout.verticalSpacing())
|
||||
for column in range(source_layout.columnCount()):
|
||||
layout.setColumnStretch(column, source_layout.columnStretch(column))
|
||||
self.gamescope_category_stack.addWidget(widget)
|
||||
self.gamescope_search_widget = widget
|
||||
return widget, layout
|
||||
|
||||
def _remove_gamescope_search_page(self) -> None:
|
||||
widget = getattr(self, 'gamescope_search_widget', None)
|
||||
if widget is None:
|
||||
return
|
||||
for checkbox in widget.findChildren(QCheckBox):
|
||||
checkbox.setParent(self.gamescope_toggle_group)
|
||||
self.gamescope_category_stack.removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
self.gamescope_search_widget = None
|
||||
@@ -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"),
|
||||
@@ -385,11 +392,13 @@ class MangoHudSettingsMixin:
|
||||
self.mangohud_toggle_widget_keys = {}
|
||||
self.mangohud_fps_widgets = {}
|
||||
self.mangohud_category_groups = {}
|
||||
self.mangohud_search_widget = None
|
||||
self.mangohud_gpu_options = []
|
||||
self.mangohud_actions_group = None
|
||||
self.mangohud_presets_group = None
|
||||
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 = []
|
||||
@@ -411,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()
|
||||
@@ -432,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:
|
||||
@@ -440,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
|
||||
@@ -457,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:
|
||||
@@ -474,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"))
|
||||
@@ -567,7 +631,7 @@ class MangoHudSettingsMixin:
|
||||
selector_layout.addWidget(self.mangohud_category_combo)
|
||||
|
||||
self.mangohud_category_stack = QStackedWidget()
|
||||
self.mangohud_category_stack.setStyleSheet("background: transparent;")
|
||||
self.mangohud_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
selector_layout.addWidget(self.mangohud_category_stack)
|
||||
|
||||
toggle_lookup = dict(MANGOHUD_TOGGLE_SPECS)
|
||||
@@ -865,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:
|
||||
@@ -1064,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)
|
||||
@@ -1178,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(
|
||||
@@ -1219,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}")
|
||||
@@ -1231,9 +1306,42 @@ class MangoHudSettingsMixin:
|
||||
|
||||
def _filter_mangohud_settings(self, search_text):
|
||||
"""Filter MangoHud groups based on search text."""
|
||||
mangohud_enabled = self.current_settings.get('PW_MANGOHUD') == '1'
|
||||
mangohud_user_conf_enabled = self.current_settings.get('PW_MANGOHUD_USER_CONF') == '1'
|
||||
config_visible = mangohud_enabled and not mangohud_user_conf_enabled
|
||||
if not self.mangohud_category_groups:
|
||||
return
|
||||
config_visible = self.current_settings.get('PW_MANGOHUD') == '1' \
|
||||
and self.current_settings.get('PW_MANGOHUD_USER_CONF') != '1'
|
||||
for checkbox in self.mangohud_toggle_widgets.values():
|
||||
layout = checkbox.parentWidget().layout()
|
||||
if layout is not None:
|
||||
layout.removeWidget(checkbox)
|
||||
self._remove_mangohud_search_page()
|
||||
matching = []
|
||||
for key, checkbox in self.mangohud_toggle_widgets.items():
|
||||
description = MANGOHUD_TOGGLE_DESCRIPTIONS.get(key, '')
|
||||
matches = not search_text or search_text in f"{checkbox.text()} {description}".lower()
|
||||
checkbox.setVisible(matches)
|
||||
if matches:
|
||||
matching.append((key, checkbox))
|
||||
columns = self.theme.mangoHudSwitchesColumns
|
||||
if search_text:
|
||||
result_widget, result_layout = self._create_mangohud_search_page()
|
||||
for index, (_key, checkbox) in enumerate(matching):
|
||||
result_layout.addWidget(checkbox, index // columns, (index % columns) * 2 + 1)
|
||||
self.mangohud_category_combo.setVisible(False)
|
||||
self.mangohud_category_stack.setCurrentWidget(result_widget)
|
||||
else:
|
||||
category_positions = {}
|
||||
for key, checkbox in matching:
|
||||
category = next((name for name, keys in MANGOHUD_TOGGLE_CATEGORIES.items() if key in keys), _("Other"))
|
||||
widget = self.mangohud_category_groups[category]
|
||||
index = category_positions.get(category, 0)
|
||||
cast(QGridLayout, widget.layout()).addWidget(
|
||||
checkbox, index // columns, (index % columns) * 2 + 1
|
||||
)
|
||||
category_positions[category] = index + 1
|
||||
self.mangohud_category_combo.setVisible(True)
|
||||
self.on_mangohud_category_changed(self.mangohud_category_combo.currentText())
|
||||
self._update_mangohud_category_stack_height()
|
||||
for group_box in self.mangohud_tab.findChildren(QGroupBox):
|
||||
if not config_visible:
|
||||
group_box.setVisible(group_box is self.mangohud_actions_group)
|
||||
@@ -1241,11 +1349,38 @@ class MangoHudSettingsMixin:
|
||||
if not search_text:
|
||||
group_box.setVisible(True)
|
||||
continue
|
||||
group_text = group_box.title().lower()
|
||||
label_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QLabel))
|
||||
checkbox_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QCheckBox))
|
||||
content_text = f"{label_text} {checkbox_text}"
|
||||
group_box.setVisible(search_text in group_text or search_text in content_text)
|
||||
content_widgets = group_box.findChildren(QLabel) + group_box.findChildren(QCheckBox)
|
||||
content_match = any(search_text in widget.text().lower() for widget in content_widgets)
|
||||
group_box.setVisible(
|
||||
search_text in group_box.title().lower() or content_match
|
||||
or group_box is self.mangohud_toggle_group and bool(matching)
|
||||
)
|
||||
|
||||
def _create_mangohud_search_page(self) -> tuple[QWidget, QGridLayout]:
|
||||
source_widget = next(iter(self.mangohud_category_groups.values()))
|
||||
source_layout = cast(QGridLayout, source_widget.layout())
|
||||
widget = QWidget()
|
||||
layout = QGridLayout(widget)
|
||||
margins = source_layout.contentsMargins()
|
||||
layout.setContentsMargins(margins.left(), margins.top(), margins.right(), margins.bottom())
|
||||
layout.setHorizontalSpacing(source_layout.horizontalSpacing())
|
||||
layout.setVerticalSpacing(source_layout.verticalSpacing())
|
||||
columns = self.theme.mangoHudSwitchesColumns
|
||||
for column in range(columns * 2 + 1):
|
||||
layout.setColumnStretch(column, source_layout.columnStretch(column))
|
||||
self.mangohud_category_stack.addWidget(widget)
|
||||
self.mangohud_search_widget = widget
|
||||
return widget, layout
|
||||
|
||||
def _remove_mangohud_search_page(self) -> None:
|
||||
widget = getattr(self, 'mangohud_search_widget', None)
|
||||
if widget is None:
|
||||
return
|
||||
for checkbox in widget.findChildren(QCheckBox):
|
||||
checkbox.setParent(self.mangohud_toggle_group)
|
||||
self.mangohud_category_stack.removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
self.mangohud_search_widget = None
|
||||
|
||||
def _get_default_mangohud_config(self) -> str | None:
|
||||
"""Read DEFAULT_MANGOHUD_CONFIG from scripts/var."""
|
||||
|
||||
@@ -356,6 +356,28 @@ class VkBasaltSettingsMixin:
|
||||
vkbasalt_enabled = self.current_settings.get('PW_VKBASALT') == '1'
|
||||
user_conf_enabled = self.current_settings.get('PW_VKBASALT_USER_CONF') == '1'
|
||||
config_visible = vkbasalt_enabled and not user_conf_enabled
|
||||
if self.vkbasalt_shaders_layout is not None:
|
||||
shaders_group = self.vkbasalt_shaders_group
|
||||
group_match = not search_text or (
|
||||
shaders_group is not None and search_text in shaders_group.title().lower()
|
||||
)
|
||||
matching = [
|
||||
(shader, checkbox)
|
||||
for shader, checkbox in self.vkbasalt_shader_widgets.items()
|
||||
if group_match or search_text in shader.lower()
|
||||
]
|
||||
matching_widgets = {checkbox for _, checkbox in matching}
|
||||
for checkbox in self.vkbasalt_shader_widgets.values():
|
||||
self.vkbasalt_shaders_layout.removeWidget(checkbox)
|
||||
checkbox.setVisible(group_match or checkbox in matching_widgets)
|
||||
columns = self.theme.mangoHudSwitchesColumns
|
||||
for index, (_, checkbox) in enumerate(matching):
|
||||
self.vkbasalt_shaders_layout.addWidget(
|
||||
checkbox,
|
||||
index // columns,
|
||||
(index % columns) * 2 + 1,
|
||||
)
|
||||
self.vkbasalt_shaders_layout.activate()
|
||||
for group_box in self.vkbasalt_tab.findChildren(QGroupBox):
|
||||
if not config_visible:
|
||||
group_box.setVisible(group_box is self.vkbasalt_actions_group)
|
||||
|
||||
@@ -31,6 +31,20 @@ class ExtractionThread(QThread):
|
||||
self._mutex.unlock()
|
||||
return not running
|
||||
|
||||
def _get_entry_path(self, entry_path: str, link_path: str | None = None) -> str:
|
||||
extract_root = os.path.realpath(self.extract_dir)
|
||||
target_path = os.path.realpath(os.path.join(extract_root, entry_path))
|
||||
if os.path.commonpath((extract_root, target_path)) != extract_root:
|
||||
raise ValueError(f"Archive entry escapes extraction directory: {entry_path}")
|
||||
if link_path is None:
|
||||
return target_path
|
||||
link_target = os.path.realpath(
|
||||
os.path.join(os.path.dirname(target_path), link_path)
|
||||
)
|
||||
if os.path.commonpath((extract_root, link_target)) != extract_root:
|
||||
raise ValueError(f"Archive link escapes extraction directory: {entry_path}")
|
||||
return target_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.progress.emit(0)
|
||||
@@ -48,10 +62,6 @@ class ExtractionThread(QThread):
|
||||
last_progress = -1
|
||||
last_bytes_read = 0
|
||||
|
||||
original_dir = os.getcwd()
|
||||
old_umask = os.umask(0)
|
||||
os.chdir(self.extract_dir)
|
||||
|
||||
try:
|
||||
deferred_times = []
|
||||
|
||||
@@ -63,23 +73,25 @@ class ExtractionThread(QThread):
|
||||
entry_path = entry.pathname
|
||||
if entry_path is None:
|
||||
continue
|
||||
link_path = os.fsdecode(entry.linkpath) if entry.issym else None
|
||||
target_path = self._get_entry_path(entry_path, link_path)
|
||||
|
||||
if entry.isdir:
|
||||
os.makedirs(entry_path, exist_ok=True)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
if entry.mode:
|
||||
try:
|
||||
os.chmod(entry_path, entry.mode)
|
||||
os.chmod(target_path, entry.mode)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
if entry.mtime:
|
||||
deferred_times.append((entry_path, entry.mtime))
|
||||
deferred_times.append((target_path, entry.mtime))
|
||||
|
||||
elif entry.isfile:
|
||||
parent_dir = os.path.dirname(entry_path)
|
||||
parent_dir = os.path.dirname(target_path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
with open(entry_path, 'wb') as f:
|
||||
with open(target_path, 'wb') as f:
|
||||
for block in entry.get_blocks():
|
||||
if self._should_stop():
|
||||
return
|
||||
@@ -87,28 +99,27 @@ class ExtractionThread(QThread):
|
||||
|
||||
if entry.mode:
|
||||
try:
|
||||
os.chmod(entry_path, entry.mode)
|
||||
os.chmod(target_path, entry.mode)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
if entry.mtime:
|
||||
try:
|
||||
os.utime(entry_path, (entry.mtime, entry.mtime))
|
||||
os.utime(target_path, (entry.mtime, entry.mtime))
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
elif entry.issym:
|
||||
parent_dir = os.path.dirname(entry_path)
|
||||
parent_dir = os.path.dirname(target_path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
if os.path.lexists(entry_path):
|
||||
os.remove(entry_path)
|
||||
if os.path.lexists(target_path):
|
||||
os.remove(target_path)
|
||||
|
||||
link_path = entry.linkpath
|
||||
if link_path is not None:
|
||||
try:
|
||||
os.symlink(link_path, entry_path)
|
||||
os.symlink(link_path, target_path)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
@@ -152,8 +163,7 @@ class ExtractionThread(QThread):
|
||||
self.finished.emit(self.archive_path, True)
|
||||
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
os.umask(old_umask)
|
||||
self.eta.emit(0)
|
||||
|
||||
except Exception as e:
|
||||
if not self._should_stop():
|
||||
|
||||
@@ -13,6 +13,33 @@ from portprotonqt.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_directory_size(path: str) -> str:
|
||||
try:
|
||||
total_size = 0
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
if total_size == 0:
|
||||
return "0 B"
|
||||
if total_size < 1024:
|
||||
return f"{total_size}.0 B"
|
||||
if total_size < 1024 * 1024:
|
||||
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
|
||||
if total_size < 1024 * 1024 * 1024:
|
||||
value = total_size / (1024 * 1024)
|
||||
return f"{int(value)}.{int((value * 10) % 10)} MiB"
|
||||
if total_size < 1024 * 1024 * 1024 * 1024:
|
||||
value = total_size / (1024 * 1024 * 1024)
|
||||
return f"{int(value)}.{int((value * 10) % 10)} GiB"
|
||||
value = total_size / (1024 * 1024 * 1024 * 1024)
|
||||
return f"{int(value)}.{int((value * 10) % 10)} TiB"
|
||||
except Exception as error:
|
||||
logger.debug("Failed to format size: %s", error)
|
||||
return ""
|
||||
|
||||
|
||||
def get_cpu_level():
|
||||
"""
|
||||
Determine CPU level based on feature flags.
|
||||
@@ -64,7 +91,18 @@ class WineLoadingThread(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
cloud_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
github_url = (
|
||||
"https://raw.githubusercontent.com/linux-gaming-ru/"
|
||||
"PortProtonQt-Wine-Metadata/main/wine_metadata_all.json"
|
||||
)
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
json_url = (
|
||||
cloud_url
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD"
|
||||
else github_url
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as tmp:
|
||||
temp_path = tmp.name
|
||||
@@ -90,3 +128,17 @@ class WineLoadingThread(QThread):
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading metadata: {e}")
|
||||
self.loading_error.emit(str(e))
|
||||
|
||||
|
||||
class InstalledWineSizeThread(QThread):
|
||||
"""Load installed Wine directory sizes outside the UI thread."""
|
||||
|
||||
sizes_loaded = Signal(object)
|
||||
|
||||
def __init__(self, versions: list[tuple[str, str]], parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.versions = versions
|
||||
|
||||
def run(self) -> None:
|
||||
sizes = [get_directory_size(path) for _name, path in self.versions]
|
||||
self.sizes_loaded.emit(sizes)
|
||||
@@ -6,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
|
||||
|
||||
@@ -86,12 +86,12 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.main_layout.addWidget(self.hints_widget)
|
||||
|
||||
if self.input_manager:
|
||||
self.input_manager.button_event.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
self.input_manager.dpad_moved.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('winetricks_dialog', update_hints)
|
||||
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
|
||||
def setup_ui(self):
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
"""Epic Games Store integration through Legendary."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
|
||||
from portprotonqt.localization import get_store_content_languages
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
EGS_LOGIN_URL = "https://legendary.gl/epiclogin"
|
||||
LEGENDARY_RELEASE_URL = (
|
||||
"https://api.github.com/repos/Heroic-Games-Launcher/legendary/releases/latest"
|
||||
)
|
||||
LEGENDARY_TIMEOUT = 120
|
||||
EGS_METADATA_WORKERS = 4
|
||||
EGS_METADATA_TIMEOUT = 15
|
||||
EGS_DESCRIPTION_CACHE_VERSION = 2
|
||||
LEGENDARY_UPDATE_INTERVAL = 30 * 24 * 60 * 60
|
||||
EGS_API_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 EpicGamesLauncher"
|
||||
)
|
||||
|
||||
|
||||
class EGSAPI:
|
||||
"""Provide EGS authentication, library and installation helpers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
self.data_dir = data_home / "PortProtonQt" / "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.bin_dir / "legendary.version"
|
||||
self.games_dir = Path.home() / "Games"
|
||||
|
||||
def get_legendary_path(self) -> str | None:
|
||||
"""Return an available Legendary executable."""
|
||||
bundled = self.bin_dir / "legendary"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
return str(bundled)
|
||||
return shutil.which("legendary")
|
||||
|
||||
def ensure_legendary(self) -> str:
|
||||
"""Download the current official Heroic Legendary binary if needed."""
|
||||
existing = self.get_legendary_path()
|
||||
if existing:
|
||||
return existing
|
||||
asset, release_tag = self._get_latest_legendary_release()
|
||||
return self._download_legendary(asset, release_tag)
|
||||
|
||||
def update_legendary(self) -> str:
|
||||
"""Update the bundled Legendary binary when a newer release exists."""
|
||||
bundled = self.bin_dir / "legendary"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
try:
|
||||
last_check = self.version_path.stat().st_mtime
|
||||
except OSError:
|
||||
last_check = 0
|
||||
if time.time() - last_check < LEGENDARY_UPDATE_INTERVAL:
|
||||
return str(bundled)
|
||||
asset, release_tag = self._get_latest_legendary_release()
|
||||
if (
|
||||
bundled.is_file()
|
||||
and self._get_legendary_release_tag() == release_tag
|
||||
):
|
||||
self.version_path.touch()
|
||||
return str(bundled)
|
||||
return self._download_legendary(asset, release_tag)
|
||||
|
||||
def _get_latest_legendary_release(self) -> tuple[dict, str]:
|
||||
"""Return the matching Legendary asset and release tag."""
|
||||
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(
|
||||
platform.machine()
|
||||
)
|
||||
if not architecture:
|
||||
raise OSError(f"Unsupported Legendary architecture: {platform.machine()}")
|
||||
response = requests.get(LEGENDARY_RELEASE_URL, timeout=15)
|
||||
response.raise_for_status()
|
||||
release = response.json()
|
||||
asset_name = f"legendary_linux_{architecture}"
|
||||
asset = next(
|
||||
(item for item in release.get("assets", []) if item.get("name") == asset_name),
|
||||
None,
|
||||
)
|
||||
if not asset:
|
||||
raise OSError(f"Legendary release asset not found: {asset_name}")
|
||||
release_tag = str(release.get("tag_name", ""))
|
||||
if not release_tag:
|
||||
raise OSError("Legendary release tag not found")
|
||||
return asset, release_tag
|
||||
|
||||
def _get_legendary_release_tag(self) -> str:
|
||||
try:
|
||||
return self.version_path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def _download_legendary(self, asset: dict, release_tag: str) -> str:
|
||||
target = self.bin_dir / "legendary"
|
||||
temporary = target.with_suffix(".part")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with requests.get(asset["browser_download_url"], stream=True, timeout=30) as response:
|
||||
response.raise_for_status()
|
||||
with open(temporary, "wb") as output:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
expected = str(asset.get("digest", "")).removeprefix("sha256:")
|
||||
if not expected or digest.hexdigest() != expected:
|
||||
raise OSError("Legendary checksum verification failed")
|
||||
temporary.chmod(0o755)
|
||||
temporary.replace(target)
|
||||
self.version_path.write_text(release_tag, encoding="utf-8")
|
||||
return str(target)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def build_command(self, arguments: list[str]) -> list[str]:
|
||||
"""Build a Legendary command using private configuration paths."""
|
||||
executable = self.get_legendary_path()
|
||||
if not executable:
|
||||
raise FileNotFoundError("Legendary executable not found")
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.config_dir.chmod(0o700)
|
||||
command_arguments = list(arguments)
|
||||
if "-y" in command_arguments:
|
||||
command_arguments.remove("-y")
|
||||
command_arguments.insert(0, "-y")
|
||||
return [executable, *command_arguments]
|
||||
|
||||
def get_environment(self) -> dict[str, str]:
|
||||
"""Return the environment required by Legendary."""
|
||||
environment = os.environ.copy()
|
||||
environment["LEGENDARY_CONFIG_PATH"] = str(self.config_dir)
|
||||
return environment
|
||||
|
||||
def authenticate(self, code: str) -> tuple[bool, str]:
|
||||
"""Authenticate an Epic account with an authorization code."""
|
||||
self.ensure_legendary()
|
||||
result = subprocess.run(
|
||||
self.build_command(["auth", "--code", code]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
if result.returncode == 0 and self.user_path.is_file():
|
||||
self.user_path.chmod(0o600)
|
||||
return True, ""
|
||||
error = result.stderr.decode(errors="replace").strip()
|
||||
logger.error("Legendary authentication failed: %s", error)
|
||||
return False, error or f"Legendary exited with code {result.returncode}"
|
||||
|
||||
@staticmethod
|
||||
def extract_auth_code(text: str) -> str:
|
||||
"""Extract an Epic authorization code from copied text or JSON."""
|
||||
value = text.strip()
|
||||
try:
|
||||
data = orjson.loads(value)
|
||||
except orjson.JSONDecodeError:
|
||||
return value if "://" not in value else ""
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
return str(data.get("authorizationCode", "")).strip()
|
||||
|
||||
def logout(self) -> bool:
|
||||
"""Remove the Epic login and cached library."""
|
||||
try:
|
||||
if self.get_legendary_path() and self.user_path.is_file():
|
||||
subprocess.run(
|
||||
self.build_command(["auth", "--delete"]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
self.user_path.unlink(missing_ok=True)
|
||||
self.library_path.unlink(missing_ok=True)
|
||||
return True
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
logger.error("Failed to disconnect Epic account: %s", error)
|
||||
return False
|
||||
|
||||
def get_account_name(self) -> str:
|
||||
"""Return the Epic display name from Legendary state."""
|
||||
user = self._load_json(self.user_path, {})
|
||||
return str(user.get("displayName", "")) if isinstance(user, dict) else ""
|
||||
|
||||
def refresh_library(
|
||||
self, progress_callback: Callable[[int, int], None] | None = None
|
||||
) -> list[dict]:
|
||||
"""Refresh and cache the Epic library through Legendary."""
|
||||
self.update_legendary()
|
||||
result = subprocess.run(
|
||||
self.build_command(["list", "--json", "--third-party"]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.decode(errors="replace").strip()
|
||||
raise OSError(error or "Failed to refresh Epic library")
|
||||
raw_games = orjson.loads(result.stdout)
|
||||
if not isinstance(raw_games, list):
|
||||
raise OSError("Legendary returned an invalid Epic library")
|
||||
games = []
|
||||
for game in raw_games:
|
||||
metadata = game.get("metadata", {})
|
||||
if metadata.get("mainGameItem"):
|
||||
continue
|
||||
release_info = metadata.get("releaseInfo", [])
|
||||
mobile_only = bool(release_info) and all(
|
||||
info.get("platform")
|
||||
and all(platform in {"Android", "iOS"}
|
||||
for platform in info["platform"])
|
||||
for info in release_info
|
||||
)
|
||||
if not mobile_only:
|
||||
games.append(self._normalize_game(game))
|
||||
games = [game for game in games if game.get("app_id")]
|
||||
self._enrich_descriptions(games, progress_callback)
|
||||
self._save_json(self.library_path, games)
|
||||
return games
|
||||
|
||||
def load_library(self) -> list[dict]:
|
||||
"""Load the cached Epic library."""
|
||||
data = self._load_json(self.library_path, [])
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def clear_library_cache(self) -> None:
|
||||
"""Remove cached Epic library metadata."""
|
||||
try:
|
||||
self.library_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to clear Epic library cache: %s", error)
|
||||
|
||||
def load_installed(self) -> dict[str, dict]:
|
||||
"""Load Legendary installed game records."""
|
||||
data = self._load_json(self.config_dir / "installed.json", {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def is_game_installed(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> bool:
|
||||
"""Return whether Legendary records a usable installation."""
|
||||
return self.get_launch_target(app_id, installed_data) is not None
|
||||
|
||||
@staticmethod
|
||||
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
|
||||
"""Return whether the EOS overlay is enabled in a Wine prefix."""
|
||||
try:
|
||||
registry = (prefix_path / "user.reg").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
for section in registry.casefold().split("\n["):
|
||||
if section.startswith(r"software\\epic games\\eos]"):
|
||||
if '"overlaypath"=' in section:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_launch_target(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> str | None:
|
||||
"""Return the installed game's Windows executable."""
|
||||
installed = (
|
||||
installed_data if installed_data is not None else self.load_installed()
|
||||
).get(app_id, {})
|
||||
install_path = Path(str(installed.get("install_path", "")))
|
||||
executable = str(installed.get("executable", "")).replace("\\", "/")
|
||||
target = install_path.joinpath(*executable.split("/"))
|
||||
return str(target) if executable and target.is_file() else None
|
||||
|
||||
def get_cached_download_sizes(self, app_id: str) -> tuple[int, int] | None:
|
||||
"""Return cached EGS download and disk sizes."""
|
||||
cache = self._load_json(self.sizes_path, {})
|
||||
value = cache.get(app_id) if isinstance(cache, dict) else None
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return None
|
||||
sizes = int(value[0]), int(value[1])
|
||||
return None if sizes[0] > 0 and sizes[1] <= 0 else sizes
|
||||
|
||||
def save_download_sizes(self, app_id: str, sizes: tuple[int, int]) -> None:
|
||||
"""Cache EGS download and disk sizes."""
|
||||
cache = self._load_json(self.sizes_path, {})
|
||||
if not isinstance(cache, dict):
|
||||
cache = {}
|
||||
cache[app_id] = list(sizes)
|
||||
self._save_json(self.sizes_path, cache)
|
||||
|
||||
@staticmethod
|
||||
def parse_download_sizes(output: bytes) -> tuple[int, int]:
|
||||
"""Extract download and install sizes from Legendary JSON."""
|
||||
data = orjson.loads(output)
|
||||
values: dict[str, int] = {}
|
||||
|
||||
def visit(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if key in {"download_size", "install_size", "disk_size"}:
|
||||
try:
|
||||
values[key] = max(values.get(key, 0), int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
visit(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
visit(item)
|
||||
|
||||
visit(data)
|
||||
install_size = values.get("install_size") or values.get("disk_size", 0)
|
||||
return values.get("download_size", 0), install_size
|
||||
|
||||
def _enrich_descriptions(
|
||||
self, games: list[dict],
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
) -> None:
|
||||
cached = {str(game.get("app_id")): game for game in self.load_library()}
|
||||
pending = []
|
||||
completed = 0
|
||||
total = len(games)
|
||||
preferred_locale = get_store_content_languages()[0]
|
||||
for game in games:
|
||||
old_game = cached.get(game["app_id"], {})
|
||||
old_description = str(old_game.get("description", ""))
|
||||
if (
|
||||
old_description
|
||||
and old_description != game["title"]
|
||||
and old_game.get("description_preference") == preferred_locale
|
||||
and old_game.get("description_cache_version")
|
||||
== EGS_DESCRIPTION_CACHE_VERSION
|
||||
):
|
||||
game["description"] = old_description
|
||||
game["description_locale"] = str(old_game.get("description_locale", ""))
|
||||
game["description_preference"] = preferred_locale
|
||||
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
|
||||
completed += 1
|
||||
if progress_callback:
|
||||
progress_callback(completed, total)
|
||||
else:
|
||||
pending.append(game)
|
||||
if not pending:
|
||||
return
|
||||
worker_count = min(EGS_METADATA_WORKERS, os.cpu_count() or 1)
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||
descriptions = executor.map(self._get_store_description, pending)
|
||||
for game, result in zip(pending, descriptions, strict=True):
|
||||
description, description_locale = result
|
||||
if description:
|
||||
game["description"] = description
|
||||
game["description_locale"] = description_locale
|
||||
game["description_preference"] = preferred_locale
|
||||
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
|
||||
completed += 1
|
||||
if progress_callback:
|
||||
progress_callback(completed, total)
|
||||
|
||||
def _get_store_description(self, game: dict) -> tuple[str, str]:
|
||||
slug = self._get_product_slug(str(game.get("namespace", "")), game["title"])
|
||||
for language in get_store_content_languages():
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://store-content.ak.epicgames.com/api/"
|
||||
f"{language}/content/products/{slug}",
|
||||
headers={"User-Agent": EGS_API_USER_AGENT},
|
||||
timeout=EGS_METADATA_TIMEOUT,
|
||||
)
|
||||
if response.status_code == requests.codes.not_found:
|
||||
continue
|
||||
response.raise_for_status()
|
||||
description = self._parse_store_description(response.json())
|
||||
if description:
|
||||
return description, language
|
||||
except requests.RequestException as error:
|
||||
logger.debug(
|
||||
"Epic description unavailable for %s: %s", game["app_id"], error
|
||||
)
|
||||
return "", ""
|
||||
return "", ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_store_description(data: Any) -> str:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("pages"), list):
|
||||
return ""
|
||||
page = next(
|
||||
(item for item in data["pages"]
|
||||
if isinstance(item, dict) and item.get("type") == "productHome"),
|
||||
{},
|
||||
)
|
||||
about = page.get("data", {}).get("about", {})
|
||||
short_description = str(about.get("shortDescription") or "").strip()
|
||||
if short_description:
|
||||
return short_description
|
||||
description = str(about.get("description") or "").strip()
|
||||
return description.split("\n\n", 1)[0].strip()
|
||||
|
||||
def _get_product_slug(self, namespace: str, title: str) -> str:
|
||||
fallback = "-".join("".join(
|
||||
character if character.isascii() and character.isalnum() else " "
|
||||
for character in title.lower()
|
||||
).split())
|
||||
if not namespace:
|
||||
return fallback
|
||||
query = {
|
||||
"query": "query ProductSlug($namespace: String!) { Catalog { "
|
||||
"catalogNs(namespace: $namespace) { mappings(pageType: "
|
||||
"\"productHome\") { pageSlug pageType } } } }",
|
||||
"variables": {"namespace": namespace},
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://launcher.store.epicgames.com/graphql",
|
||||
json=query,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": EGS_API_USER_AGENT,
|
||||
},
|
||||
timeout=EGS_METADATA_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
mappings = response.json()["data"]["Catalog"]["catalogNs"]["mappings"]
|
||||
return str(mappings[0].get("pageSlug") or fallback) if mappings else fallback
|
||||
except (KeyError, IndexError, TypeError, requests.RequestException) as error:
|
||||
logger.debug("Epic product slug unavailable for %s: %s", namespace, error)
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _normalize_game(game: dict) -> dict:
|
||||
metadata = game.get("metadata", {})
|
||||
images = metadata.get("keyImages", []) if isinstance(metadata, dict) else []
|
||||
cover = next(
|
||||
(str(image.get("url", "")) for image in images
|
||||
if image.get("type") in ("DieselGameBoxTall", "OfferImageTall")),
|
||||
"",
|
||||
)
|
||||
return {
|
||||
"app_id": str(game.get("app_name") or game.get("appName") or ""),
|
||||
"title": str(game.get("app_title") or metadata.get("title") or ""),
|
||||
"description": str(metadata.get("description", "")),
|
||||
"cover": cover,
|
||||
"namespace": str(metadata.get("namespace", "")),
|
||||
"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,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _load_json(path: Path, default: Any) -> Any:
|
||||
try:
|
||||
return orjson.loads(path.read_bytes())
|
||||
except (OSError, orjson.JSONDecodeError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _save_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(orjson.dumps(data, option=orjson.OPT_INDENT_2))
|
||||
+418
-157
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtGui import (
|
||||
QPainter,
|
||||
@@ -12,8 +13,13 @@ from PySide6.QtGui import (
|
||||
QIcon,
|
||||
QPen,
|
||||
QLinearGradient,
|
||||
QEnterEvent,
|
||||
QFocusEvent,
|
||||
QMouseEvent,
|
||||
QKeyEvent,
|
||||
QPaintEvent,
|
||||
)
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -24,6 +30,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QStackedLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
)
|
||||
from portprotonqt.image_utils import (
|
||||
cleanup_animated_cover,
|
||||
@@ -199,11 +206,136 @@ class SourceCorner(QWidget):
|
||||
self._visible = visible
|
||||
super().setVisible(visible)
|
||||
|
||||
def refresh_source_theme(self, config: dict, icon: str | QIcon | None) -> None:
|
||||
"""Refresh source-corner visuals for a live theme change."""
|
||||
self._cfg = config
|
||||
self._icon = icon
|
||||
self._color = QColor(config.get("ribbon_color", "#3f424d"))
|
||||
self._fold_color = QColor(config.get("ribbon_fold_color", "#00000096"))
|
||||
self.update()
|
||||
|
||||
class GameCard(QFrame):
|
||||
|
||||
class AnimatedCard(QFrame):
|
||||
animation_base_size: tuple[int, int]
|
||||
borderWidthChanged = Signal()
|
||||
gradientAngleChanged = Signal()
|
||||
scaleChanged = Signal()
|
||||
hoverChanged = Signal(str, bool)
|
||||
focusChanged = Signal(str, bool)
|
||||
clicked = Signal()
|
||||
|
||||
def setup_card_animations(self, theme: Any, layout_config: dict) -> None:
|
||||
self.theme = theme
|
||||
self.card_layout_cfg = layout_config
|
||||
self._borderWidth = layout_config.get(
|
||||
"default_border_width", theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||
)
|
||||
self._gradientAngle = layout_config.get(
|
||||
"gradient_start_angle", theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||
)
|
||||
self._scale = layout_config.get(
|
||||
"default_scale", theme.GAME_CARD_ANIMATION["default_scale"]
|
||||
)
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
self._hover_sound_pending = False
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setMouseTracking(True)
|
||||
self.animations = GameCardAnimations(self, theme)
|
||||
self.animations.setup_animations()
|
||||
|
||||
def update_scale(self) -> None:
|
||||
width, height = self.animation_base_size
|
||||
self.setFixedSize(int(width * self._scale), int(height * self._scale))
|
||||
|
||||
def getBorderWidth(self) -> int:
|
||||
return self._borderWidth
|
||||
|
||||
def setBorderWidth(self, value: int) -> None:
|
||||
self._borderWidth = value
|
||||
self.borderWidthChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getGradientAngle(self) -> float:
|
||||
return self._gradientAngle
|
||||
|
||||
def setGradientAngle(self, value: float) -> None:
|
||||
self._gradientAngle = value
|
||||
self.gradientAngleChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getScale(self) -> float:
|
||||
return self._scale
|
||||
|
||||
def setScale(self, value: float) -> None:
|
||||
self._scale = value
|
||||
self.update_scale()
|
||||
self.scaleChanged.emit()
|
||||
|
||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, notify=borderWidthChanged)
|
||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, notify=gradientAngleChanged)
|
||||
scale = Property(float, getScale, setScale, notify=scaleChanged)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
parent = self.parentWidget()
|
||||
layout = parent.layout() if parent is not None else None
|
||||
if getattr(self, "list_layout", False) and isinstance(layout, QVBoxLayout):
|
||||
row_pitch = self.height() + layout.spacing()
|
||||
row = (self.y() - layout.contentsMargins().top()) // max(1, row_pitch)
|
||||
colors = (self.theme.color_bg, self.theme.color_surface_elevated)
|
||||
painter.fillRect(self.rect(), QColor(colors[row % len(colors)]))
|
||||
self.animations.paint_border(painter)
|
||||
if getattr(self, "batch_selected", False):
|
||||
painter.setPen(QPen(QColor(self.theme.color_accent),
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"]))
|
||||
painter.drawRect(self.rect())
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.raise_()
|
||||
self._hover_sound_pending = True
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
self._hover_sound_pending = False
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
||||
if self._hover_sound_pending:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self._hover_sound_pending = False
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def focusInEvent(self, event: QFocusEvent) -> None:
|
||||
self.raise_()
|
||||
if QApplication.activeWindow() is not None and event.reason() not in (
|
||||
Qt.FocusReason.ActiveWindowFocusReason,
|
||||
Qt.FocusReason.MouseFocusReason,
|
||||
):
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event: QFocusEvent) -> None:
|
||||
self.animations.handle_focus_out_event()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.position().toPoint()):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
self.clicked.emit()
|
||||
|
||||
|
||||
class GameCard(AnimatedCard):
|
||||
coverReady = Signal(object)
|
||||
editShortcutRequested = Signal(str, str, str)
|
||||
deleteGameRequested = Signal(str, str)
|
||||
addToMenuRequested = Signal(str, str)
|
||||
@@ -213,8 +345,6 @@ class GameCard(QFrame):
|
||||
addToSteamRequested = Signal(str, str, str)
|
||||
removeFromSteamRequested = Signal(str, str)
|
||||
openGameFolderRequested = Signal(str, str)
|
||||
hoverChanged = Signal(str, bool)
|
||||
focusChanged = Signal(str, bool)
|
||||
|
||||
def __init__(
|
||||
self, name, description, cover_path, appid, controller_support, exec_line,
|
||||
@@ -224,6 +354,7 @@ class GameCard(QFrame):
|
||||
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
|
||||
@@ -249,6 +380,7 @@ class GameCard(QFrame):
|
||||
|
||||
self.select_callback = select_callback
|
||||
self.context_menu_manager = context_menu_manager
|
||||
self.batch_selected = False
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.theme_manager = ThemeManager()
|
||||
@@ -257,33 +389,48 @@ class GameCard(QFrame):
|
||||
self.display_filter = game_config.get_display_filter()
|
||||
self.badge_view_mode = ui_config.get_badge_view_mode()
|
||||
self.current_theme_name = ui_config.get_theme()
|
||||
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.list_layout = self.layout_mode == "list"
|
||||
parent_mode = parent.property("library_layout_mode") if parent is not None else None
|
||||
self.layout_mode = str(
|
||||
parent_mode or getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
).lower()
|
||||
self.list_layout = self.layout_mode in {"list", "vertical"}
|
||||
self.vertical_layout = self.layout_mode == "vertical"
|
||||
self.horizontal_layout = self.layout_mode in {"horizontal", "horizontal_top"}
|
||||
self.economy_mode = ui_config.get_economy_mode()
|
||||
self.missing_executable_path = self._get_missing_executable_path()
|
||||
|
||||
self.steam_visible = (str(game_source).lower() == "steam" and not self.economy_mode)
|
||||
self.gog_visible = (str(game_source).lower() == "gog" and not self.economy_mode)
|
||||
self.portproton_visible = (str(game_source).lower() == "portproton" and not self.economy_mode)
|
||||
self.steam_visible = str(game_source).lower() == "steam"
|
||||
self.gog_visible = str(game_source).lower() == "gog"
|
||||
self.egs_visible = str(game_source).lower() == "egs"
|
||||
self.native_visible = str(game_source).lower() == "native"
|
||||
self.portproton_visible = str(game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
config_name = "GAME_CARD_LIST" if self.list_layout else "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = getattr(self.theme, config_name, {})
|
||||
if self.vertical_layout:
|
||||
config_name = "GAME_CARD_VERTICAL"
|
||||
elif self.list_layout:
|
||||
config_name = "GAME_CARD_LIST"
|
||||
elif self.horizontal_layout:
|
||||
config_name = "GAME_CARD_HORIZONTAL"
|
||||
else:
|
||||
config_name = "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = 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)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
||||
|
||||
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
self._hover_sound_pending = False
|
||||
self.setMouseTracking(True)
|
||||
|
||||
self.animations = GameCardAnimations(self, self.theme)
|
||||
self.animations.setup_animations()
|
||||
card_style_name = (
|
||||
"GAME_CARD_VERTICAL_STYLE"
|
||||
if self.vertical_layout
|
||||
else "GAME_CARD_WINDOW_STYLE"
|
||||
)
|
||||
self.setProperty("theme_style_name", card_style_name)
|
||||
card_style = (
|
||||
self.theme.GAME_CARD_VERTICAL_STYLE
|
||||
if self.vertical_layout
|
||||
else self.theme.GAME_CARD_WINDOW_STYLE
|
||||
)
|
||||
self.setStyleSheet(card_style)
|
||||
self.setup_card_animations(self.theme, self.card_layout_cfg)
|
||||
|
||||
self.shadow = QGraphicsDropShadowEffect(self)
|
||||
self.shadow.setBlurRadius(self.theme.shadow_blur_radius)
|
||||
@@ -301,6 +448,7 @@ class GameCard(QFrame):
|
||||
|
||||
self.coverWidget = QWidget()
|
||||
if self.list_layout:
|
||||
self.coverWidget.setProperty("theme_style_name", "COVER_WIDGET_STYLE")
|
||||
self.coverWidget.setStyleSheet(self.theme.COVER_WIDGET_STYLE)
|
||||
coverLayout = QStackedLayout(self.coverWidget)
|
||||
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -310,8 +458,8 @@ class GameCard(QFrame):
|
||||
self.coverLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
|
||||
if self.missing_executable_path or (
|
||||
str(self.game_source).lower() == "gog"
|
||||
and self.exec_line.startswith("gog://install/")
|
||||
str(self.game_source).lower() in ("gog", "egs")
|
||||
and self.exec_line.startswith(("gog://install/", "egs://install/"))
|
||||
):
|
||||
self.coverOpacity = QGraphicsOpacityEffect(self.coverLabel)
|
||||
self.coverOpacity.setOpacity(self.theme.missing_exe_cover_opacity)
|
||||
@@ -323,6 +471,7 @@ class GameCard(QFrame):
|
||||
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:
|
||||
@@ -349,9 +498,7 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
if self.economy_mode:
|
||||
self.steamLabel.setVisible(False)
|
||||
self.steamLabel.setVisible(self.steam_visible and not self.vertical_layout)
|
||||
|
||||
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
|
||||
self.gogLabel = SourceCorner(
|
||||
@@ -359,9 +506,22 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
if self.economy_mode:
|
||||
self.gogLabel.setVisible(False)
|
||||
self.gogLabel.setVisible(self.gog_visible and not self.vertical_layout)
|
||||
|
||||
egs_icon = self.theme_manager.get_icon("badge_egs", as_path=True)
|
||||
self.egsLabel = SourceCorner(
|
||||
icon=egs_icon,
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
|
||||
|
||||
self.nativeLabel = SourceCorner(
|
||||
icon=self.theme_manager.get_icon("badge_linux", as_path=True),
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.nativeLabel.setVisible(self.native_visible and not self.vertical_layout)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
@@ -369,9 +529,9 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
if self.economy_mode:
|
||||
self.portprotonLabel.setVisible(False)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and not self.vertical_layout
|
||||
)
|
||||
|
||||
if self.ppdb_visible:
|
||||
self.ppdbLabel = ClickableLabel(
|
||||
@@ -417,9 +577,36 @@ class GameCard(QFrame):
|
||||
self.nameLabel.setContentsMargins(0, 0, 10, 0)
|
||||
else:
|
||||
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||
self.layout_.addWidget(self.nameLabel)
|
||||
if self.list_layout:
|
||||
if self.horizontal_layout:
|
||||
self.nameLabel.setWordWrap(True)
|
||||
name_style = (
|
||||
self.theme.GAME_CARD_VERTICAL_NAME_STYLE
|
||||
if self.vertical_layout
|
||||
else self.theme.GAME_CARD_NAME_LABEL_STYLE
|
||||
)
|
||||
self.nameLabel.setStyleSheet(name_style)
|
||||
if self.vertical_layout:
|
||||
self.nameLabel.setProperty(
|
||||
"theme_style_name", "GAME_CARD_VERTICAL_NAME_STYLE"
|
||||
)
|
||||
if self.vertical_layout:
|
||||
stretches = self.theme.GAME_CARD_VERTICAL["column_stretches"]
|
||||
self.layout_.addWidget(self.nameLabel, stretches[0])
|
||||
for text, stretch in zip(
|
||||
(self.last_launch, self.formatted_playtime, str(self.game_source)),
|
||||
stretches[1:],
|
||||
strict=True,
|
||||
):
|
||||
label = QLabel(text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setProperty(
|
||||
"theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE"
|
||||
)
|
||||
label.setStyleSheet(self.theme.GAME_CARD_COLUMN_LABEL_STYLE)
|
||||
self.layout_.addWidget(label, stretch)
|
||||
else:
|
||||
self.layout_.addWidget(self.nameLabel)
|
||||
if self.list_layout and not self.vertical_layout:
|
||||
self.layout_.addStretch()
|
||||
|
||||
font_size = self.nameLabel.font().pointSizeF()
|
||||
@@ -436,6 +623,115 @@ class GameCard(QFrame):
|
||||
layout.invalidate()
|
||||
parent.updateGeometry()
|
||||
|
||||
def refresh_theme(self, theme: Any) -> None:
|
||||
"""Refresh cached card visuals after a live theme change."""
|
||||
old_theme = self.theme
|
||||
old_layout_cfg = self.card_layout_cfg
|
||||
self.theme = theme
|
||||
if self.vertical_layout:
|
||||
config_name = "GAME_CARD_VERTICAL"
|
||||
elif self.list_layout:
|
||||
config_name = "GAME_CARD_LIST"
|
||||
elif self.horizontal_layout:
|
||||
config_name = "GAME_CARD_HORIZONTAL"
|
||||
else:
|
||||
config_name = "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = 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)
|
||||
self.layout_.setSpacing(spacing)
|
||||
margin = self.base_extra_margin // 2
|
||||
self.layout_.setContentsMargins(margin, margin, margin, margin)
|
||||
self.shadow.setBlurRadius(theme.shadow_blur_radius)
|
||||
self.shadow.setColor(QColor(theme.color_shadow_card))
|
||||
self.shadow.setOffset(*theme.shadow_offset)
|
||||
if hasattr(self, "coverOpacity"):
|
||||
self.coverOpacity.setOpacity(theme.missing_exe_cover_opacity)
|
||||
if is_valid_protondb_tier(self.protondb_tier):
|
||||
self.protondbLabel.setStyleSheet(
|
||||
theme.get_protondb_badge_style(self.protondb_tier)
|
||||
)
|
||||
if self.ppdb_id:
|
||||
ppdb_style = (
|
||||
theme.get_ppdb_badge_style(self.ppdb_rating)
|
||||
if self.ppdb_rating
|
||||
else theme.STEAM_BADGE_STYLE
|
||||
)
|
||||
self.ppdbLabel.setStyleSheet(ppdb_style)
|
||||
if self.getAntiCheatText(self.anticheat_status):
|
||||
self.anticheatLabel.setStyleSheet(
|
||||
theme.get_anticheat_badge_style(self.anticheat_status)
|
||||
)
|
||||
corner_config = theme.get_source_corner_config()
|
||||
for label, icon_name in (
|
||||
(self.steamLabel, "badge_steam"),
|
||||
(self.gogLabel, "badge_gog"),
|
||||
(self.egsLabel, "badge_egs"),
|
||||
(self.nativeLabel, "badge_linux"),
|
||||
(self.portprotonLabel, "badge_portproton"),
|
||||
):
|
||||
icon = self.theme_manager.get_icon(
|
||||
icon_name, self.current_theme_name, as_path=True
|
||||
)
|
||||
label.refresh_source_theme(corner_config, icon)
|
||||
if (
|
||||
old_theme.GAME_CARD_ANIMATION != theme.GAME_CARD_ANIMATION
|
||||
or old_layout_cfg != self.card_layout_cfg
|
||||
):
|
||||
self.animations.refresh_theme(theme)
|
||||
else:
|
||||
self.animations.theme = theme
|
||||
self.update_favorite_icon()
|
||||
layout_changed = (
|
||||
old_layout_cfg != self.card_layout_cfg
|
||||
or old_theme.COMPACT_CARD != theme.COMPACT_CARD
|
||||
or old_theme.favoriteLabelSize != theme.favoriteLabelSize
|
||||
or old_theme.favoriteLabelIconSize != theme.favoriteLabelIconSize
|
||||
)
|
||||
if layout_changed:
|
||||
self.update_scale()
|
||||
else:
|
||||
self.update()
|
||||
|
||||
def _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
|
||||
@@ -448,7 +744,8 @@ class GameCard(QFrame):
|
||||
height = cover_size
|
||||
else:
|
||||
width = self.base_card_width
|
||||
height = int(self.base_card_width * 1.5)
|
||||
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
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback()
|
||||
@@ -456,7 +753,7 @@ class GameCard(QFrame):
|
||||
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,
|
||||
@@ -593,11 +890,13 @@ class GameCard(QFrame):
|
||||
source_ribbons = [
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.egs_visible, self.egsLabel),
|
||||
(self.native_visible, self.nativeLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
if not is_visible or ribbon is None or hidden_badges:
|
||||
if not is_visible or ribbon is None:
|
||||
if ribbon is not None:
|
||||
ribbon.setVisible(False)
|
||||
continue
|
||||
@@ -615,6 +914,8 @@ class GameCard(QFrame):
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.nativeLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -637,7 +938,15 @@ class GameCard(QFrame):
|
||||
int(self.card_layout_cfg.get("cover_size", 56) * self._scale),
|
||||
)
|
||||
margin_left = self.card_layout_cfg.get("cover_left_margin", 10)
|
||||
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
|
||||
if self.vertical_layout:
|
||||
self.setMinimumWidth(0)
|
||||
self.setFixedHeight(row_height + scaled_extra)
|
||||
self.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding,
|
||||
QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
else:
|
||||
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
|
||||
self.coverWidget.setFixedSize(icon_size + margin_left, icon_size)
|
||||
self.coverWidget.setContentsMargins(margin_left, 0, 0, 0)
|
||||
self.coverLabel.setFixedSize(icon_size, icon_size)
|
||||
@@ -645,11 +954,11 @@ class GameCard(QFrame):
|
||||
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)
|
||||
@@ -725,23 +1034,17 @@ class GameCard(QFrame):
|
||||
# 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):
|
||||
@@ -751,19 +1054,22 @@ class GameCard(QFrame):
|
||||
|
||||
self.display_filter = display_filter
|
||||
self.economy_mode = ui_config.get_economy_mode()
|
||||
self.steam_visible = (str(self.game_source).lower() == "steam" and not self.economy_mode)
|
||||
self.gog_visible = (str(self.game_source).lower() == "gog" and not self.economy_mode)
|
||||
self.portproton_visible = (str(self.game_source).lower() == "portproton" and not self.economy_mode)
|
||||
self.steam_visible = str(self.game_source).lower() == "steam"
|
||||
self.gog_visible = str(self.game_source).lower() == "gog"
|
||||
self.egs_visible = str(self.game_source).lower() == "egs"
|
||||
self.native_visible = str(self.game_source).lower() == "native"
|
||||
self.portproton_visible = str(self.game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible and not hidden_badges)
|
||||
self.gogLabel.setVisible(self.gog_visible and not hidden_badges)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible and not hidden_badges)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.nativeLabel.setVisible(self.native_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
|
||||
@@ -925,6 +1231,9 @@ class GameCard(QFrame):
|
||||
# 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:
|
||||
@@ -953,76 +1262,6 @@ class GameCard(QFrame):
|
||||
favorites_config.set_games(favorites)
|
||||
self.update_favorite_icon()
|
||||
|
||||
def getBorderWidth(self) -> int:
|
||||
return self._borderWidth
|
||||
|
||||
def setBorderWidth(self, value: int):
|
||||
if self._borderWidth != value:
|
||||
self._borderWidth = value
|
||||
self.borderWidthChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getGradientAngle(self) -> float:
|
||||
return self._gradientAngle
|
||||
|
||||
def setGradientAngle(self, value: float):
|
||||
if self._gradientAngle != value:
|
||||
self._gradientAngle = value
|
||||
self.gradientAngleChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getScale(self) -> float:
|
||||
return self._scale
|
||||
|
||||
def setScale(self, value: float):
|
||||
if self._scale != value:
|
||||
self._scale = value
|
||||
self.update_scale()
|
||||
self.scaleChanged.emit()
|
||||
|
||||
borderWidth = Property(int, fget=getBorderWidth, fset=setBorderWidth, notify=borderWidthChanged)
|
||||
gradientAngle = Property(float, fget=getGradientAngle, fset=setGradientAngle, notify=gradientAngleChanged)
|
||||
scale = Property(float, fget=getScale, fset=setScale, notify=scaleChanged)
|
||||
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hover_sound_pending = True
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hover_sound_pending = False
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._hover_sound_pending:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self._hover_sound_pending = False
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
if (
|
||||
QApplication.activeWindow() is not None
|
||||
and event.reason() not in (
|
||||
Qt.FocusReason.ActiveWindowFocusReason,
|
||||
Qt.FocusReason.MouseFocusReason,
|
||||
)
|
||||
):
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event):
|
||||
self.animations.handle_focus_out_event()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def hideEvent(self, event: QHideEvent) -> None:
|
||||
self.set_animated_cover_paused(True)
|
||||
super().hideEvent(event)
|
||||
@@ -1031,30 +1270,52 @@ class GameCard(QFrame):
|
||||
self.set_animated_cover_paused(False)
|
||||
super().showEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
def click(self) -> None:
|
||||
if self.context_menu_manager:
|
||||
if self.context_menu_manager.card_selection_mode is True:
|
||||
self.batch_selected = not self.batch_selected
|
||||
self.update()
|
||||
return
|
||||
self.context_menu_manager.clear_card_selection()
|
||||
if self._get_missing_executable_path() and self.context_menu_manager:
|
||||
self.context_menu_manager.handle_missing_executable(self)
|
||||
return
|
||||
game_data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"cover_path": self.cover_path,
|
||||
"appid": self.appid,
|
||||
"controller_support": self.controller_support,
|
||||
"exec_line": self.exec_line,
|
||||
"last_launch": self.last_launch,
|
||||
"formatted_playtime": self.formatted_playtime,
|
||||
"playtime_seconds": self.playtime_seconds,
|
||||
"protondb_tier": self.protondb_tier,
|
||||
"game_source": self.game_source,
|
||||
"anticheat_status": self.anticheat_status,
|
||||
"anticheat_slug": self.anticheat_slug,
|
||||
"ppdb_id": self.ppdb_id,
|
||||
"ppdb_rating": self.ppdb_rating,
|
||||
"protondb_appid": self.protondb_appid,
|
||||
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
|
||||
}
|
||||
self.select_callback(game_data)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
game_data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"cover_path": self.cover_path,
|
||||
"appid": self.appid,
|
||||
"controller_support": self.controller_support,
|
||||
"exec_line": self.exec_line,
|
||||
"last_launch": self.last_launch,
|
||||
"formatted_playtime": self.formatted_playtime,
|
||||
"playtime_seconds": self.playtime_seconds,
|
||||
"protondb_tier": self.protondb_tier,
|
||||
"game_source": self.game_source,
|
||||
"anticheat_status": self.anticheat_status,
|
||||
"anticheat_slug": self.anticheat_slug,
|
||||
"ppdb_id": self.ppdb_id,
|
||||
"ppdb_rating": self.ppdb_rating,
|
||||
"protondb_appid": self.protondb_appid,
|
||||
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
|
||||
}
|
||||
self.select_callback(game_data)
|
||||
self.click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
if event.key() == Qt.Key.Key_Space and self.context_menu_manager:
|
||||
if self.context_menu_manager.card_selection_mode:
|
||||
self.context_menu_manager.clear_card_selection()
|
||||
else:
|
||||
self.context_menu_manager.start_card_selection(self)
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up animations to prevent memory leaks when the card is destroyed."""
|
||||
if hasattr(self, 'coverLabel') and self.coverLabel is not None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Protocol
|
||||
from portprotonqt.game_card import GameCard
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Protocol
|
||||
from portprotonqt.game_card import AnimatedCard, GameCard
|
||||
from portprotonqt.search_utils import (
|
||||
SearchOptimizer,
|
||||
ThreadedSearch,
|
||||
@@ -7,15 +8,42 @@ from portprotonqt.search_utils import (
|
||||
search_index,
|
||||
)
|
||||
from PySide6.QtWidgets import QAbstractButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QRegion
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
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
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.detail_pages.utils import remove_cover_background, setup_cover_background
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
|
||||
from collections import deque
|
||||
|
||||
|
||||
class FullLibraryTile(AnimatedCard):
|
||||
def __init__(self, theme: Any):
|
||||
super().__init__()
|
||||
self.name = ""
|
||||
self.animation_base_size = theme.fullLibraryTileSize
|
||||
self.tile_pixmap = QPixmap()
|
||||
self.setup_card_animations(
|
||||
theme, GameCard._get_layout_config(theme, "GAME_CARD_HORIZONTAL")
|
||||
)
|
||||
self.update_scale()
|
||||
|
||||
def set_tile_pixmap(self, pixmap: QPixmap) -> None:
|
||||
self.tile_pixmap = pixmap
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
QFrame.paintEvent(self, event)
|
||||
painter = QPainter(self)
|
||||
painter.drawPixmap(self.rect(), self.tile_pixmap)
|
||||
painter.end()
|
||||
border_painter = QPainter(self)
|
||||
self.animations.paint_border(border_painter)
|
||||
border_painter.end()
|
||||
|
||||
class MainWindowProtocol(Protocol):
|
||||
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
|
||||
|
||||
@@ -46,17 +74,31 @@ 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 | None = None
|
||||
self.gamesListLayout: FlowLayout | QHBoxLayout | QVBoxLayout | None = None
|
||||
self.gamesScrollArea: QScrollArea | None = None
|
||||
self.libraryHeaderWidget: QWidget | None = None
|
||||
self.libraryContentLayout: QVBoxLayout | None = None
|
||||
self.libraryBackgroundLabel: QLabel | None = None
|
||||
self.fullLibraryTile: FullLibraryTile | None = None
|
||||
self._full_library_tile_covers: list[str] = []
|
||||
self._full_library_tile_pixmaps: dict[int, QPixmap] = {}
|
||||
self.full_library_open = False
|
||||
self.sizeSlider: QSlider | None = None
|
||||
self._update_timer: QTimer | None = None
|
||||
self._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()
|
||||
@@ -69,8 +111,21 @@ class GameLibraryManager:
|
||||
def create_games_library_widget(self):
|
||||
"""Creates the games library widget with search, grid, and slider."""
|
||||
self.gamesLibraryWidget = QWidget()
|
||||
self.gamesLibraryWidget.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
|
||||
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(self.gamesLibraryWidget)
|
||||
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
|
||||
stack_layout = QGridLayout(self.gamesLibraryWidget)
|
||||
if isinstance(library_background, dict):
|
||||
stack_layout.setContentsMargins(*library_background["margins"])
|
||||
else:
|
||||
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)
|
||||
|
||||
# Search widget
|
||||
@@ -79,16 +134,41 @@ class GameLibraryManager:
|
||||
|
||||
# Scroll area for game grid
|
||||
scrollArea = AutoHideScrollArea(theme=self.theme)
|
||||
scrollArea.setProperty("theme_style_name", "TRANSPARENT_BACKGROUND_STYLE")
|
||||
self.gamesScrollArea = scrollArea
|
||||
scrollArea.setWidgetResizable(True)
|
||||
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
|
||||
|
||||
self.gamesListWidget = QWidget()
|
||||
self.gamesListWidget.setProperty("library_layout_mode", self.layout_mode)
|
||||
self.gamesListWidget.setProperty("theme_style_name", "LIST_WIDGET_STYLE")
|
||||
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
|
||||
self.gamesListLayout = FlowLayout(self.gamesListWidget)
|
||||
if self.layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
|
||||
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
|
||||
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))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
else:
|
||||
self.gamesListLayout = FlowLayout(self.gamesListWidget)
|
||||
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
|
||||
@@ -106,7 +186,7 @@ class GameLibraryManager:
|
||||
self.main_window._register_gamepad_tooltip(self.sizeSlider, f"{self.card_width} px")
|
||||
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
|
||||
sliderLayout.addWidget(self.sizeSlider)
|
||||
if self.layout_mode == "list":
|
||||
if self.layout_mode in {"list", "vertical", "horizontal", "horizontal_top"}:
|
||||
self.sizeSlider.setVisible(False)
|
||||
self._set_card_width_from_slider()
|
||||
|
||||
@@ -117,36 +197,168 @@ 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
|
||||
|
||||
def _create_library_header(self) -> QWidget:
|
||||
"""Create the shared header for vertical library columns."""
|
||||
config = self.theme.GAME_CARD_VERTICAL
|
||||
header = QWidget()
|
||||
header.setFixedHeight(config["header_height"])
|
||||
header.setProperty("theme_style_name", "LIBRARY_HEADER_STYLE")
|
||||
header.setStyleSheet(self.theme.LIBRARY_HEADER_STYLE)
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(*config["header_margins"])
|
||||
header_layout.setSpacing(config["header_spacing"])
|
||||
header_layout.addSpacing(config["header_cover_width"] + 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.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:
|
||||
"""Replace the card layout when a theme changes its library mode."""
|
||||
if self.gamesListWidget is None or self.gamesListLayout is None:
|
||||
return
|
||||
self.clear_layout(self.gamesListLayout)
|
||||
self.fullLibraryTile = None
|
||||
old_layout = self.gamesListLayout
|
||||
if layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
|
||||
self.gamesListLayout = QHBoxLayout()
|
||||
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))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
else:
|
||||
self.gamesListLayout = FlowLayout()
|
||||
QWidget().setLayout(old_layout)
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
if self.gamesScrollArea is not None:
|
||||
horizontal = layout_mode in {"horizontal", "horizontal_top"}
|
||||
vertical_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if horizontal
|
||||
else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
if layout_mode == "vertical"
|
||||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
|
||||
self.gamesScrollArea.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
theme_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
self.full_library_open = theme_mode == "horizontal_top" and layout_mode == "grid"
|
||||
if self.full_library_open and self.libraryBackgroundLabel is not None:
|
||||
remove_cover_background(self.libraryBackgroundLabel)
|
||||
self.layout_mode = layout_mode
|
||||
if (
|
||||
layout_mode == "vertical"
|
||||
and self.libraryHeaderWidget is None
|
||||
and self.libraryContentLayout is not None
|
||||
):
|
||||
self.libraryHeaderWidget = self._create_library_header()
|
||||
self.libraryContentLayout.insertWidget(1, self.libraryHeaderWidget)
|
||||
if self.libraryHeaderWidget is not None:
|
||||
self.libraryHeaderWidget.setVisible(layout_mode == "vertical")
|
||||
self.gamesListWidget.setProperty("library_layout_mode", layout_mode)
|
||||
if self.sizeSlider is not None:
|
||||
self.sizeSlider.setVisible(
|
||||
not self.full_library_open
|
||||
and layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
|
||||
)
|
||||
self._set_card_width_from_slider()
|
||||
self.main_window.card_width = self.card_width
|
||||
self.set_games(self.games, focus_first_card=False)
|
||||
|
||||
def open_full_library(self) -> None:
|
||||
"""Open the complete grid from the horizontal top mode."""
|
||||
if self.layout_mode == "horizontal_top":
|
||||
self.rebuild_library_layout("grid")
|
||||
|
||||
def close_full_library(self) -> bool:
|
||||
"""Return from the complete grid to the horizontal top mode."""
|
||||
if not self.full_library_open:
|
||||
return False
|
||||
self.full_library_open = False
|
||||
self.rebuild_library_layout("horizontal_top")
|
||||
return True
|
||||
|
||||
def on_slider_released(self):
|
||||
"""Handles slider release to update card size."""
|
||||
if self.layout_mode == "list":
|
||||
if self.full_library_open or self.layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
return
|
||||
if self.sizeSlider is None:
|
||||
return
|
||||
self._set_card_width_from_slider()
|
||||
if hasattr(self.main_window, "_gamepad_tooltip_map"):
|
||||
self.main_window._gamepad_tooltip_map[self.sizeSlider] = f"{self.card_width} px"
|
||||
if self.layout_mode != "list":
|
||||
ui_config.set_card_width(self.card_width)
|
||||
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 list layout."""
|
||||
"""Use max card width for fixed-size layouts."""
|
||||
if self.sizeSlider is None:
|
||||
return
|
||||
if self.layout_mode == "list":
|
||||
if self.full_library_open or self.layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
self.card_width = self.sizeSlider.maximum()
|
||||
else:
|
||||
self.card_width = self.sizeSlider.value()
|
||||
@@ -156,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
|
||||
@@ -178,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()
|
||||
|
||||
@@ -199,7 +414,7 @@ class GameLibraryManager:
|
||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||
try:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
self.main_window.current_hovered_card.animations.handle_leave_event()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = None
|
||||
@@ -210,10 +425,36 @@ class GameLibraryManager:
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_focused_card = card
|
||||
self._update_library_background(card)
|
||||
else:
|
||||
if self.main_window.current_focused_card == card:
|
||||
self.main_window.current_focused_card = None
|
||||
|
||||
def _update_library_background(self, card: GameCard) -> None:
|
||||
"""Render the configured library background from the active cover."""
|
||||
if self.full_library_open or self.libraryBackgroundLabel is None:
|
||||
return
|
||||
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 = 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(
|
||||
DETAIL_PAGE_BG_MODE=library_config.get("mode", "gradient"),
|
||||
DETAIL_PAGE_BACKGROUNDS=backgrounds,
|
||||
)
|
||||
setup_cover_background(
|
||||
label, pixmap, self.main_window, background_theme
|
||||
)
|
||||
|
||||
def _collapse_library_filter_controls(self) -> None:
|
||||
if getattr(self.main_window, "_library_controls_hover_close_delayed", False):
|
||||
return
|
||||
@@ -251,10 +492,11 @@ class GameLibraryManager:
|
||||
try:
|
||||
if self.main_window.current_hovered_card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
self.main_window.current_hovered_card.animations.handle_leave_event()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = card
|
||||
self._update_library_background(card)
|
||||
else:
|
||||
if self.main_window.current_hovered_card == card:
|
||||
self.main_window.current_hovered_card = None
|
||||
@@ -275,9 +517,16 @@ 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
|
||||
self.layout_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:
|
||||
self.sizeSlider.setVisible(self.layout_mode != "list")
|
||||
self.sizeSlider.setVisible(
|
||||
not self.full_library_open
|
||||
and self.layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
|
||||
)
|
||||
old_card_width = self.card_width
|
||||
self._set_card_width_from_slider()
|
||||
self.main_window.card_width = self.card_width
|
||||
@@ -292,6 +541,9 @@ class GameLibraryManager:
|
||||
# When filtering, we want to update with the current filtered_games
|
||||
# which has already been set by _perform_search
|
||||
pass
|
||||
if is_filter and self.layout_mode == "horizontal_top":
|
||||
self.dirty = True
|
||||
is_filter = False
|
||||
self.is_filtering = is_filter
|
||||
self._pending_update = True
|
||||
|
||||
@@ -308,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,
|
||||
@@ -330,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()
|
||||
@@ -372,23 +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."""
|
||||
@@ -404,12 +688,13 @@ class GameLibraryManager:
|
||||
self._update_search_results(search_text)
|
||||
else:
|
||||
# Full update: sorting, removal/addition, reorganization
|
||||
games_list = self.filtered_games if self.filtered_games else self.games
|
||||
games_list = self.filtered_games
|
||||
if self.layout_mode != "horizontal_top" and not games_list:
|
||||
games_list = self.games
|
||||
favorites = favorites_config.get_games()
|
||||
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
|
||||
|
||||
@@ -433,15 +718,31 @@ class GameLibraryManager:
|
||||
sorted_fav = sorted(fav_games, key=partition_sort_key)
|
||||
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
|
||||
sorted_games = sorted_fav + sorted_non_fav
|
||||
if self.layout_mode == "horizontal_top":
|
||||
limit = self.theme.horizontalTopGameLimit
|
||||
tile_games = sorted_games[limit:limit + self.theme.fullLibraryTileGameCount]
|
||||
self._full_library_tile_covers = [str(game[2] or "") for game in tile_games]
|
||||
sorted_games = sorted_games[:limit]
|
||||
else:
|
||||
self._full_library_tile_covers = []
|
||||
|
||||
# Build set of current game keys for faster lookup
|
||||
current_game_keys = {(game[0], game[5]) for game in sorted_games}
|
||||
|
||||
# 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)
|
||||
@@ -454,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
|
||||
|
||||
@@ -518,22 +819,81 @@ 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()
|
||||
self.force_update_cards_library()
|
||||
|
||||
self.is_filtering = False # Reset flag in any case
|
||||
if not self._incremental_add_queue:
|
||||
self._sync_full_library_tile()
|
||||
self._schedule_focus_first_card()
|
||||
|
||||
def _sync_full_library_tile(self) -> None:
|
||||
if self.fullLibraryTile is not None:
|
||||
self.fullLibraryTile.setParent(None)
|
||||
self.fullLibraryTile.deleteLater()
|
||||
self.fullLibraryTile = None
|
||||
if self.layout_mode != "horizontal_top" or self.gamesListLayout is None:
|
||||
return
|
||||
if len(self.filtered_games) <= self.theme.horizontalTopGameLimit:
|
||||
return
|
||||
tile = FullLibraryTile(self.theme)
|
||||
tile.setProperty("full_library_tile", True)
|
||||
tile.setStyleSheet(self.theme.FULL_LIBRARY_TILE_STYLE)
|
||||
tile.clicked.connect(self.open_full_library)
|
||||
self.gamesListLayout.addWidget(tile)
|
||||
self.fullLibraryTile = tile
|
||||
self._full_library_tile_pixmaps = {}
|
||||
cover_width, cover_height = self.theme.fullLibraryTileSize
|
||||
for index, cover_path in enumerate(self._full_library_tile_covers):
|
||||
load_pixmap_async(
|
||||
cover_path,
|
||||
cover_width,
|
||||
cover_height,
|
||||
lambda pixmap, item=index: self._set_full_library_tile_cover(
|
||||
tile, item, pixmap
|
||||
),
|
||||
)
|
||||
|
||||
def _set_full_library_tile_cover(
|
||||
self, tile: FullLibraryTile, index: int, pixmap: QPixmap
|
||||
) -> None:
|
||||
if tile is not self.fullLibraryTile:
|
||||
return
|
||||
self._full_library_tile_pixmaps[index] = pixmap
|
||||
width, height = self.theme.fullLibraryTileSize
|
||||
cell_width = width // self.theme.fullLibraryTileColumns
|
||||
cell_height = height // self.theme.fullLibraryTileRows
|
||||
canvas = QPixmap(width, height)
|
||||
canvas.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(canvas)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
clip = QPainterPath()
|
||||
clip.addRoundedRect(
|
||||
QRectF(canvas.rect()),
|
||||
self.theme.fullLibraryTileRadius,
|
||||
self.theme.fullLibraryTileRadius,
|
||||
)
|
||||
painter.setClipPath(clip)
|
||||
for item, cover in self._full_library_tile_pixmaps.items():
|
||||
cell = QRect(
|
||||
item % self.theme.fullLibraryTileColumns * cell_width,
|
||||
item // self.theme.fullLibraryTileColumns * cell_height,
|
||||
cell_width,
|
||||
cell_height,
|
||||
)
|
||||
painter.drawPixmap(cell, cover)
|
||||
painter.end()
|
||||
tile.set_tile_pixmap(canvas)
|
||||
|
||||
def _schedule_focus_first_card(self) -> None:
|
||||
if not self._focus_first_card_after_update:
|
||||
return
|
||||
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
|
||||
@@ -550,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."""
|
||||
@@ -633,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)
|
||||
@@ -678,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)
|
||||
@@ -721,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()
|
||||
@@ -748,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)
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SDL_INIT_GAMEPAD = 0x00002000
|
||||
SDL_GAMEPAD_TYPE_STANDARD = 1
|
||||
SDL_GAMEPAD_TYPE_XBOX360 = 2
|
||||
SDL_GAMEPAD_TYPE_XBOXONE = 3
|
||||
SDL_GAMEPAD_TYPE_PS3 = 4
|
||||
SDL_GAMEPAD_TYPE_PS4 = 5
|
||||
SDL_GAMEPAD_TYPE_PS5 = 6
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
|
||||
SDL_GAMEPAD_TYPE_GAMECUBE = 11
|
||||
SDL_GAMEPAD_TYPE_STEAM = 12
|
||||
SDL3_XBOX_LIKE_TYPES = {
|
||||
SDL_GAMEPAD_TYPE_STANDARD,
|
||||
SDL_GAMEPAD_TYPE_XBOX360,
|
||||
SDL_GAMEPAD_TYPE_XBOXONE,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
|
||||
SDL_GAMEPAD_TYPE_GAMECUBE,
|
||||
SDL_GAMEPAD_TYPE_STEAM,
|
||||
}
|
||||
SDL3_PLAYSTATION_TYPES = {
|
||||
SDL_GAMEPAD_TYPE_PS3,
|
||||
SDL_GAMEPAD_TYPE_PS4,
|
||||
SDL_GAMEPAD_TYPE_PS5,
|
||||
}
|
||||
|
||||
SDL_GAMEPAD_BUTTON_SOUTH = 0
|
||||
SDL_GAMEPAD_BUTTON_EAST = 1
|
||||
SDL_GAMEPAD_BUTTON_WEST = 2
|
||||
SDL_GAMEPAD_BUTTON_NORTH = 3
|
||||
SDL_GAMEPAD_BUTTON_BACK = 4
|
||||
SDL_GAMEPAD_BUTTON_GUIDE = 5
|
||||
SDL_GAMEPAD_BUTTON_START = 6
|
||||
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
|
||||
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
|
||||
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
|
||||
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
|
||||
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
|
||||
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
|
||||
SDL_GAMEPAD_AXIS_LEFTX = 0
|
||||
SDL_GAMEPAD_AXIS_LEFTY = 1
|
||||
SDL_GAMEPAD_AXIS_RIGHTX = 2
|
||||
SDL_GAMEPAD_AXIS_RIGHTY = 3
|
||||
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
|
||||
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
|
||||
|
||||
|
||||
class GamepadType(Enum):
|
||||
XBOX = "Xbox"
|
||||
PLAYSTATION = "PlayStation"
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
|
||||
def _get_sdl3_error(sdl: ctypes.CDLL) -> str:
|
||||
error = sdl.SDL_GetError()
|
||||
if not error:
|
||||
return ""
|
||||
return error.decode(errors="replace")
|
||||
|
||||
|
||||
def _configure_sdl3_gamepad_api(sdl: ctypes.CDLL) -> None:
|
||||
sdl.SDL_InitSubSystem.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_InitSubSystem.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepads.argtypes = [ctypes.POINTER(ctypes.c_int)]
|
||||
sdl.SDL_GetGamepads.restype = ctypes.POINTER(ctypes.c_uint32)
|
||||
sdl.SDL_GetGamepadTypeForID.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_GetGamepadTypeForID.restype = ctypes.c_int
|
||||
sdl.SDL_GetGamepadNameForID.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_GetGamepadNameForID.restype = ctypes.c_char_p
|
||||
sdl.SDL_OpenGamepad.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_OpenGamepad.restype = ctypes.c_void_p
|
||||
sdl.SDL_CloseGamepad.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_CloseGamepad.restype = None
|
||||
sdl.SDL_GamepadConnected.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_GamepadConnected.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepadName.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_GetGamepadName.restype = ctypes.c_char_p
|
||||
sdl.SDL_GetGamepadButton.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
sdl.SDL_GetGamepadButton.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepadAxis.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
sdl.SDL_GetGamepadAxis.restype = ctypes.c_int16
|
||||
sdl.SDL_UpdateGamepads.argtypes = []
|
||||
sdl.SDL_UpdateGamepads.restype = None
|
||||
sdl.SDL_PumpEvents.argtypes = []
|
||||
sdl.SDL_PumpEvents.restype = None
|
||||
sdl.SDL_QuitSubSystem.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_QuitSubSystem.restype = None
|
||||
sdl.SDL_GetError.argtypes = []
|
||||
sdl.SDL_GetError.restype = ctypes.c_char_p
|
||||
sdl.SDL_free.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_free.restype = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_sdl3() -> ctypes.CDLL | None:
|
||||
library_names = (
|
||||
ctypes.util.find_library("SDL3"),
|
||||
"libSDL3.so.0",
|
||||
"libSDL3.so",
|
||||
)
|
||||
for library_name in library_names:
|
||||
if not library_name:
|
||||
continue
|
||||
try:
|
||||
sdl = ctypes.CDLL(library_name)
|
||||
_configure_sdl3_gamepad_api(sdl)
|
||||
except (AttributeError, OSError) as e:
|
||||
logger.debug("Failed to load SDL3 from %s: %s", library_name, e)
|
||||
continue
|
||||
if sdl.SDL_InitSubSystem(SDL_INIT_GAMEPAD):
|
||||
return sdl
|
||||
logger.debug("Failed to initialize SDL3 gamepad subsystem: %s", _get_sdl3_error(sdl))
|
||||
return None
|
||||
|
||||
|
||||
def _decode_sdl3_name(name: bytes | None) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
return name.decode(errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SDLGamepad:
|
||||
sdl: ctypes.CDLL
|
||||
controller: ctypes.c_void_p
|
||||
name: str
|
||||
path: str
|
||||
instance_id: int
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self.sdl.SDL_CloseGamepad(self.controller)
|
||||
except (AttributeError, OSError) as e:
|
||||
logger.debug("Failed to close SDL gamepad: %s", e)
|
||||
|
||||
def connected(self) -> bool:
|
||||
return bool(self.sdl.SDL_GamepadConnected(self.controller))
|
||||
|
||||
def update(self) -> None:
|
||||
self.sdl.SDL_UpdateGamepads()
|
||||
|
||||
def get_button(self, button: int) -> int:
|
||||
return int(self.sdl.SDL_GetGamepadButton(self.controller, button))
|
||||
|
||||
def get_axis(self, axis: int) -> int:
|
||||
return int(self.sdl.SDL_GetGamepadAxis(self.controller, axis))
|
||||
|
||||
|
||||
def find_gamepad() -> SDLGamepad | None:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is None:
|
||||
return None
|
||||
sdl.SDL_PumpEvents()
|
||||
sdl.SDL_UpdateGamepads()
|
||||
count = ctypes.c_int()
|
||||
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
|
||||
if not gamepads:
|
||||
return None
|
||||
try:
|
||||
return _open_first_sdl3_gamepad(sdl, gamepads, count.value)
|
||||
finally:
|
||||
sdl.SDL_free(gamepads)
|
||||
|
||||
|
||||
def _open_first_sdl3_gamepad(
|
||||
sdl: ctypes.CDLL,
|
||||
gamepads: Any,
|
||||
count: int,
|
||||
) -> SDLGamepad | None:
|
||||
for index in range(count):
|
||||
instance_id = int(gamepads[index])
|
||||
gamepad = sdl.SDL_OpenGamepad(instance_id)
|
||||
if not gamepad:
|
||||
logger.debug("Skipping SDL3 gamepad %s: %s", instance_id, _get_sdl3_error(sdl))
|
||||
continue
|
||||
return SDLGamepad(
|
||||
sdl=sdl,
|
||||
controller=gamepad,
|
||||
name=_decode_sdl3_name(sdl.SDL_GetGamepadName(gamepad)),
|
||||
path=f"sdl3-gamepad:{instance_id}",
|
||||
instance_id=instance_id,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def detect_gamepad_type(gamepad: SDLGamepad) -> GamepadType:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is None:
|
||||
return GamepadType.XBOX
|
||||
count = ctypes.c_int()
|
||||
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
|
||||
if not gamepads:
|
||||
return GamepadType.XBOX
|
||||
try:
|
||||
gamepad_type = _find_sdl3_gamepad_type(sdl, gamepads, count.value, gamepad.name)
|
||||
finally:
|
||||
sdl.SDL_free(gamepads)
|
||||
return gamepad_type or GamepadType.XBOX
|
||||
|
||||
|
||||
def _find_sdl3_gamepad_type(
|
||||
sdl: ctypes.CDLL,
|
||||
gamepads: Any,
|
||||
count: int,
|
||||
target_name: str,
|
||||
) -> GamepadType | None:
|
||||
for index in range(count):
|
||||
gamepad_id = gamepads[index]
|
||||
sdl_name = _decode_sdl3_name(sdl.SDL_GetGamepadNameForID(gamepad_id))
|
||||
if count > 1 and sdl_name.casefold() != target_name.casefold():
|
||||
continue
|
||||
gamepad_type = _gamepad_type_from_sdl3_value(sdl.SDL_GetGamepadTypeForID(gamepad_id))
|
||||
if gamepad_type is not None:
|
||||
return gamepad_type
|
||||
return None
|
||||
|
||||
|
||||
def _gamepad_type_from_sdl3_value(sdl_type: int) -> GamepadType | None:
|
||||
if sdl_type in SDL3_PLAYSTATION_TYPES:
|
||||
return GamepadType.PLAYSTATION
|
||||
if sdl_type in SDL3_XBOX_LIKE_TYPES:
|
||||
return GamepadType.XBOX
|
||||
return None
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is not None:
|
||||
sdl.SDL_QuitSubSystem(SDL_INIT_GAMEPAD)
|
||||
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user