Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7acdd2ef4
|
||
|
|
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 | ||
|
|
d202b03025
|
||
|
|
4e9f1ee906
|
||
|
|
2590583e98
|
||
|
|
a62c2b4ee6
|
||
|
|
c3adf4c6de
|
||
|
|
eff8436aa0
|
||
|
|
7707d348c2
|
||
|
|
6d624a59c9
|
||
|
|
87f06913f2
|
||
|
|
b193d36d42 | ||
|
|
5e12b29c5f
|
||
|
|
cdfedd1503
|
||
|
|
11dbd249ab
|
||
|
|
000220dd47
|
||
|
|
8356659108
|
||
|
|
2afa185084 | ||
|
|
a41da6b35b
|
||
|
|
bb84524b06
|
||
|
|
2cf5369892
|
||
|
|
67026aa040
|
||
|
|
0e3e300eb5
|
||
|
|
b9991f89b3
|
||
|
|
d9a6e1cca4
|
||
|
|
c4a216422e
|
||
|
|
91f92a215c
|
||
|
|
13dd00c8d5
|
||
|
|
ef7b0bce16
|
||
|
|
72546d2bd5
|
||
|
|
0e54c0451a
|
||
|
|
2d26067f37
|
||
|
|
1c9cfea1eb
|
||
|
|
7cc0873ee2
|
||
|
|
bb8fc83259
|
||
|
|
3f2c368946
|
||
|
|
0ba671ed1a
|
||
|
|
d96c78d3cb
|
||
|
|
3ad8193701
|
||
|
|
4fd947cab5
|
||
|
|
a09ee2e804
|
||
|
|
4a37d901a2
|
||
|
|
5cfea4d8fd
|
||
|
|
7ca9cb4cf5
|
||
|
|
e5021edbed
|
||
|
|
96f471336c
|
||
|
|
a1441669c1
|
||
|
|
de794d1aeb
|
||
|
|
d27ffbf407 | ||
|
|
d2448b09b7 | ||
|
|
549123657d | ||
|
|
2f89479802 | ||
|
|
6325eee8c2 | ||
|
|
83c9c9ba64
|
||
|
|
1542c5bd0c
|
||
|
|
681ba1ff89
|
||
|
|
db099bce1f
|
||
|
|
5271596481
|
||
|
|
c3d7cf4c62
|
||
|
|
7941258ba7
|
||
|
|
1a105fcf0b
|
||
|
|
95aeb4a62f
|
||
|
|
55a0a158c8
|
||
|
|
daa05187f5
|
||
|
|
92bb49286b
|
||
|
|
edba3c10bf | ||
|
|
fa133fff98 | ||
|
|
8e90e9cafc
|
||
|
|
6f8436eeb2
|
||
|
|
69833e7295 | ||
|
|
7533769b15 | ||
|
|
174fae6dab | ||
|
|
497acf85e4
|
||
|
|
3b57f87770 | ||
|
|
d8a9ee58bc
|
||
|
|
66adeb619d | ||
|
|
333cc09409
|
||
|
|
229f1ee78f
|
||
|
|
20b64de731
|
||
|
|
f0ab40a2da
|
||
|
|
2fb580eb09
|
||
|
|
b45d26ea8c
|
||
|
|
32e4b28aac
|
||
|
|
c853327cac
|
||
|
|
7d2c5e4662
|
||
|
|
a6564d350f
|
||
|
|
0909faf50c
|
||
|
|
e123bfa79f
|
||
|
|
b45a0683c9
|
||
|
|
e567a16437
|
||
|
|
7e38d9a70f
|
||
|
|
3c793345ba | ||
|
|
9e0946ae7e
|
||
|
|
2bc610940b
|
||
|
|
f9c790b568 | ||
|
|
035e560fd5 | ||
|
|
d185c7cfdc | ||
|
|
97d4ea26d5
|
||
|
|
a63c894cb9
|
||
|
|
032398a988
|
||
|
|
9a5265f335
|
||
|
|
589c8657a6
|
||
|
|
b1155726e0
|
||
|
|
4fd11e09fe
|
||
|
|
5dc66e0822 | ||
|
|
0cd98c0996
|
||
|
|
d813ac1fac
|
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
|
||||
+65
-11
@@ -2,16 +2,26 @@ name: Build AppImage, Arch, Fedora and Debian Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: "Run mode"
|
||||
required: true
|
||||
default: "hotfix"
|
||||
type: choice
|
||||
options:
|
||||
- hotfix
|
||||
- release
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]*.[0-9]*'
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 1.3.0
|
||||
VERSION: 1.5.0
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RUN_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'release' }}
|
||||
|
||||
jobs:
|
||||
build-appimage:
|
||||
@@ -21,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
|
||||
|
||||
@@ -33,7 +48,13 @@ jobs:
|
||||
run: |
|
||||
cd build-aux/AppImage
|
||||
chmod +x get-dependencies.sh portprotonqt-appimage.sh
|
||||
./get-dependencies.sh
|
||||
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
|
||||
./get-dependencies.sh --branch "${{ github.ref_name }}"
|
||||
elif [ "${{ github.event_name }}" = "push" ]; then
|
||||
./get-dependencies.sh --tag "${{ github.ref_name }}"
|
||||
else
|
||||
./get-dependencies.sh
|
||||
fi
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
@@ -53,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
|
||||
@@ -85,6 +111,9 @@ jobs:
|
||||
cd /__w/portproton-repo
|
||||
cp -r "$GITHUB_WORKSPACE" PortProtonQt
|
||||
cd /__w/portproton-repo/PortProtonQt/build-aux
|
||||
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
|
||||
sed -i "s|^source=.*|source=(\"git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git#branch=${{ github.ref_name }}\")|" PKGBUILD
|
||||
fi
|
||||
chown user -R ..
|
||||
su user -c "yes '' | makepkg --noconfirm -s"
|
||||
|
||||
@@ -100,7 +129,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [42, 43, 44, rawhide]
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -109,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: |
|
||||
@@ -126,6 +162,9 @@ jobs:
|
||||
- name: Copy fedora.spec
|
||||
run: |
|
||||
cp build-aux/fedora.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
|
||||
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
|
||||
sed -i "s|git checkout v%{pypi_version}|git checkout ${{ github.ref_name }}|" /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
|
||||
fi
|
||||
chown -R rpmbuild:users /home/rpmbuild
|
||||
|
||||
- name: Build RPM
|
||||
@@ -148,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
|
||||
@@ -170,6 +223,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Create and Publish Release
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.mode == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@v4
|
||||
|
||||
@@ -22,21 +22,25 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
|
||||
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
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Download stats
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
stats:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update download stats
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_ACTOR: ${{ gitea.actor }}
|
||||
GITEA_SERVER: "git.linux-gaming.ru"
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
run: bash dev-scripts/get_download_stats.sh
|
||||
@@ -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:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
|
||||
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:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
|
||||
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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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
|
||||
@@ -186,6 +211,9 @@ if key_value >= Qt.Key.Key_Escape.value: ...
|
||||
- Do not refactor unrelated code
|
||||
- Do not modify localization files (`portprotonqt/locales/`) unless explicitly requested
|
||||
- Do not extract single-use logic into a helper function without a clear need
|
||||
- **Before adding any helper, wrapper, class, or infrastructure, search for an existing method that already performs the operation and call it directly**
|
||||
- **NEVER add a task-local helper that only wraps an existing method or a single call; keep the call in the modified block unless extraction is required by the function limits**
|
||||
- If the user identifies overengineering, remove the unnecessary abstraction before continuing; do not layer another fix on top of it
|
||||
- Do not add comments for obvious code
|
||||
- **NEVER leave outdated comments after refactoring** (e.g., "without numpy" after numpy removal, "legacy" after rewrite)
|
||||
- **ALWAYS update or remove comments that reference removed dependencies, patterns, or context**
|
||||
@@ -247,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
|
||||
|
||||
@@ -260,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").
|
||||
|
||||
---
|
||||
|
||||
@@ -274,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
|
||||
|
||||
---
|
||||
|
||||
@@ -301,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
|
||||
@@ -381,23 +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
|
||||
@@ -422,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 |
|
||||
@@ -437,6 +491,8 @@ pre-commit run pytest
|
||||
| `classic/styles.py` | Missing styles after theme rewrite (NAV, COMBOBOX, TAB, etc.) | 519edd1 |
|
||||
| `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 | — |
|
||||
|
||||
---
|
||||
|
||||
@@ -458,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**
|
||||
|
||||
@@ -869,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
|
||||
```
|
||||
|
||||
@@ -909,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 |
|
||||
@@ -935,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-06-18
|
||||
**Version:** 1.2
|
||||
**Last updated:** 2026-08-11
|
||||
**Version:** 1.3
|
||||
**Status:** Release
|
||||
+276
@@ -3,6 +3,282 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [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-иконок унифицирован для карточек и детальных страниц
|
||||
- Папки `custom_data` теперь используют имя exe и MD5 полного пути для предотвращения конфликтов одинаковых имён
|
||||
- Управление геймпадом переведено с `pygame` на SDL3, зависимость `pygame` удалена
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
|
||||
- Возвращено отображение нативных Linux-игр в библиотеке Steam без отображения саундтреков
|
||||
- После создания или удаления ярлыков меню обновляется база desktop-файлов
|
||||
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
|
||||
|
||||
### Fixed
|
||||
- Настройки прокси теперь передаются `curl` при загрузке файлов скриптами PortProton
|
||||
- Устранена повторная перестройка индексов Steam и PPDB для каждой карточки в экономном режиме
|
||||
- Скрытие бейджей больше не скрывает ленту источника игры на карточках библиотеки
|
||||
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
|
||||
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
|
||||
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
|
||||
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
|
||||
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
|
||||
- Исправлено обрезание подсказок управления при переносе на вторую строку
|
||||
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
|
||||
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
|
||||
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
|
||||
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
|
||||
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
|
||||
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
|
||||
- Системные действия в режиме `--ppqtos` переведены на `systemctl` и `loginctl`
|
||||
- Исправлена перекраска SVG-иконок подсказок управления через `ICON_COLORS`
|
||||
- Исправлен ввод кириллицы при поиске игр
|
||||
- Поиск игр ограничен названиями без совпадений по описаниям
|
||||
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
|
||||
- Исправлено зависание интерфейса после завершения игры
|
||||
- Исправлено определение основного аккаунта 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
|
||||
- Исправлено CVE-2026-59678: уязвимость повышения привилегий в Polkit-правилах udisks и NetworkManager, где проверка командной строки через `ps` позволяла локальным пользователям обойти авторизацию
|
||||
|
||||
### Changed
|
||||
- `--ppqtos` теперь требует что бы пользователь был в группе `portprotonqt`
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
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,422 +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.
|
||||
@@ -3,15 +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>
|
||||
<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
|
||||
@@ -28,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
|
||||
@@ -56,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
|
||||
|
||||
@@ -64,11 +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.
|
||||
+26
-8
@@ -1,17 +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>
|
||||
<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
|
||||
@@ -28,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
|
||||
@@ -64,10 +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.
|
||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
|
||||
- [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.
|
||||
|
||||
Условия лицензии 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
@@ -1,58 +0,0 @@
|
||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
|
||||
- [X] Добавить возможность управления с геймпада
|
||||
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
|
||||
- [X] Добавить возможность управления с мыши и клавиатуры
|
||||
- [X] Добавить систему тем [Документация](documentation/theme_guide)
|
||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
|
||||
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
|
||||
- [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
||||
- [X] Разобраться почему теряется часть стилей в Gamescope
|
||||
- [x] Разработать адаптивный дизайн (за эталон берётся 1000x700 из гайдлайнов Flathub)
|
||||
- [x] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
|
||||
- [X] Получать описания и названия игр из базы данных Steam
|
||||
- [X] Получать обложки для игр из CDN Steam
|
||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
|
||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
|
||||
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
||||
- [X] Избавиться от вызовов yad
|
||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
||||
- [X] Добавить экранную клавиатуру в поиск
|
||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
||||
- [X] Добавить индикацию запуска приложения
|
||||
- [X] Достигнуть паритета функциональности с Ingame
|
||||
- [X] Достигнуть паритета функциональности с PortProton
|
||||
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
|
||||
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
|
||||
- [X] Добавить переводы в переопределения
|
||||
- [X] Добавить в карточку игры сведения о поддержке геймпада
|
||||
- [X] Добавить в карточки данные с ProtonDB
|
||||
- [X] Добавить в карточки данные с AreWeAntiCheatYet
|
||||
- [X] Продублировать бейджи с карточки на страницу с деталями игры
|
||||
- [X] Добавить парсинг ярлыков из Steam
|
||||
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
|
||||
- [X] Добавить поддержку версий Steam для Flatpak и Snap
|
||||
- [X] Реализовать добавление игры как сторонней в Steam без перезапуска
|
||||
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
|
||||
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
|
||||
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
|
||||
- [X] Отображать описания игр и другие данные на языке системы
|
||||
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
|
||||
- [X] Добавить систему избранного для карточек
|
||||
- [X] Заменить все `print` на `logging`
|
||||
- [X] Привести все логи к единому языку
|
||||
- [X] Уменьшить количество подстановок в переводах
|
||||
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
|
||||
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
|
||||
- [X] Исправить частичное применение тем на лету
|
||||
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
|
||||
- [ ] Добавить поддержку GOG (?)
|
||||
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
||||
- [X] Добавить данные с HowLongToBeat на страницу с деталями игры
|
||||
- [X] Добавить виброотдачу на геймпаде при запуске игры
|
||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||
- [X] Добавить подсказки к управлению с геймпада
|
||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
||||
@@ -5,6 +5,8 @@ set -eu
|
||||
# Initialize variables
|
||||
LOCAL_MODE=false
|
||||
BRANCH="main"
|
||||
USE_BRANCH=false
|
||||
TAG=""
|
||||
REPO_URL="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git"
|
||||
|
||||
# Parse arguments
|
||||
@@ -16,12 +18,22 @@ while [ $# -gt 0 ]; do
|
||||
--branch)
|
||||
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
|
||||
BRANCH="$2"
|
||||
USE_BRANCH=true
|
||||
shift
|
||||
else
|
||||
echo "Error: --branch requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--tag)
|
||||
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
|
||||
TAG="$2"
|
||||
shift
|
||||
else
|
||||
echo "Error: --tag requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--repo)
|
||||
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
|
||||
REPO_URL="$2"
|
||||
@@ -40,14 +52,16 @@ 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..."
|
||||
PPQT_PKGBUILD=""
|
||||
elif [ -n "$TAG" ]; then
|
||||
echo "Using stable version of PortProtonQt from $TAG tag..."
|
||||
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/tag/$TAG/build-aux/PKGBUILD"
|
||||
else
|
||||
echo "Using stable version of PortProtonQt from main branch..."
|
||||
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/build-aux/PKGBUILD"
|
||||
echo "Using stable version of PortProtonQt from $BRANCH branch..."
|
||||
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/$BRANCH/build-aux/PKGBUILD"
|
||||
fi
|
||||
|
||||
echo "Tweak makepkg..."
|
||||
@@ -68,23 +82,6 @@ echo "---------------------------------------------------------------"
|
||||
pacman-key --init
|
||||
pacman -S --noconfirm archlinux-keyring
|
||||
|
||||
# Chaotic-AUR keys
|
||||
pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com
|
||||
pacman-key --lsign-key 3056513887B78AEB
|
||||
|
||||
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst'
|
||||
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst'
|
||||
|
||||
# Enable the multilib repository
|
||||
cat << EOM >> /etc/pacman.conf
|
||||
[multilib]
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
|
||||
[chaotic-aur]
|
||||
Include = /etc/pacman.d/chaotic-mirrorlist
|
||||
SigLevel = Never
|
||||
EOM
|
||||
|
||||
# Disable locale noextract
|
||||
sed -i -E 's@[[:space:]]*usr/share/locale/\*@@g; s@[[:space:]]+@ @g; s@[[:space:]]+$@@' /etc/pacman.conf
|
||||
|
||||
@@ -97,14 +94,16 @@ if [ "$LOCAL_MODE" = true ]; then
|
||||
else
|
||||
wget --retry-connrefused --tries=30 "$PPQT_PKGBUILD" -O ./PKGBUILD
|
||||
fi
|
||||
sed -i "s|source=(\"git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git\")|source=(\"git+${REPO_URL}#branch=$BRANCH\")|" PKGBUILD
|
||||
if [ "$USE_BRANCH" = true ]; then
|
||||
sed -i "s|^source=.*|source=(\"git+${REPO_URL}#branch=$BRANCH\")|" PKGBUILD
|
||||
elif [ -n "$TAG" ]; then
|
||||
sed -i "s|^source=.*|source=(\"git+${REPO_URL}#tag=$TAG\")|" PKGBUILD
|
||||
fi
|
||||
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
|
||||
@@ -14,35 +13,89 @@ export UPINFO="gitea-releases-zsync|git.linux-gaming.ru|Linux-Gaming|PortProtonQ
|
||||
export DEPLOY_OPENGL=1
|
||||
export DEPLOY_SDL=1
|
||||
export DEPLOY_PYTHON=1
|
||||
export MAIN_BIN=portprotonqt
|
||||
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 manually to avoid tracing 208 .ppdb files with quick-sharun
|
||||
mkdir -p ./AppDir/share
|
||||
if [ -d /usr/local/share/portproton ]; then
|
||||
cp -r /usr/local/share/portproton ./AppDir/share
|
||||
elif [ -d /usr/share/portproton ]; then
|
||||
cp -r /usr/share/portproton ./AppDir/share
|
||||
fi
|
||||
cp -r /usr/share/portproton ./AppDir/share
|
||||
|
||||
# QSoundEffect only needs PCM WAV and PulseAudio. Remove the optional native
|
||||
# PipeWire path and FFmpeg media backend before quick-sharun scans Qt plugins.
|
||||
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.
|
||||
PYTHON_DIR=$(find ./AppDir/lib -maxdepth 1 -type d -name 'python3.*' -print -quit)
|
||||
PYTHON_SITE=$PYTHON_DIR/site-packages
|
||||
PYSIDE_DIR=$PYTHON_SITE/PySide6
|
||||
|
||||
rm -rf \
|
||||
"$PYTHON_DIR"/ensurepip \
|
||||
"$PYTHON_DIR"/idlelib \
|
||||
"$PYTHON_DIR"/pydoc_data \
|
||||
"$PYTHON_DIR"/tkinter \
|
||||
"$PYTHON_DIR"/turtledemo \
|
||||
"$PYTHON_DIR"/venv \
|
||||
"$PYTHON_SITE"/Cython \
|
||||
"$PYTHON_SITE"/cython.py \
|
||||
"$PYTHON_SITE"/mesonbuild \
|
||||
"$PYTHON_SITE"/pygments \
|
||||
"$PYTHON_SITE"/pyximport \
|
||||
"$PYTHON_SITE"/setuptools \
|
||||
"$PYTHON_SITE"/vapoursynth \
|
||||
"$PYTHON_SITE"/wheel \
|
||||
"$PYTHON_SITE"/cython-*.dist-info \
|
||||
"$PYTHON_SITE"/meson-*.dist-info \
|
||||
"$PYTHON_SITE"/pygments-*.dist-info \
|
||||
"$PYTHON_SITE"/setuptools-*.dist-info \
|
||||
"$PYTHON_SITE"/vapoursynth-*.dist-info \
|
||||
"$PYTHON_SITE"/wheel-*.dist-info
|
||||
|
||||
find "$PYTHON_DIR" -depth -type d \( -name test -o -name tests \) \
|
||||
-exec rm -rf {} +
|
||||
find "$PYTHON_SITE" -type f -name '*.pyi' -delete
|
||||
|
||||
for module in "$PYSIDE_DIR"/Qt*.so; do
|
||||
case "${module##*/}" in
|
||||
QtCore.*|QtGui.*|QtMultimedia.*|QtNetwork.*|QtSvg.*|QtWidgets.*) continue ;;
|
||||
esac
|
||||
rm -f "$module"
|
||||
done
|
||||
rm -rf "$PYSIDE_DIR"/QtAsyncio
|
||||
|
||||
# Turn AppDir into AppImage
|
||||
./quick-sharun --make-appimage
|
||||
quick-sharun --make-appimage
|
||||
+18
-18
@@ -1,16 +1,26 @@
|
||||
pkgname=('portprotonqt')
|
||||
pkgver=1.3.0
|
||||
# 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-pygame' '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' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
|
||||
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' '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"
|
||||
|
||||
+17
-17
@@ -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-pygame' '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' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
|
||||
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' '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.0
|
||||
%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}
|
||||
@@ -33,7 +34,6 @@ Requires: python3-websocket-client
|
||||
Requires: python3-orjson
|
||||
Requires: python3-psutil
|
||||
Requires: python3-pyside6
|
||||
Requires: python3-pygame
|
||||
Requires: python3-requests
|
||||
Requires: python3-tqdm
|
||||
Requires: python3-vdf
|
||||
@@ -43,9 +43,9 @@ 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
|
||||
Requires: cabextract
|
||||
Requires: gzip
|
||||
Requires: unzip
|
||||
@@ -65,6 +65,7 @@ Requires: procps-ng
|
||||
Requires: psmisc
|
||||
Requires: 7zip
|
||||
Requires: python3-dbus-fast
|
||||
Requires: desktop-file-utils
|
||||
|
||||
# System Tab
|
||||
Recommends: NetworkManager
|
||||
@@ -80,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}
|
||||
|
||||
@@ -111,12 +112,14 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
|
||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
|
||||
%{_udevrulesdir}/60-portprotonqt.rules
|
||||
%{_sysusersdir}/portprotonqt.conf
|
||||
%{_datadir}/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.log.desktop
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.silent.desktop
|
||||
%{_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.0
|
||||
%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}
|
||||
@@ -30,7 +31,6 @@ Requires: python3-websocket-client
|
||||
Requires: python3-orjson
|
||||
Requires: python3-psutil
|
||||
Requires: python3-pyside6
|
||||
Requires: python3-pygame
|
||||
Requires: python3-requests
|
||||
Requires: python3-tqdm
|
||||
Requires: python3-vdf
|
||||
@@ -40,9 +40,9 @@ 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
|
||||
Requires: cabextract
|
||||
Requires: gzip
|
||||
Requires: unzip
|
||||
@@ -62,6 +62,7 @@ Requires: procps-ng
|
||||
Requires: psmisc
|
||||
Requires: 7zip
|
||||
Requires: python3-dbus-fast
|
||||
Requires: desktop-file-utils
|
||||
|
||||
# System Tab
|
||||
Recommends: NetworkManager
|
||||
@@ -77,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}
|
||||
|
||||
@@ -109,12 +110,14 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
|
||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
|
||||
%{_udevrulesdir}/60-portprotonqt.rules
|
||||
%{_sysusersdir}/portprotonqt.conf
|
||||
%{_datadir}/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.log.desktop
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.silent.desktop
|
||||
%{_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.
@@ -0,0 +1 @@
|
||||
g portprotonqt - -
|
||||
@@ -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-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;
|
||||
@@ -0,0 +1,20 @@
|
||||
[Desktop Entry]
|
||||
Name=PortProtonQt (log)
|
||||
Name[ru]=PortProtonQt (режим лога)
|
||||
Name[es]=PortProtonQt (modo de registro)
|
||||
Name[pt]=PortProtonQt (modo de registro)
|
||||
Name[de]=PortProtonQt (Protokollmodus)
|
||||
Exec=portprotonqt --log %u
|
||||
Type=Application
|
||||
Comment=Launch Windows applications with logging through PortProtonQt
|
||||
Comment[ru]=Запуск Windows-приложений с записью лога через PortProtonQt
|
||||
Comment[es]=Ejecuta aplicaciones de Windows con registro mediante PortProtonQt
|
||||
Comment[pt]=Executa aplicativos do Windows com registro pelo PortProtonQt
|
||||
Comment[de]=Startet Windows-Anwendungen mit Protokollierung über PortProtonQt
|
||||
Terminal=false
|
||||
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-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,20 @@
|
||||
[Desktop Entry]
|
||||
Name=PortProtonQt (silent)
|
||||
Name[ru]=PortProtonQt (тихий режим)
|
||||
Name[es]=PortProtonQt (modo silencioso)
|
||||
Name[pt]=PortProtonQt (modo silencioso)
|
||||
Name[de]=PortProtonQt (stiller Modus)
|
||||
Exec=portprotonqt --silent %u
|
||||
Type=Application
|
||||
Comment=Launch Windows applications silently through PortProtonQt
|
||||
Comment[ru]=Тихий запуск Windows-приложений через PortProtonQt
|
||||
Comment[es]=Ejecuta aplicaciones de Windows silenciosamente mediante PortProtonQt
|
||||
Comment[pt]=Executa aplicativos do Windows silenciosamente pelo PortProtonQt
|
||||
Comment[de]=Startet Windows-Anwendungen lautlos über PortProtonQt
|
||||
Terminal=false
|
||||
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-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,24 @@
|
||||
</developer>
|
||||
|
||||
<releases>
|
||||
<release version="1.3.0" date="2026-07-13">
|
||||
<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>
|
||||
<release version="1.2.0" date="2026-06-10">
|
||||
<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>
|
||||
@@ -1,21 +1,15 @@
|
||||
polkit.addRule(function(action, subject) {
|
||||
var cmd_line = polkit.spawn(["ps", "-o", "args=", "-p", String(subject.pid)]);
|
||||
var ppid = polkit.spawn(["ps", "-o", "ppid=", "-p", String(subject.pid)]).trim();
|
||||
var parent_cmd_line = "";
|
||||
if (ppid) {
|
||||
parent_cmd_line = polkit.spawn(["ps", "-o", "args=", "-p", ppid]);
|
||||
if (!subject.local || !subject.active ||
|
||||
!subject.isInGroup("portprotonqt")) {
|
||||
return polkit.Result.NOT_HANDLED;
|
||||
}
|
||||
var is_ppqt_call =
|
||||
cmd_line.includes("ppqtos") ||
|
||||
parent_cmd_line.includes("ppqtos");
|
||||
|
||||
if ( (
|
||||
action.id === "org.freedesktop.NetworkManager.settings.modify.system" ||
|
||||
action.id == "org.freedesktop.udisks2.filesystem-mount-system" ||
|
||||
action.id == "org.freedesktop.udisks2.filesystem-mount-other-seat" ||
|
||||
action.id == "org.freedesktop.udisks2.filesystem-unmount-others" ) &&
|
||||
is_ppqt_call)
|
||||
{
|
||||
return polkit.Result.YES;
|
||||
if (action.id === "org.freedesktop.NetworkManager.settings.modify.system" ||
|
||||
action.id === "org.freedesktop.udisks2.filesystem-mount-system" ||
|
||||
action.id === "org.freedesktop.udisks2.filesystem-mount-other-seat" ||
|
||||
action.id === "org.freedesktop.udisks2.filesystem-unmount-others") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
|
||||
return polkit.Result.NOT_HANDLED;
|
||||
});
|
||||
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 ; 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"
|
||||
@@ -3756,10 +3799,19 @@ start_portproton () {
|
||||
|
||||
add_in_start_portproton
|
||||
|
||||
if [[ "$FULL_LN" == russian ]] ; then
|
||||
local steam_emu_language=""
|
||||
case "${PW_LOCALE_SELECT}" in
|
||||
ru_*) steam_emu_language="russian" ;;
|
||||
en_*) steam_emu_language="english" ;;
|
||||
zh_*) steam_emu_language="schinese" ;;
|
||||
ja_*) steam_emu_language="japanese" ;;
|
||||
ko_*) steam_emu_language="koreana" ;;
|
||||
disabled|"") steam_emu_language="$FULL_LN" ;;
|
||||
esac
|
||||
if [[ -n "$steam_emu_language" ]] ; then
|
||||
for chk_lang_and_uname in "steam_emu.ini" "steam_api.ini" "steam_api64.ini" "SteamConfig.ini" ; do
|
||||
if [[ -f "${PATH_TO_GAME}/${chk_lang_and_uname}" ]] ; then
|
||||
sed -i "/^Language/c\Language=${FULL_LN}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^Language/c\Language=${steam_emu_language}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^UserName/c\UserName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^PlayerName/c\PlayerName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
fi
|
||||
@@ -3796,6 +3848,7 @@ start_portproton () {
|
||||
}
|
||||
|
||||
pw_run () {
|
||||
local pw_game_exit_code
|
||||
if [[ "$PW_USE_TERMINAL" == 1 ]] \
|
||||
&& [[ "${PRESSURE_VESSEL_TERMINAL:-}" != "tty" ]]
|
||||
then
|
||||
@@ -3862,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
|
||||
@@ -3882,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
|
||||
@@ -3911,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:"
|
||||
@@ -3931,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
|
||||
|
||||
@@ -3953,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
|
||||
@@ -4089,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 \
|
||||
@@ -4098,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 ))
|
||||
@@ -4114,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}" &
|
||||
@@ -4131,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%%.*}"
|
||||
@@ -4158,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
|
||||
@@ -4213,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.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
|
||||
@@ -4359,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 ))"
|
||||
|
||||
@@ -4903,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 $?
|
||||
}
|
||||
@@ -4957,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
+30
@@ -1,3 +1,33 @@
|
||||
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.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Fri, 17 Jul 2026 19:46:59 +0500
|
||||
|
||||
portprotonqt (1.3.0) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
Vendored
+3
-1
@@ -5,6 +5,7 @@ Maintainer: Ivan Mazhukin <vanyamajukin@outlook.com>
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
gettext,
|
||||
libsdl3-dev,
|
||||
libvulkan-dev,
|
||||
meson,
|
||||
ninja-build,
|
||||
@@ -20,6 +21,7 @@ Depends:
|
||||
7zip,
|
||||
cabextract,
|
||||
curl,
|
||||
desktop-file-utils,
|
||||
file,
|
||||
gawk,
|
||||
libimage-exiftool-perl,
|
||||
@@ -37,9 +39,9 @@ Depends:
|
||||
python3-pefile,
|
||||
python3-pil,
|
||||
python3-psutil,
|
||||
python3-pygame,
|
||||
python3-pyside6.qtcore,
|
||||
python3-pyside6.qtgui,
|
||||
python3-pyside6.qtmultimedia,
|
||||
python3-pyside6.qtnetwork,
|
||||
python3-pyside6.qtsvg,
|
||||
python3-pyside6.qtwidgets,
|
||||
|
||||
Vendored
+3
@@ -1,8 +1,11 @@
|
||||
usr/bin/portprotonqt
|
||||
usr/bin/vk_gpu_info
|
||||
usr/lib/python3/dist-packages/portprotonqt/
|
||||
usr/lib/sysusers.d/portprotonqt.conf
|
||||
usr/lib/udev/rules.d/60-portprotonqt.rules
|
||||
usr/share/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
usr/share/applications/ru.linux_gaming.PortProtonQt.log.desktop
|
||||
usr/share/applications/ru.linux_gaming.PortProtonQt.silent.desktop
|
||||
usr/share/bash-completion/completions/portprotonqt
|
||||
usr/share/fish/vendor_completions.d/portprotonqt.fish
|
||||
usr/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||
|
||||
Vendored
+3
@@ -19,3 +19,6 @@ execute_after_dh_auto_install:
|
||||
debian/tmp/usr/share/zsh/site-functions/_portprotonqt
|
||||
install -Dpm 0644 completions/portprotonqt.fish \
|
||||
debian/tmp/usr/share/fish/vendor_completions.d/portprotonqt.fish
|
||||
|
||||
execute_after_dh_install:
|
||||
dh_installsysusers
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
remote="${1:-origin}"
|
||||
main_branch="${2:-main}"
|
||||
weblate_branch="${3:-weblate}"
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Working tree is not clean."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch "$remote" "$main_branch" "$weblate_branch"
|
||||
git switch "$main_branch"
|
||||
git merge --ff-only "$remote/$main_branch"
|
||||
|
||||
if git merge-base --is-ancestor "$remote/$weblate_branch" HEAD; then
|
||||
echo "No new Weblate changes to merge."
|
||||
elif git merge-base --is-ancestor HEAD "$remote/$weblate_branch"; then
|
||||
git merge --ff-only "$remote/$weblate_branch"
|
||||
else
|
||||
git switch -C "$weblate_branch" "$remote/$weblate_branch"
|
||||
git rebase "$main_branch"
|
||||
git switch "$main_branch"
|
||||
git merge --ff-only "$weblate_branch"
|
||||
fi
|
||||
|
||||
python dev-scripts/l10n.py
|
||||
|
||||
git push "$remote" "$main_branch"
|
||||
git push --force "$remote" "$main_branch:$weblate_branch"
|
||||
|
||||
echo "Weblate translations accepted and $weblate_branch synced to $main_branch."
|
||||
+11
-2
@@ -141,14 +141,23 @@ def bump_workflow(path: Path, old: str, new: str) -> bool:
|
||||
|
||||
def bump_changelog(path: Path, old: str, new: str) -> bool:
|
||||
"""
|
||||
Update [Unreleased] to [new] - YYYY-MM-DD in CHANGELOG.md
|
||||
Create a new [Unreleased] section and release the current one
|
||||
"""
|
||||
if not path.exists():
|
||||
return False
|
||||
text = path.read_text(encoding='utf-8')
|
||||
pattern = re.compile(r"(?m)^##\s*\[Unreleased\]$")
|
||||
current_date = date.today().strftime('%Y-%m-%d')
|
||||
new_text, count = pattern.subn(f"## [{new}] - {current_date}", text)
|
||||
release_header = (
|
||||
"## [Unreleased]\n\n"
|
||||
"### Added\n\n"
|
||||
"### Changed\n\n"
|
||||
"### Fixed\n\n"
|
||||
"### Contributors\n\n"
|
||||
"---\n\n"
|
||||
f"## [{new}] - {current_date}"
|
||||
)
|
||||
new_text, count = pattern.subn(release_header, text)
|
||||
if count:
|
||||
path.write_text(new_text, encoding='utf-8')
|
||||
return bool(count)
|
||||
|
||||
@@ -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 --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 --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,10 +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]'
|
||||
'--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]'
|
||||
@@ -93,17 +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 --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 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"
|
||||
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
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_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"
|
||||
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 "$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 -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"
|
||||
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 "$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_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 | format_downloads)\t\(.name)"'
|
||||
echo "GitHub:"
|
||||
echo "$GH_RAW" | jq -r "$jq_format $jq_filter_top"
|
||||
echo
|
||||
echo "Gitea:"
|
||||
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 | format_downloads)"'
|
||||
echo "GitHub:"
|
||||
echo "$GH_RAW" | jq -r "$jq_format $jq_type"
|
||||
echo
|
||||
echo "Gitea:"
|
||||
echo "$GIT_RAW" | jq -r "$jq_format $jq_type"
|
||||
|
||||
if [[ "${CI:-}" != "true" ]]; then
|
||||
echo
|
||||
echo "Local run: README update and commit skipped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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: $BADGE_TOTAL"
|
||||
|
||||
git diff --quiet README.md README.ru.md && { echo "No changes to commit"; exit 0; }
|
||||
|
||||
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
git config http.sslVerify false
|
||||
git config --local user.email "gitea-actions@users.noreply.gitea.com"
|
||||
git config --local user.name "Gitea Actions"
|
||||
|
||||
git add README.md README.ru.md
|
||||
git commit -m "chore: download stats ${timestamp}"
|
||||
remote_repo="https://${GITEA_ACTOR}:${GITEA_TOKEN}@${GITEA_SERVER}/${GITEA_REPOSITORY}.git"
|
||||
git push "${remote_repo}" HEAD:main
|
||||
echo "Pushed to Gitea"
|
||||
@@ -18,7 +18,7 @@ DEBUG_MODE = os.environ.get('DEBUG_MODE', 'false').lower() == 'true'
|
||||
|
||||
# Конфигурация API
|
||||
STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
|
||||
PPDB_RATINGS_URL = "https://ppdb.linux-gaming.ru/api/games/ratings?fields=id,name,overall_rating&has_reports=true"
|
||||
PPDB_RATINGS_URL = "https://linux-gaming.ru/api/games/ratings?fields=id,name,overall_rating&has_reports=true"
|
||||
CATEGORY_STEAM = "games"
|
||||
|
||||
# Отключаем предупреждения об SSL в дебаг-режиме
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
- [Library Layout Mode](#library-layout-mode)
|
||||
- [Detail Page Layout Mode](#detail-page-layout-mode)
|
||||
- [Detail Page Background Mode](#detail-page-background-mode)
|
||||
- [Custom Gradient Stops](#custom-gradient-stops)
|
||||
- [Wave Configuration](#wave-configuration)
|
||||
- [Background Configuration](#background-configuration)
|
||||
- [Library Background](#library-background)
|
||||
- [Preloader](#preloader)
|
||||
- [Source Corner (Ribbon)](#source-corner-ribbon)
|
||||
- [Terminal Color Schemes](#terminal-color-schemes)
|
||||
@@ -26,6 +26,7 @@
|
||||
- [Recoloring SVG Icons](#recoloring-svg-icons)
|
||||
- [AutoSizeButton State Recoloring](#autosizebutton-state-recoloring)
|
||||
- [Non-Inherited Icon Colors](#non-inherited-icon-colors)
|
||||
- [Sound Effects](#sound-effects-optional)
|
||||
|
||||
---
|
||||
|
||||
@@ -78,6 +79,7 @@ my_custom_theme/
|
||||
├── metainfo.ini
|
||||
├── fonts/
|
||||
├── images/
|
||||
├── sounds/
|
||||
└── styles/
|
||||
├── __init__.py # This empty file makes the directory a Python package
|
||||
├── constants.py
|
||||
@@ -142,7 +144,7 @@ THEME_INHERITS = "classic"
|
||||
|
||||
If `THEME_INHERITS` is not defined, the theme inherits styles from `standart`.
|
||||
|
||||
Fonts, icons, and images also follow the inheritance chain — the first match found walking from child to `standart` wins. If your theme has a `fonts/` directory, it takes priority over the parent's. If it doesn't, the parent's fonts are loaded. Screenshots are the exception — they are loaded only from the current theme, not inherited.
|
||||
Fonts, icons, images, and sounds also follow the inheritance chain — the first match found walking from child to `standart` wins. If your theme has a `fonts/` directory, it takes priority over the parent's. Missing sound events fall back individually to the parent theme. Screenshots are the exception — they are loaded only from the current theme, not inherited.
|
||||
|
||||
### How Inheritance Works
|
||||
|
||||
@@ -233,12 +235,104 @@ 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.
|
||||
|
||||
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 = {
|
||||
"extra_margin": 8,
|
||||
"spacing": 12,
|
||||
"cover_load_size": 64,
|
||||
"cover_size": 56,
|
||||
"min_cover_size": 48,
|
||||
"cover_left_margin": 10,
|
||||
"row_height": 72,
|
||||
"min_row_height": 68,
|
||||
"cover_radius": 8,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
"card_height_ratio": 1.8,
|
||||
"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.
|
||||
|
||||
This is a theme-level option and does not depend on app settings.
|
||||
|
||||
@@ -265,84 +359,205 @@ Economy mode also forces the compact detail page layout.
|
||||
You can control the detail page background style from the theme via `styles.py`:
|
||||
|
||||
```python
|
||||
# "gradient" (default), "static_waves", or "waves"
|
||||
# One of the background modes listed below
|
||||
DETAIL_PAGE_BG_MODE = "gradient"
|
||||
```
|
||||
|
||||
- `gradient`: diagonal linear gradient extracted from the cover image palette (default).
|
||||
- `static_waves`: gradient background with static (non-animated) wave shapes overlaid.
|
||||
- `waves`: gradient background with animated wave shapes that drift over time.
|
||||
- `aurora`: animated vertical aurora ribbons.
|
||||
- `metaballs`: animated glowing metaballs.
|
||||
- `leaf`: animated falling leaves with a configurable foliage type.
|
||||
- `veins`: animated connected vein pattern.
|
||||
- `diagnostics`: animated grid, signal trace, scanner, and compatibility status.
|
||||
|
||||
### Custom Gradient Stops
|
||||
|
||||
By default, the detail page gradient is generated from the cover image palette with evenly distributed positions. Define `DETAIL_PAGE_GRADIENT` in `styles.py` to override positions, colors, or both:
|
||||
Prefix an animated background mode with `static_` to render one frame without
|
||||
starting its animation timer. For example:
|
||||
|
||||
```python
|
||||
# Custom positions — palette colors redistributed to these positions
|
||||
DETAIL_PAGE_GRADIENT = [0, 0.3, 0.7, 1]
|
||||
|
||||
# Custom colors — position and color for each stop
|
||||
DETAIL_PAGE_GRADIENT = [
|
||||
{"position": 0, "color": "#101010"},
|
||||
{"position": 0.5, "color": "#202020"},
|
||||
{"position": 1, "color": "#303030"},
|
||||
]
|
||||
|
||||
# Raw QSS stop string
|
||||
DETAIL_PAGE_GRADIENT = "stop:0 #101010, stop:0.5 #202020, stop:1 #303030"
|
||||
DETAIL_PAGE_BG_MODE = "static_diagnostics"
|
||||
```
|
||||
|
||||
### Gradient Type and Number of Colors Extracted from the Cover
|
||||
The supported static modes are `static_waves`, `static_aurora`,
|
||||
`static_metaballs`, `static_leaf`, `static_veins`, and
|
||||
`static_diagnostics`.
|
||||
|
||||
You can set the gradient type (linear or radial), by defining `DETAIL_PAGE_GRADIENT_TYPE` in `styles.py`:
|
||||
|
||||
```python
|
||||
# Accepts `linear` or `radial`
|
||||
DETAIL_PAGE_GRADIENT_TYPE = 'linear'
|
||||
```
|
||||
|
||||
Set the gradient direction:
|
||||
|
||||
```python
|
||||
# For linear gradients
|
||||
DETAIL_PAGE_GRADIENT_X1 = 0
|
||||
DETAIL_PAGE_GRADIENT_Y1 = 0
|
||||
DETAIL_PAGE_GRADIENT_X2 = 1
|
||||
DETAIL_PAGE_GRADIENT_Y2 = 0
|
||||
|
||||
# For radial gradients
|
||||
DETAIL_PAGE_GRADIENT_CX = 0.3
|
||||
DETAIL_PAGE_GRADIENT_CY = 0.3
|
||||
DETAIL_PAGE_GRADIENT_RADIUS = 0.7
|
||||
DETAIL_PAGE_GRADIENT_FX = 0.3
|
||||
DETAIL_PAGE_GRADIENT_FY = 0.3
|
||||
```
|
||||
|
||||
Set the number of colors to extract from the cover:
|
||||
Set the number of colors extracted from the cover:
|
||||
|
||||
```python
|
||||
# Must be at least 2
|
||||
DETAIL_PAGE_PALETTE_COLORS = 5
|
||||
```
|
||||
|
||||
### Wave Configuration
|
||||
### Background Configuration
|
||||
|
||||
When `DETAIL_PAGE_BG_MODE` is `"static_waves"` or `"waves"`, you can customize the wave appearance via the `DETAIL_PAGE_WAVES` dictionary:
|
||||
Background modes use `DETAIL_PAGE_BACKGROUNDS`:
|
||||
|
||||
```python
|
||||
DETAIL_PAGE_WAVES = {
|
||||
"layer_count": 4, # Number of wave layers
|
||||
"wave_amplitude_ratio": 0.06, # Wave height as ratio of page height
|
||||
"wave_frequency": 2.0, # Number of wave cycles across the page
|
||||
"layer_spacing_ratio": 0.04, # Vertical spacing between layers as ratio of page height
|
||||
"base_opacity": 0.45, # Opacity of the first wave layer (0.0–1.0)
|
||||
"opacity_decay": 0.85, # Opacity multiplier for each subsequent layer
|
||||
"animation_speed": 0.03, # Phase increment per tick — "waves" mode only (higher = faster)
|
||||
"animation_interval_ms": 30, # Timer interval in ms — "waves" mode only (lower = smoother)
|
||||
DETAIL_PAGE_BACKGROUNDS = {
|
||||
# Common settings for aurora, diagnostics, leaf, metaballs, and veins.
|
||||
"animation_interval_ms": 30, # Milliseconds between frames.
|
||||
"animation_speed": 0.03, # Phase increment per frame.
|
||||
"gradient": {
|
||||
# None uses cover colors.
|
||||
# [0, 0.3, 0.7, 1] distributes cover colors at custom positions.
|
||||
# [{"position": 0, "color": "#101010"}, ...] sets custom colors.
|
||||
# "stop:0 #101010, stop:1 #303030" accepts raw QSS stops.
|
||||
"stops": None,
|
||||
"type": "linear", # "linear" or "radial".
|
||||
"x1": 0, # Linear gradient start X (0.0-1.0).
|
||||
"y1": 0, # Linear gradient start Y (0.0-1.0).
|
||||
"x2": 1, # Linear gradient end X (0.0-1.0).
|
||||
"y2": 1, # Linear gradient end Y (0.0-1.0).
|
||||
"cx": 0.5, # Radial gradient center X (0.0-1.0).
|
||||
"cy": 0.5, # Radial gradient center Y (0.0-1.0).
|
||||
"radius": 0.5, # Radial gradient radius relative to the page.
|
||||
"fx": 0.5, # Radial gradient focal point X (0.0-1.0).
|
||||
"fy": 0.5, # Radial gradient focal point Y (0.0-1.0).
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4, # Number of overlapping wave layers.
|
||||
"wave_amplitude_ratio": 0.06, # Wave height / page height.
|
||||
"wave_frequency": 2.0, # Oscillations across the page.
|
||||
"layer_spacing_ratio": 0.04, # Layer gap / page height.
|
||||
"base_opacity": 0.45, # First layer opacity (0.0-1.0).
|
||||
"opacity_decay": 0.85, # Opacity multiplier for later layers.
|
||||
"animation_speed": 0.03, # Wave phase increment per frame.
|
||||
"animation_interval_ms": 30, # Milliseconds between wave frames.
|
||||
},
|
||||
"aurora": {
|
||||
"line_count": 8, # Number of vertical ribbons.
|
||||
"line_width_ratio": 0.006, # Ribbon width / page width.
|
||||
"opacity": 0.35, # Ribbon opacity (0.0-1.0).
|
||||
"steps": 40, # Curve segments per ribbon.
|
||||
"frequency": 5.0, # Bends along each ribbon.
|
||||
"speed": 1.2, # Ribbon motion speed multiplier.
|
||||
"phase_step": 0.8, # Phase difference between ribbons.
|
||||
"edge_offset": 0.5, # Offset used to distribute ribbons.
|
||||
"drift_ratio": 0.06, # Horizontal drift / page width.
|
||||
},
|
||||
"diagnostics": {
|
||||
"color": color_accent, # Color of every diagnostics element.
|
||||
"font_family": font_family,# Status text font.
|
||||
"font_size": 13, # Status text size in pixels.
|
||||
"grid_spacing": 64, # Grid cell size in pixels.
|
||||
"grid_line_width": 1, # Grid line width in pixels.
|
||||
"grid_opacity": 0.08, # Grid opacity (0.0-1.0).
|
||||
"trace_baseline": 0.78, # Trace Y position / page height.
|
||||
"trace_amplitude": 0.025, # Trace height / page height.
|
||||
"trace_frequency": 28, # Oscillations across the page.
|
||||
"trace_speed": 1.4, # Trace motion speed multiplier.
|
||||
"trace_step_width": 4, # Pixels between trace samples.
|
||||
"trace_line_width": 2, # Trace line width in pixels.
|
||||
"trace_opacity": 0.45, # Trace opacity (0.0-1.0).
|
||||
"text_opacity": 0.7, # Status text opacity (0.0-1.0).
|
||||
"text_x_ratio": 0.025, # Text left margin / page width.
|
||||
"first_line_y_ratio": 0.88,# First status Y / page height.
|
||||
"second_line_y_ratio": 0.91,# Second status Y / page height.
|
||||
"status_text": "COMPATIBILITY LAYER: OPERATIONAL", # First status.
|
||||
"containment_text": "WINDOWS CONTAINMENT: ACTIVE", # Second status.
|
||||
"sweep_offset": 0.25, # Initial scanner X position (0.0-1.0).
|
||||
"sweep_speed": 0.08, # Scanner motion speed multiplier.
|
||||
"sweep_width": 2, # Scanner width in pixels.
|
||||
"sweep_opacity": 0.2, # Scanner opacity (0.0-1.0).
|
||||
},
|
||||
"metaballs": {
|
||||
"count": 18, # Number of glowing balls.
|
||||
"seed_offset": 1, # Seed offset for another layout.
|
||||
"min_radius_ratio": 0.08, # Minimum radius / page height.
|
||||
"radius_range": 0.08, # Random extra radius range.
|
||||
"min_speed": 0.004, # Minimum vertical travel speed.
|
||||
"opacity": 0.55, # Ball center opacity (0.0-1.0).
|
||||
"x_seed": 1.31, # Horizontal placement seed multiplier.
|
||||
"y_seed": 7.13, # Vertical placement seed multiplier.
|
||||
"speed_seed": 2.71, # Individual speed seed multiplier.
|
||||
},
|
||||
"leaf": {
|
||||
# "generic", "sakura", "oak", "maple", or "birch".
|
||||
"type": "generic", # Shape of the falling foliage.
|
||||
"count": 32, # Number of leaves.
|
||||
"seed_offset": 1, # Seed offset for another layout.
|
||||
"min_size_ratio": 0.009, # Minimum size / page height.
|
||||
"size_range": 0.009, # Random extra size range.
|
||||
"min_speed": 0.002, # Minimum falling speed.
|
||||
"opacity": 0.65, # Leaf opacity (0.0-1.0).
|
||||
"sway_ratio": 0.04, # Horizontal sway / page width.
|
||||
"rotation_speed": 40, # Degrees per animation phase.
|
||||
"x_seed": 1.31, # Horizontal placement seed multiplier.
|
||||
"y_seed": 7.13, # Vertical placement seed multiplier.
|
||||
"speed_seed": 2.71, # Individual speed seed multiplier.
|
||||
},
|
||||
"veins": {
|
||||
"columns": 9, # Number of point columns.
|
||||
"rows": 6, # Number of point rows.
|
||||
"seed_offset": 1, # Seed offset for another pattern.
|
||||
"cell_offset": 0.5, # Point position inside a grid cell.
|
||||
"motion_ratio": 0.12, # Horizontal movement / cell width.
|
||||
"line_width": 2, # Connection width in pixels.
|
||||
"opacity": 0.3, # Connection opacity (0.0-1.0).
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Wave colors are derived from the darkened cover image palette. Each layer uses a different palette color with decreasing opacity. The `animation_speed` and `animation_interval_ms` parameters only affect the `"waves"` mode.
|
||||
Each named dictionary contains the visual constants for that effect. Copy the
|
||||
corresponding dictionary from the standard theme before overriding individual
|
||||
values so that all required keys remain present.
|
||||
|
||||
The `diagnostics` effect also exposes `status_text` and `containment_text`. The
|
||||
standard theme uses `COMPATIBILITY LAYER: OPERATIONAL` and
|
||||
`WINDOWS CONTAINMENT: ACTIVE`.
|
||||
|
||||
Existing themes may continue using `DETAIL_PAGE_GRADIENT`,
|
||||
`DETAIL_PAGE_GRADIENT_TYPE`, `DETAIL_PAGE_GRADIENT_X1` through
|
||||
`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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -742,3 +957,41 @@ When no `ICON_COLORS` entry matches an `AutoSizeButton` state, the button falls
|
||||
`ICON_COLORS` is not inherited from parent themes. If a child theme needs SVG recoloring, define its own `ICON_COLORS` dictionary in that theme.
|
||||
|
||||
---
|
||||
|
||||
## 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`:
|
||||
|
||||
| Event | Used for |
|
||||
|---|---|
|
||||
| `navigate` | Moving focus between UI items |
|
||||
| `click` | Pressing buttons, clickable labels, and confirming actions |
|
||||
| `back` | Returning or closing with the Back action |
|
||||
| `toggle` | Changing a checkbox or opening a combo box |
|
||||
| `open` | Opening pages, dialogs, and menus |
|
||||
| `tab_switch` | Switching application tabs |
|
||||
| `game_launch` | Successful game process launch |
|
||||
| `gamepad_connect` | Connecting a gamepad |
|
||||
| `gamepad_off` | Disconnecting a gamepad |
|
||||
|
||||
Use uncompressed PCM WAV files, for example `sounds/navigate.wav`. Other audio formats are not supported.
|
||||
|
||||
Sounds are inherited per event. A child theme can override only `navigate`; missing events are resolved through its parent chain up to `standart`.
|
||||
|
||||
Theme validation checks the extension, file size, and format signature before a sound is loaded. Sound files larger than 20 MiB, symlinks, files outside the theme directory, and files whose content does not match their extension are rejected. Include the license and attribution required by every sound asset distributed with a theme.
|
||||
|
||||
---
|
||||
@@ -13,8 +13,8 @@
|
||||
- [Режим сетки библиотеки](#режим-сетки-библиотеки)
|
||||
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
|
||||
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
|
||||
- [Свои стопы градиента](#свои-стопы-градиента)
|
||||
- [Настройка волн](#настройка-волн)
|
||||
- [Настройка фона](#настройка-фона)
|
||||
- [Фон библиотеки](#фон-библиотеки)
|
||||
- [Прелоадер](#прелоадер)
|
||||
- [Уголок источника (Лента)](#уголок-источника-лента)
|
||||
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
|
||||
@@ -26,6 +26,7 @@
|
||||
- [Перекраска SVG-иконок](#перекраска-svg-иконок)
|
||||
- [Перекраска состояний AutoSizeButton](#перекраска-состояний-autosizebutton)
|
||||
- [Ненаследуемые цвета иконок](#ненаследуемые-цвета-иконок)
|
||||
- [Звуковые эффекты](#звуковые-эффекты-опционально)
|
||||
|
||||
---
|
||||
|
||||
@@ -78,6 +79,7 @@ my_custom_theme/
|
||||
├── metainfo.ini
|
||||
├── fonts/
|
||||
├── images/
|
||||
├── sounds/
|
||||
└── styles/
|
||||
├── __init__.py # Этот пустой файл делает директорию Python-пакетом
|
||||
├── constants.py
|
||||
@@ -142,7 +144,7 @@ THEME_INHERITS = "classic"
|
||||
|
||||
Если `THEME_INHERITS` не указан, тема наследует стили от `standart`.
|
||||
|
||||
Шрифты, иконки и изображения также следуют цепочке наследования — побеждает первое совпадение при обходе от дочерней темы до `standart`. Если в вашей теме есть директория `fonts/`, она имеет приоритет над родительской. Если нет — загружаются шрифты родителя. Скриншоты — исключение: загружаются только из текущей темы, не наследуются.
|
||||
Шрифты, иконки, изображения и звуки также следуют цепочке наследования — побеждает первое совпадение при обходе от дочерней темы до `standart`. Если в вашей теме есть директория `fonts/`, она имеет приоритет над родительской. Отсутствующие звуковые события по отдельности загружаются из родительской темы. Скриншоты — исключение: загружаются только из текущей темы, не наследуются.
|
||||
|
||||
### Как работает наследование
|
||||
|
||||
@@ -233,12 +235,106 @@ 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` игр из текущих результатов
|
||||
фильтрации и сортировки, затем плитка из следующих игр для открытия полной
|
||||
сетки.
|
||||
|
||||
Плитка перехода в режиме `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 = {
|
||||
"extra_margin": 8,
|
||||
"spacing": 12,
|
||||
"cover_load_size": 64,
|
||||
"cover_size": 56,
|
||||
"min_cover_size": 48,
|
||||
"cover_left_margin": 10,
|
||||
"row_height": 72,
|
||||
"min_row_height": 68,
|
||||
"cover_radius": 8,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
"card_height_ratio": 1.8,
|
||||
"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` как в сеточном, так и в списочном режиме.
|
||||
|
||||
Это параметр уровня темы и не зависит от настроек приложения.
|
||||
|
||||
@@ -265,84 +361,205 @@ DETAIL_PAGE_LAYOUT_MODE = "full"
|
||||
Вы можете управлять стилем фона детальной страницы из темы через `styles.py`:
|
||||
|
||||
```python
|
||||
# "gradient" (по умолчанию), "static_waves" или "waves"
|
||||
# Один из перечисленных ниже режимов фона
|
||||
DETAIL_PAGE_BG_MODE = "gradient"
|
||||
```
|
||||
|
||||
- `gradient`: диагональный линейный градиент, извлечённый из палитры обложки (по умолчанию).
|
||||
- `static_waves`: градиентный фон со статическими (без анимации) волнами.
|
||||
- `waves`: градиентный фон с анимированными волнами, движущимися во времени.
|
||||
- `aurora`: анимированные вертикальные ленты северного сияния.
|
||||
- `metaballs`: анимированные светящиеся метаболы.
|
||||
- `leaf`: анимированная падающая листва с настраиваемым типом.
|
||||
- `veins`: анимированный связанный узор прожилок.
|
||||
- `diagnostics`: анимированная сетка, осциллограмма, сканер и статус совместимости.
|
||||
|
||||
### Свои стопы градиента
|
||||
|
||||
По умолчанию градиент детальной страницы строится из палитры обложки с равномерными позициями. Задайте `DETAIL_PAGE_GRADIENT` в `styles.py`, чтобы переопределить позиции, цвета или и то, и другое:
|
||||
Добавьте префикс `static_` к анимированному режиму фона, чтобы отрисовать один
|
||||
кадр без запуска таймера анимации. Например:
|
||||
|
||||
```python
|
||||
# Свои позиции — цвета обложки распределяются по этим позициям
|
||||
DETAIL_PAGE_GRADIENT = [0, 0.3, 0.7, 1]
|
||||
|
||||
# Свои цвета — позиция и цвет для каждого стопа
|
||||
DETAIL_PAGE_GRADIENT = [
|
||||
{"position": 0, "color": "#101010"},
|
||||
{"position": 0.5, "color": "#202020"},
|
||||
{"position": 1, "color": "#303030"},
|
||||
]
|
||||
|
||||
# Готовая QSS-строка
|
||||
DETAIL_PAGE_GRADIENT = "stop:0 #101010, stop:0.5 #202020, stop:1 #303030"
|
||||
DETAIL_PAGE_BG_MODE = "static_diagnostics"
|
||||
```
|
||||
|
||||
### Тип градиента и количество цветов извлекаемых из обложки
|
||||
Доступные статические режимы: `static_waves`, `static_aurora`,
|
||||
`static_metaballs`, `static_leaf`, `static_veins` и `static_diagnostics`.
|
||||
|
||||
Вы можете задать тип градиента (linear/radial), задав `DETAIL_PAGE_GRADIENT_TYPE` в `styles.py`:
|
||||
|
||||
```python
|
||||
# Принимает значения `linear` или `radial`
|
||||
DETAIL_PAGE_GRADIENT_TYPE = 'linear'
|
||||
```
|
||||
|
||||
Задать направление градиента:
|
||||
|
||||
```python
|
||||
# Для линейного
|
||||
DETAIL_PAGE_GRADIENT_X1 = 0
|
||||
DETAIL_PAGE_GRADIENT_Y1 = 0
|
||||
DETAIL_PAGE_GRADIENT_X2 = 1
|
||||
DETAIL_PAGE_GRADIENT_Y2 = 0
|
||||
|
||||
# Для радиального
|
||||
DETAIL_PAGE_GRADIENT_CX = 0.3
|
||||
DETAIL_PAGE_GRADIENT_CY = 0.3
|
||||
DETAIL_PAGE_GRADIENT_RADIUS = 0.7
|
||||
DETAIL_PAGE_GRADIENT_FX = 0.3
|
||||
DETAIL_PAGE_GRADIENT_FY = 0.3
|
||||
```
|
||||
|
||||
Задать количество цветов извлекаемых из обложки:
|
||||
Количество цветов, извлекаемых из обложки:
|
||||
|
||||
```python
|
||||
# Не менее 2
|
||||
DETAIL_PAGE_PALETTE_COLORS = 5
|
||||
```
|
||||
|
||||
### Настройка волн
|
||||
### Настройка фона
|
||||
|
||||
При `DETAIL_PAGE_BG_MODE` равном `"static_waves"` или `"waves"` можно настроить внешний вид волн через словарь `DETAIL_PAGE_WAVES`:
|
||||
Режимы фона используют словарь `DETAIL_PAGE_BACKGROUNDS`:
|
||||
|
||||
```python
|
||||
DETAIL_PAGE_WAVES = {
|
||||
"layer_count": 4, # Количество слоёв волн
|
||||
"wave_amplitude_ratio": 0.06, # Высота волны как отношение к высоте страницы
|
||||
"wave_frequency": 2.0, # Количество циклов волны на странице
|
||||
"layer_spacing_ratio": 0.04, # Вертикальный отступ между слоями как отношение к высоте страницы
|
||||
"base_opacity": 0.45, # Прозрачность первого слоя волны (0.0–1.0)
|
||||
"opacity_decay": 0.85, # Множитель прозрачности для каждого следующего слоя
|
||||
"animation_speed": 0.03, # Приращение фазы за тик — только для режима "waves" (больше = быстрее)
|
||||
"animation_interval_ms": 30, # Интервал таймера в мс — только для режима "waves" (меньше = плавнее)
|
||||
DETAIL_PAGE_BACKGROUNDS = {
|
||||
# Общие настройки aurora, diagnostics, leaf, metaballs и veins.
|
||||
"animation_interval_ms": 30, # Интервал между кадрами в мс.
|
||||
"animation_speed": 0.03, # Приращение фазы за кадр.
|
||||
"gradient": {
|
||||
# None берёт цвета обложки.
|
||||
# [0, 0.3, 0.7, 1] распределяет цвета обложки по своим позициям.
|
||||
# [{"position": 0, "color": "#101010"}, ...] задаёт свои цвета.
|
||||
# "stop:0 #101010, stop:1 #303030" принимает готовые стопы QSS.
|
||||
"stops": None,
|
||||
"type": "linear", # "linear" или "radial".
|
||||
"x1": 0, # Начальная координата X (0.0-1.0).
|
||||
"y1": 0, # Начальная координата Y (0.0-1.0).
|
||||
"x2": 1, # Конечная координата X (0.0-1.0).
|
||||
"y2": 1, # Конечная координата Y (0.0-1.0).
|
||||
"cx": 0.5, # Координата X центра радиального градиента.
|
||||
"cy": 0.5, # Координата Y центра радиального градиента.
|
||||
"radius": 0.5, # Радиус градиента относительно страницы.
|
||||
"fx": 0.5, # Координата X фокусной точки.
|
||||
"fy": 0.5, # Координата Y фокусной точки.
|
||||
},
|
||||
"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, # Интервал между кадрами волн.
|
||||
},
|
||||
"aurora": {
|
||||
"line_count": 8, # Количество вертикальных лент.
|
||||
"line_width_ratio": 0.006, # Ширина ленты / ширина страницы.
|
||||
"opacity": 0.35, # Прозрачность лент.
|
||||
"steps": 40, # Сегменты кривой каждой ленты.
|
||||
"frequency": 5.0, # Количество изгибов ленты.
|
||||
"speed": 1.2, # Множитель скорости движения.
|
||||
"phase_step": 0.8, # Сдвиг фазы между лентами.
|
||||
"edge_offset": 0.5, # Смещение при распределении лент.
|
||||
"drift_ratio": 0.06, # Отклонение / ширина страницы.
|
||||
},
|
||||
"diagnostics": {
|
||||
"color": color_accent, # Цвет всех элементов диагностики.
|
||||
"font_family": font_family,# Шрифт статусного текста.
|
||||
"font_size": 13, # Размер текста в пикселях.
|
||||
"grid_spacing": 64, # Размер ячейки сетки в пикселях.
|
||||
"grid_line_width": 1, # Толщина линий сетки.
|
||||
"grid_opacity": 0.08, # Прозрачность сетки.
|
||||
"trace_baseline": 0.78, # Положение сигнала / высота страницы.
|
||||
"trace_amplitude": 0.025, # Высота сигнала / высота страницы.
|
||||
"trace_frequency": 28, # Количество колебаний по ширине.
|
||||
"trace_speed": 1.4, # Множитель скорости сигнала.
|
||||
"trace_step_width": 4, # Интервал между отсчётами сигнала.
|
||||
"trace_line_width": 2, # Толщина линии сигнала.
|
||||
"trace_opacity": 0.45, # Прозрачность сигнала.
|
||||
"text_opacity": 0.7, # Прозрачность статусного текста.
|
||||
"text_x_ratio": 0.025, # Левый отступ / ширина страницы.
|
||||
"first_line_y_ratio": 0.88,# Положение первой строки / высота.
|
||||
"second_line_y_ratio": 0.91,# Положение второй строки / высота.
|
||||
"status_text": "COMPATIBILITY LAYER: OPERATIONAL", # Первая строка.
|
||||
"containment_text": "WINDOWS CONTAINMENT: ACTIVE", # Вторая строка.
|
||||
"sweep_offset": 0.25, # Начальная позиция сканера.
|
||||
"sweep_speed": 0.08, # Множитель скорости сканера.
|
||||
"sweep_width": 2, # Толщина сканера в пикселях.
|
||||
"sweep_opacity": 0.2, # Прозрачность сканера.
|
||||
},
|
||||
"metaballs": {
|
||||
"count": 18, # Количество светящихся метаболов.
|
||||
"seed_offset": 1, # Смещение seed для другого рисунка.
|
||||
"min_radius_ratio": 0.08, # Минимальный радиус / высота.
|
||||
"radius_range": 0.08, # Случайный дополнительный радиус.
|
||||
"min_speed": 0.004, # Минимальная скорость движения.
|
||||
"opacity": 0.55, # Прозрачность центра метабола.
|
||||
"x_seed": 1.31, # Seed горизонтального положения.
|
||||
"y_seed": 7.13, # Seed вертикального положения.
|
||||
"speed_seed": 2.71, # Seed индивидуальной скорости.
|
||||
},
|
||||
"leaf": {
|
||||
# "generic", "sakura", "oak", "maple" или "birch".
|
||||
"type": "generic", # Форма падающей листвы.
|
||||
"count": 32, # Количество листьев.
|
||||
"seed_offset": 1, # Смещение seed для другого рисунка.
|
||||
"min_size_ratio": 0.009, # Минимальный размер / высота.
|
||||
"size_range": 0.009, # Случайный дополнительный размер.
|
||||
"min_speed": 0.002, # Минимальная скорость падения.
|
||||
"opacity": 0.65, # Прозрачность листьев.
|
||||
"sway_ratio": 0.04, # Покачивание / ширина страницы.
|
||||
"rotation_speed": 40, # Градусы вращения на единицу фазы.
|
||||
"x_seed": 1.31, # Seed горизонтального положения.
|
||||
"y_seed": 7.13, # Seed вертикального положения.
|
||||
"speed_seed": 2.71, # Seed индивидуальной скорости.
|
||||
},
|
||||
"veins": {
|
||||
"columns": 9, # Количество столбцов точек.
|
||||
"rows": 6, # Количество строк точек.
|
||||
"seed_offset": 1, # Смещение seed для другого узора.
|
||||
"cell_offset": 0.5, # Положение точки внутри ячейки.
|
||||
"motion_ratio": 0.12, # Движение точки / ширина ячейки.
|
||||
"line_width": 2, # Толщина соединений в пикселях.
|
||||
"opacity": 0.3, # Прозрачность соединений.
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Цвета волн берутся из затемнённой палитры обложки. Каждый слой использует разный цвет палитры с уменьшающейся прозрачностью. Параметры `animation_speed` и `animation_interval_ms` влияют только на режим `"waves"`.
|
||||
Каждый именованный словарь содержит визуальные константы соответствующего
|
||||
эффекта. Перед изменением отдельных значений скопируйте нужный словарь из
|
||||
стандартной темы, чтобы сохранить все обязательные ключи.
|
||||
|
||||
Эффект `diagnostics` также предоставляет параметры `status_text` и
|
||||
`containment_text`. В стандартной теме используются строки
|
||||
`COMPATIBILITY LAYER: OPERATIONAL` и `WINDOWS CONTAINMENT: ACTIVE`.
|
||||
|
||||
Существующие темы могут продолжать использовать `DETAIL_PAGE_GRADIENT`,
|
||||
`DETAIL_PAGE_GRADIENT_TYPE`, переменные от `DETAIL_PAGE_GRADIENT_X1` до
|
||||
`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` скопируйте нужный
|
||||
словарь из описания выше.
|
||||
|
||||
---
|
||||
|
||||
@@ -742,3 +959,42 @@ ICON_COLORS = {
|
||||
`ICON_COLORS` не наследуется от родительских тем. Если дочерней теме нужна перекраска SVG, задайте собственный словарь `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`:
|
||||
|
||||
| Событие | Назначение |
|
||||
|---|---|
|
||||
| `navigate` | Перемещение фокуса между элементами интерфейса |
|
||||
| `click` | Нажатие кнопок, кликабельных меток и подтверждение действий |
|
||||
| `back` | Возврат или закрытие действием Back |
|
||||
| `toggle` | Изменение флажка или открытие комбобокса |
|
||||
| `open` | Открытие страниц, диалогов и меню |
|
||||
| `tab_switch` | Переключение вкладок приложения |
|
||||
| `game_launch` | Успешный запуск процесса игры |
|
||||
| `gamepad_connect` | Подключение геймпада |
|
||||
| `gamepad_off` | Отключение геймпада |
|
||||
|
||||
Используйте несжатые PCM WAV-файлы, например `sounds/navigate.wav`. Другие аудиоформаты не поддерживаются.
|
||||
|
||||
Звуки наследуются отдельно для каждого события. Дочерняя тема может переопределить только `navigate`; отсутствующие события ищутся по цепочке родительских тем вплоть до `standart`.
|
||||
|
||||
При проверке темы проверяются расширение, размер и сигнатура формата каждого звука. Файлы размером более 20 МиБ, символические ссылки, файлы за пределами директории темы и файлы, содержимое которых не соответствует расширению, отклоняются. Добавляйте лицензию и требуемую атрибуцию для каждого звукового ресурса, распространяемого вместе с темой.
|
||||
|
||||
---
|
||||
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."
|
||||
+19
-22
@@ -1,5 +1,5 @@
|
||||
project('portprotonqt', 'c',
|
||||
version: '1.3.0',
|
||||
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',
|
||||
@@ -84,6 +75,12 @@ install_data(
|
||||
install_dir: datadir / 'applications',
|
||||
)
|
||||
|
||||
install_data(
|
||||
'build-aux/share/applications/ru.linux_gaming.PortProtonQt.log.desktop',
|
||||
'build-aux/share/applications/ru.linux_gaming.PortProtonQt.silent.desktop',
|
||||
install_dir: datadir / 'applications',
|
||||
)
|
||||
|
||||
# Install MIME type
|
||||
install_data(
|
||||
'build-aux/share/mime/packages/ru.linux_gaming.PortProtonQt.xml',
|
||||
@@ -114,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',
|
||||
@@ -139,3 +131,8 @@ install_data(
|
||||
'build-aux/share/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules',
|
||||
install_dir: prefix / 'share' / 'polkit-1' / 'rules.d',
|
||||
)
|
||||
|
||||
install_data(
|
||||
'build-aux/lib/sysusers.d/portprotonqt.conf',
|
||||
install_dir: prefix / 'lib' / 'sysusers.d',
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
APP_VERSION = @APP_VERSION@
|
||||
APP_COMMIT = @APP_COMMIT@
|
||||
@@ -1,10 +1,12 @@
|
||||
from portprotonqt.animations.detail_page import DetailPageAnimations
|
||||
from portprotonqt.animations.detail_background import DetailBackgroundAnimations
|
||||
from portprotonqt.animations.game_card import GameCardAnimations
|
||||
from portprotonqt.animations.library_controls import ExpandingSearchAnimation, LibraryControlsAnimation
|
||||
from portprotonqt.animations.virtual_keyboard import VirtualKeyboardAnimations
|
||||
|
||||
__all__ = [
|
||||
"DetailPageAnimations",
|
||||
"DetailBackgroundAnimations",
|
||||
"ExpandingSearchAnimation",
|
||||
"GameCardAnimations",
|
||||
"LibraryControlsAnimation",
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Procedural detail page backgrounds adapted from omikuji."""
|
||||
|
||||
import math
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QRadialGradient,
|
||||
)
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
class DetailBackgroundAnimations:
|
||||
"""Manage animated and static omikuji detail backgrounds."""
|
||||
|
||||
effects = frozenset({"aurora", "diagnostics", "leaf", "metaballs", "veins"})
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._states: WeakKeyDictionary[QWidget, dict] = WeakKeyDictionary()
|
||||
|
||||
def setup(self, page: QWidget, palette: list, theme) -> bool:
|
||||
"""Setup an omikuji background and report whether its mode matched."""
|
||||
mode = getattr(theme, "DETAIL_PAGE_BG_MODE", "gradient")
|
||||
effect = mode.removeprefix("static_")
|
||||
if effect not in self.effects:
|
||||
self.remove(page)
|
||||
return False
|
||||
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,
|
||||
"palette": palette,
|
||||
"phase": 0.0,
|
||||
"config": config,
|
||||
"original_paint": page.paintEvent,
|
||||
"timer": None,
|
||||
}
|
||||
self._states[page] = state
|
||||
|
||||
def paint_event(event) -> None:
|
||||
state["original_paint"](event)
|
||||
self._paint(page, state)
|
||||
|
||||
page.paintEvent = paint_event
|
||||
if animated:
|
||||
self._start_timer(page, state)
|
||||
return True
|
||||
|
||||
def remove(self, page: QWidget) -> None:
|
||||
"""Remove the omikuji background from a detail page."""
|
||||
state = self._states.pop(page, None)
|
||||
if state is None:
|
||||
return
|
||||
timer = state["timer"]
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
page.paintEvent = state["original_paint"]
|
||||
|
||||
def _start_timer(self, page: QWidget, state: dict) -> None:
|
||||
config = state["config"]
|
||||
timer = QTimer(page)
|
||||
timer.setInterval(config["animation_interval_ms"])
|
||||
|
||||
def tick() -> None:
|
||||
if page.isHidden() or page.parent() is None:
|
||||
timer.stop()
|
||||
return
|
||||
state["phase"] += config["animation_speed"]
|
||||
page.update()
|
||||
|
||||
timer.timeout.connect(tick)
|
||||
timer.start()
|
||||
state["timer"] = timer
|
||||
|
||||
def _paint(self, page: QWidget, state: dict) -> None:
|
||||
if not state["palette"]:
|
||||
return
|
||||
painter = QPainter(page)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(page.rect())
|
||||
painters = {
|
||||
"aurora": self._paint_aurora,
|
||||
"diagnostics": self._paint_diagnostics,
|
||||
"metaballs": self._paint_metaballs,
|
||||
"leaf": self._paint_leaf,
|
||||
"veins": self._paint_veins,
|
||||
}
|
||||
painters[state["effect"]](painter, rect, state)
|
||||
painter.end()
|
||||
|
||||
def _paint_diagnostics(
|
||||
self, painter: QPainter, rect: QRectF, state: dict,
|
||||
) -> None:
|
||||
config = state["config"]["diagnostics"]
|
||||
color = QColor(config["color"])
|
||||
grid_color = QColor(color)
|
||||
grid_color.setAlphaF(config["grid_opacity"])
|
||||
painter.setPen(QPen(grid_color, config["grid_line_width"]))
|
||||
spacing = config["grid_spacing"]
|
||||
for x in range(0, int(rect.width()), spacing):
|
||||
painter.drawLine(QPointF(x, 0), QPointF(x, rect.height()))
|
||||
for y in range(0, int(rect.height()), spacing):
|
||||
painter.drawLine(QPointF(0, y), QPointF(rect.width(), y))
|
||||
|
||||
trace_color = QColor(color)
|
||||
trace_color.setAlphaF(config["trace_opacity"])
|
||||
painter.setPen(QPen(trace_color, config["trace_line_width"]))
|
||||
trace = QPainterPath()
|
||||
steps = max(int(rect.width() / config["trace_step_width"]), 1)
|
||||
for step in range(steps + 1):
|
||||
progress = step / steps
|
||||
signal = math.sin(
|
||||
progress * config["trace_frequency"]
|
||||
+ state["phase"] * config["trace_speed"]
|
||||
)
|
||||
point = QPointF(
|
||||
rect.width() * progress,
|
||||
rect.height() * (config["trace_baseline"] + signal * config["trace_amplitude"]),
|
||||
)
|
||||
trace.moveTo(point) if step == 0 else trace.lineTo(point)
|
||||
painter.drawPath(trace)
|
||||
self._paint_diagnostic_status(painter, rect, state)
|
||||
|
||||
def _paint_diagnostic_status(
|
||||
self, painter: QPainter, rect: QRectF, state: dict,
|
||||
) -> None:
|
||||
config = state["config"]["diagnostics"]
|
||||
color = QColor(config["color"])
|
||||
color.setAlphaF(config["text_opacity"])
|
||||
painter.setPen(color)
|
||||
font = QFont(config["font_family"])
|
||||
font.setPixelSize(config["font_size"])
|
||||
painter.setFont(font)
|
||||
x = rect.width() * config["text_x_ratio"]
|
||||
first_y = rect.height() * config["first_line_y_ratio"]
|
||||
second_y = rect.height() * config["second_line_y_ratio"]
|
||||
painter.drawText(QPointF(x, first_y), config["status_text"])
|
||||
painter.drawText(QPointF(x, second_y), config["containment_text"])
|
||||
sweep = (
|
||||
config["sweep_offset"] + state["phase"] * config["sweep_speed"]
|
||||
) % 1
|
||||
sweep_color = QColor(color)
|
||||
sweep_color.setAlphaF(config["sweep_opacity"])
|
||||
painter.setPen(QPen(sweep_color, config["sweep_width"]))
|
||||
sweep_x = rect.width() * sweep
|
||||
painter.drawLine(QPointF(sweep_x, 0), QPointF(sweep_x, rect.height()))
|
||||
|
||||
def _paint_aurora(self, painter: QPainter, rect: QRectF, state: dict) -> None:
|
||||
config = state["config"]["aurora"]
|
||||
color = QColor(state["palette"][-1])
|
||||
color.setAlphaF(config["opacity"])
|
||||
painter.setPen(QPen(color, rect.width() * config["line_width_ratio"]))
|
||||
for index in range(config["line_count"]):
|
||||
path = QPainterPath()
|
||||
for step in range(config["steps"] + 1):
|
||||
progress = step / config["steps"]
|
||||
drift = math.sin(
|
||||
progress * config["frequency"]
|
||||
+ state["phase"] * config["speed"]
|
||||
+ index * config["phase_step"]
|
||||
)
|
||||
x = rect.width() * (
|
||||
(index + config["edge_offset"]) / config["line_count"]
|
||||
+ drift * config["drift_ratio"]
|
||||
)
|
||||
point = QPointF(x, rect.height() * progress)
|
||||
path.moveTo(point) if step == 0 else path.lineTo(point)
|
||||
painter.drawPath(path)
|
||||
|
||||
def _paint_metaballs(self, painter: QPainter, rect: QRectF, state: dict) -> None:
|
||||
config = state["config"]["metaballs"]
|
||||
accent = QColor(state["palette"][-1])
|
||||
for index in range(config["count"]):
|
||||
seed = index + config["seed_offset"]
|
||||
radius = rect.height() * (
|
||||
config["min_radius_ratio"] + self._hash(seed) * config["radius_range"]
|
||||
)
|
||||
x = rect.width() * self._hash(seed * config["x_seed"])
|
||||
travel = state["phase"] * (
|
||||
config["min_speed"] + self._hash(seed * config["speed_seed"])
|
||||
)
|
||||
y = rect.height() * ((self._hash(seed * config["y_seed"]) + travel) % 1)
|
||||
gradient = QRadialGradient(QPointF(x, y), radius)
|
||||
center = QColor(accent)
|
||||
center.setAlphaF(config["opacity"])
|
||||
edge = QColor(accent)
|
||||
edge.setAlpha(0)
|
||||
gradient.setColorAt(0, center)
|
||||
gradient.setColorAt(1, edge)
|
||||
painter.setBrush(gradient)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(QPointF(x, y), radius, radius)
|
||||
|
||||
def _paint_leaf(self, painter: QPainter, rect: QRectF, state: dict) -> None:
|
||||
config = state["config"]["leaf"]
|
||||
color = QColor(state["palette"][-1])
|
||||
color.setAlphaF(config["opacity"])
|
||||
painter.setBrush(color)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
leaf_type = config["type"]
|
||||
for index in range(config["count"]):
|
||||
seed = index + config["seed_offset"]
|
||||
size = rect.height() * (
|
||||
config["min_size_ratio"] + self._hash(seed) * config["size_range"]
|
||||
)
|
||||
fall = state["phase"] * (
|
||||
config["min_speed"] + self._hash(seed * config["speed_seed"])
|
||||
)
|
||||
y = rect.height() * ((self._hash(seed * config["y_seed"]) + fall) % 1)
|
||||
sway = math.sin(state["phase"] + seed) * config["sway_ratio"]
|
||||
x = rect.width() * ((self._hash(seed * config["x_seed"]) + sway) % 1)
|
||||
painter.save()
|
||||
painter.translate(x, y)
|
||||
painter.rotate(state["phase"] * config["rotation_speed"] + seed)
|
||||
painter.drawPath(self._leaf_path(leaf_type, size))
|
||||
painter.restore()
|
||||
|
||||
@staticmethod
|
||||
def _leaf_path(leaf_type: str, size: float) -> QPainterPath:
|
||||
shapes = {
|
||||
"sakura": (
|
||||
(0, -0.62), (0.2, -0.95), (0.68, -0.82), (0.92, -0.3),
|
||||
(0.72, 0.35), (0, 0.92), (-0.72, 0.35), (-0.92, -0.3),
|
||||
(-0.68, -0.82), (-0.2, -0.95),
|
||||
),
|
||||
"oak": (
|
||||
(0, -1), (0.3, -0.82), (0.18, -0.64), (0.56, -0.55),
|
||||
(0.25, -0.34), (0.64, -0.16), (0.27, 0.02),
|
||||
(0.55, 0.28), (0.2, 0.36), (0.14, 0.62), (0.08, 0.58),
|
||||
(0.08, 1), (-0.08, 1), (-0.08, 0.58), (-0.14, 0.62),
|
||||
(-0.2, 0.36), (-0.55, 0.28), (-0.27, 0.02),
|
||||
(-0.64, -0.16), (-0.25, -0.34), (-0.56, -0.55),
|
||||
(-0.18, -0.64), (-0.3, -0.82),
|
||||
),
|
||||
"maple": (
|
||||
(0, -1),
|
||||
(0.14, -0.55), (0.36, -0.72), (0.32, -0.36),
|
||||
(0.72, -0.48), (0.56, -0.12), (1, -0.05),
|
||||
(0.62, 0.2), (0.72, 0.5), (0.22, 0.38),
|
||||
(0.08, 0.55), (0.08, 1), (-0.08, 1),
|
||||
(-0.08, 0.55), (-0.22, 0.38), (-0.72, 0.5),
|
||||
(-0.62, 0.2), (-1, -0.05), (-0.56, -0.12),
|
||||
(-0.72, -0.48), (-0.32, -0.36), (-0.36, -0.72),
|
||||
(-0.14, -0.55),
|
||||
),
|
||||
"birch": (
|
||||
(0, -1), (0.18, -0.72), (0.38, -0.78), (0.34, -0.56),
|
||||
(0.58, -0.55), (0.48, -0.34), (0.68, -0.26),
|
||||
(0.52, -0.08), (0.62, 0.12), (0.35, 0.22),
|
||||
(0.12, 0.48), (0.07, 1), (-0.07, 1), (-0.12, 0.48),
|
||||
(-0.35, 0.22), (-0.62, 0.12), (-0.52, -0.08),
|
||||
(-0.68, -0.26), (-0.48, -0.34), (-0.58, -0.55),
|
||||
(-0.34, -0.56), (-0.38, -0.78), (-0.18, -0.72),
|
||||
),
|
||||
"generic": (
|
||||
(0, -1), (0.38, -0.65), (0.62, -0.15), (0.5, 0.3),
|
||||
(0.12, 0.62), (0.07, 1), (-0.07, 1), (-0.12, 0.62),
|
||||
(-0.5, 0.3), (-0.62, -0.15), (-0.38, -0.65),
|
||||
),
|
||||
}
|
||||
points = shapes.get(leaf_type, shapes["generic"])
|
||||
path = QPainterPath(QPointF(size * points[0][0], size * points[0][1]))
|
||||
for x_ratio, y_ratio in points[1:]:
|
||||
path.lineTo(size * x_ratio, size * y_ratio)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
|
||||
def _paint_veins(self, painter: QPainter, rect: QRectF, state: dict) -> None:
|
||||
config = state["config"]["veins"]
|
||||
color = QColor(state["palette"][-1])
|
||||
color.setAlphaF(config["opacity"])
|
||||
painter.setPen(QPen(color, config["line_width"]))
|
||||
columns = config["columns"]
|
||||
rows = config["rows"]
|
||||
points = []
|
||||
for row in range(rows):
|
||||
row_points = []
|
||||
for column in range(columns):
|
||||
seed = row * columns + column + config["seed_offset"]
|
||||
motion = math.sin(state["phase"] + seed) * config["motion_ratio"]
|
||||
x = rect.width() * ((column + config["cell_offset"] + motion) / columns)
|
||||
y = rect.height() * (
|
||||
(row + config["cell_offset"] + self._hash(seed)) / rows
|
||||
)
|
||||
row_points.append(QPointF(x, y))
|
||||
points.append(row_points)
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
if column + 1 < columns:
|
||||
painter.drawLine(points[row][column], points[row][column + 1])
|
||||
if row + 1 < rows:
|
||||
painter.drawLine(points[row][column], points[row + 1][column])
|
||||
|
||||
@staticmethod
|
||||
def _hash(value: float) -> float:
|
||||
return math.sin(value * 12.9898) * 43758.5453 % 1
|
||||
@@ -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)
|
||||
@@ -245,7 +293,8 @@ class GameCardAnimations:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(fill_brush)
|
||||
radius = 18 * self.game_card._scale
|
||||
radius = self.game_card.card_layout_cfg.get("border_radius", 18)
|
||||
radius *= self.game_card._scale
|
||||
bw = round(self.game_card._borderWidth / 2)
|
||||
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
|
||||
if rect.isEmpty():
|
||||
|
||||
+305
-119
@@ -1,35 +1,26 @@
|
||||
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.0"
|
||||
|
||||
__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,
|
||||
@@ -60,6 +51,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 +83,6 @@ def get_version():
|
||||
return __app_version__
|
||||
|
||||
|
||||
def stop_portproton_game(start_sh: list[str], logger: Logger) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
start_sh + ["cli", "--stop"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("Failed to execute PortProton stop command: %s", e)
|
||||
|
||||
|
||||
def get_portproton_tray_icon() -> QIcon:
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
icon = ThemeManager().get_icon("tray_portproton", ui_config.get_theme())
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
|
||||
"""Launch a game with 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 +140,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:
|
||||
@@ -290,6 +284,8 @@ def main():
|
||||
restore_prefix_path = None
|
||||
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)
|
||||
@@ -303,29 +299,45 @@ 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 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 args.file_or_url and is_launch_file(args.file_or_url):
|
||||
ipc_message = f"open:{normalize_launch_path(args.file_or_url)}"
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
elif args.file_or_url and is_autoinstall_file(args.file_or_url):
|
||||
autoinstall_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"autoinstall:{quote(autoinstall_path, safe='')}"
|
||||
elif args.file_or_url and args.file_or_url.startswith("gog://launch/"):
|
||||
app_id = args.file_or_url.removeprefix("gog://launch/")
|
||||
if app_id.isdigit():
|
||||
gog_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"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,
|
||||
@@ -343,7 +355,10 @@ def main():
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
|
||||
# --- Single-instance logic ---
|
||||
server_name = __app_id__
|
||||
runtime_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
|
||||
server_name = os.path.join(runtime_dir, __app_id__)
|
||||
instance_lock = QLockFile(server_name + ".lock")
|
||||
owns_instance = instance_lock.tryLock(0)
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
@@ -356,7 +371,11 @@ def main():
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Remove old socket if it exists
|
||||
if not owns_instance:
|
||||
logger.warning("PortProtonQt is already running but its IPC server is unavailable")
|
||||
return
|
||||
|
||||
# Only the lock owner can remove a stale socket.
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
@@ -380,7 +399,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:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_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)
|
||||
@@ -464,13 +483,79 @@ 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
|
||||
)
|
||||
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 = f"{source}://launch/{app_id}"
|
||||
if log_mode:
|
||||
window._pending_log_exe = 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],
|
||||
})
|
||||
|
||||
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 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:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe():
|
||||
window.handle_launch_exe(exe_path)
|
||||
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():
|
||||
@@ -484,6 +569,14 @@ def main():
|
||||
def handle_restore_prefix():
|
||||
window._perform_restore(restore_prefix_path)
|
||||
QTimer.singleShot(0, handle_restore_prefix)
|
||||
elif 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():
|
||||
@@ -501,35 +594,47 @@ def main():
|
||||
if (
|
||||
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:"):
|
||||
launch_path = msg[5:].strip()
|
||||
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 = 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)
|
||||
handle_game_request(launch_path, False, log_mode)
|
||||
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
|
||||
handle_game_request(unquote(launch_path), False, True)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -539,6 +644,18 @@ def main():
|
||||
window._perform_restore(backup_path)
|
||||
else:
|
||||
logger.warning("Invalid prefix backup via IPC: %s", backup_path)
|
||||
elif msg.startswith("gog:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id.isdigit():
|
||||
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:
|
||||
@@ -581,15 +698,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
|
||||
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")
|
||||
@@ -599,7 +721,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)
|
||||
@@ -646,13 +768,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):
|
||||
@@ -718,10 +862,15 @@ def main():
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
if start_sh:
|
||||
# Initial setup can stop Wine and must not race a silent game launch.
|
||||
if start_sh and not silent_game_request:
|
||||
worker = InitialCommandWorker(start_sh)
|
||||
window.initialCommandWorker = worker
|
||||
worker.finished.connect(
|
||||
lambda: setattr(window, "initialCommandWorker", None)
|
||||
)
|
||||
worker.start()
|
||||
else:
|
||||
elif not start_sh:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
# --- Cleanup ---
|
||||
@@ -736,7 +885,44 @@ def main():
|
||||
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
sys.exit(app.exec())
|
||||
exit_code = app.exec()
|
||||
if restart_target is not None:
|
||||
if window.game_processes:
|
||||
window._terminate_game_processes()
|
||||
window.resetPlayButton()
|
||||
window._stopBackgroundWorkers()
|
||||
try:
|
||||
children = psutil.Process().children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(children, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
for child in alive:
|
||||
try:
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(alive, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
if alive:
|
||||
logger.error("Cannot restart while child processes are still running")
|
||||
sys.exit(1)
|
||||
if start_sh:
|
||||
subprocess.run(start_sh + ["cli", "--stop"], check=True,
|
||||
timeout=RESTART_STOP_TIMEOUT_SECONDS)
|
||||
instance_lock.unlock()
|
||||
os.execv(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", restart_target])
|
||||
except OSError as error:
|
||||
logger.error("Failed to restart PortProtonQt: %s", error)
|
||||
exit_code = 1
|
||||
except psutil.Error as error:
|
||||
logger.error("Failed to stop PortProtonQt child processes: %s", error)
|
||||
exit_code = 1
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.error("Failed to stop PortProton before restart: %s", error)
|
||||
exit_code = 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""User-level AppImage desktop integration."""
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
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-dosexec;application/vnd.microsoft.portable-executable;"
|
||||
)
|
||||
INTEGRATION_MODES = ("log", "silent")
|
||||
|
||||
|
||||
def _find_appimage_asset(appdir: Path, names: tuple[str, ...]) -> Path | None:
|
||||
for name in names:
|
||||
path = appdir / name
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _copy_appimage(source: Path, destination: Path) -> None:
|
||||
if source.resolve() == destination.resolve():
|
||||
return
|
||||
temporary = destination.with_suffix(".appimage.part")
|
||||
shutil.copy2(source, temporary)
|
||||
temporary.chmod(EXECUTABLE_MODE)
|
||||
temporary.replace(destination)
|
||||
|
||||
|
||||
def _remove_existing_integration(applications_dir: Path, icon_dir: Path) -> None:
|
||||
desktop_names = (
|
||||
f"{APP_ID}.desktop",
|
||||
f"{APP_ID}.log.desktop",
|
||||
f"{APP_ID}.silent.desktop",
|
||||
)
|
||||
for name in desktop_names:
|
||||
(applications_dir / name).unlink(missing_ok=True)
|
||||
for suffix in (".svg", ".png"):
|
||||
(icon_dir / f"{APP_ID}{suffix}").unlink(missing_ok=True)
|
||||
|
||||
|
||||
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={APP_ID}", content, flags=re.MULTILINE)
|
||||
if re.search(r"^TryExec=", content, flags=re.MULTILINE):
|
||||
return re.sub(
|
||||
r"^TryExec=.*$",
|
||||
f"TryExec={appimage}",
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
return content.replace("Type=Application\n", f"Type=Application\nTryExec={appimage}\n", 1)
|
||||
|
||||
|
||||
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 (
|
||||
"[Desktop Entry]\n"
|
||||
f"Name=PortProtonQt — {action}\n"
|
||||
f"Exec={exec_line}\n"
|
||||
f"TryExec={appimage}\n"
|
||||
"Type=Application\n"
|
||||
f"Comment={action}\n"
|
||||
"Terminal=false\n"
|
||||
f"Icon={APP_ID}\n"
|
||||
f"StartupWMClass={APP_ID}\n"
|
||||
"Categories=Game;\n"
|
||||
"NoDisplay=true\n"
|
||||
f"MimeType={WINDOWS_MIME_TYPES}\n"
|
||||
"StartupNotify=true\n"
|
||||
)
|
||||
|
||||
|
||||
def _install_desktop_files(
|
||||
source: Path,
|
||||
applications_dir: Path,
|
||||
appimage: 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(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), encoding="utf-8")
|
||||
|
||||
|
||||
def _set_default_mime_handlers() -> None:
|
||||
desktop_id = f"{APP_ID}.desktop"
|
||||
for mime_type in WINDOWS_MIME_TYPES.rstrip(";").split(";"):
|
||||
try:
|
||||
subprocess.run(
|
||||
["xdg-mime", "default", desktop_id, mime_type],
|
||||
check=True,
|
||||
timeout=DESKTOP_DATABASE_TIMEOUT,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
logger.warning("Failed to set default MIME handler: %s", error)
|
||||
return
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.warning("Failed to set default handler for %s: %s", mime_type, error)
|
||||
|
||||
|
||||
def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
|
||||
desktop = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
f"{APP_ID}.desktop",
|
||||
f"usr/share/applications/{APP_ID}.desktop",
|
||||
f"share/applications/{APP_ID}.desktop",
|
||||
),
|
||||
)
|
||||
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()
|
||||
appdir = Path(os.environ["APPDIR"]).expanduser()
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError("AppImage desktop metadata is incomplete")
|
||||
desktop_source, icon_source = _get_appimage_metadata(appdir)
|
||||
|
||||
appimages_dir = Path.home() / "AppImages"
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
applications_dir = data_home / "applications"
|
||||
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}.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)
|
||||
_install_mime_package(appdir, data_home)
|
||||
_set_default_mime_handlers()
|
||||
try:
|
||||
subprocess.run(
|
||||
["update-desktop-database", str(applications_dir)],
|
||||
check=False,
|
||||
timeout=DESKTOP_DATABASE_TIMEOUT,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class AppImageIntegrationWorker(QThread):
|
||||
"""Install AppImage integration outside the UI thread."""
|
||||
|
||||
completed = Signal(bool, str)
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
destination = integrate_appimage()
|
||||
self.completed.emit(True, str(destination))
|
||||
except (OSError, subprocess.SubprocessError, KeyError) as error:
|
||||
logger.error("Failed to integrate AppImage: %s", error)
|
||||
self.completed.emit(False, str(error))
|
||||
@@ -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)
|
||||
+296
-1
@@ -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",
|
||||
@@ -69,6 +91,11 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Launch supported Windows file in tray without showing the main window"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log",
|
||||
action="store_true",
|
||||
help="Enable PortProton logging for the launched Windows file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore-prefix",
|
||||
action="store_true",
|
||||
@@ -101,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.
|
||||
@@ -322,8 +615,10 @@ def remove_steam_compat_tool() -> bool:
|
||||
def clear_cache() -> bool:
|
||||
"""Clear PortProtonQt cache."""
|
||||
from portprotonqt.config import cache_config
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
|
||||
cache_config.clear_cache()
|
||||
GOGAPI().clear_library_cache()
|
||||
print("PortProtonQt cache cleared")
|
||||
return True
|
||||
|
||||
@@ -428,7 +723,7 @@ def is_autoinstall_file(path: str) -> bool:
|
||||
def parse_portproton_url(url: str) -> str | None:
|
||||
"""Parse a portproton:// URL to extract the full download URL.
|
||||
|
||||
Expected format: portproton://https//ppdb.linux-gaming.ru/api/games/130127/ppdb/download
|
||||
Expected format: portproton://https//linux-gaming.ru/api/games/130127/ppdb/download
|
||||
|
||||
Args:
|
||||
url: The portproton:// URL to parse
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
# Copyright 2026 mirkobrombin <brombin94@gmail.com>
|
||||
# Adapted from the Bottles compatibility analyser under GPL-3.0.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import pefile
|
||||
import psutil
|
||||
|
||||
from portprotonqt.debug_utils import (
|
||||
get_portproton_env,
|
||||
get_prefix_name,
|
||||
get_selectable_gpu_entries,
|
||||
get_vulkan_use_info,
|
||||
get_wine_version,
|
||||
get_winetricks_log,
|
||||
)
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.scripts_utils.graphics_detector import analyze_executable
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CRASH_THRESHOLD_SECONDS = 7
|
||||
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
|
||||
PE_MACHINE_AMD64 = 0x8664
|
||||
COMPATIBILITY_SCAN_BYTES = 4_194_304
|
||||
COMPATIBILITY_NEIGHBOR_SCAN_LIMIT = 20
|
||||
COMPATIBILITY_RULES_PATH = Path(__file__).with_name("compatibility_rules.json")
|
||||
I586_ELF_LOADER = "/lib/ld-linux.so.2"
|
||||
DXVK_VULKAN_REQUIREMENTS = {
|
||||
"6": ("Newest", "DXVK_NEW_VER", (1, 4)),
|
||||
"2": ("Stable", "DXVK_OLD_VER", (1, 3)),
|
||||
"1": ("Sarek", "DXVK_SAREK_VER", (1, 1)),
|
||||
}
|
||||
DXVK_DRIVER_REQUIREMENTS = {
|
||||
"0x1002": ("AMD RADV", (25, 0), "driver_version"),
|
||||
"0x10de": ("NVIDIA", (550, 54, 14), "driver_info"),
|
||||
"0x8086": ("Intel ANV", (25, 1), "driver_version"),
|
||||
}
|
||||
DXVK_NVK_REQUIREMENT = ("NVIDIA NVK", (25, 1), "driver_version")
|
||||
DXVK_NEWEST_NVIDIA_REQUIREMENT = ("NVIDIA", (575, 51, 2), "driver_info")
|
||||
VCRUN_COMPONENTS = {
|
||||
"Visual C++ 2010": ("vcrun2010",),
|
||||
"Visual C++ 2012": ("vcrun2012",),
|
||||
"Visual C++ 2013": ("vcrun2013",),
|
||||
"Visual C++ 2015-2022": ("vcrun2015", "vcrun2017", "vcrun2019", "vcrun2022"),
|
||||
}
|
||||
|
||||
DLL_MAPPINGS = {
|
||||
"mscoree.dll": (".NET Framework", "Runtimes"),
|
||||
"hostfxr.dll": (".NET Core/5+", "Runtimes"),
|
||||
"coreclr.dll": (".NET Core/5+", "Runtimes"),
|
||||
"mono-2.0-bdwgc.dll": ("Mono", "Runtimes"),
|
||||
"jvm.dll": ("Java", "Runtimes"),
|
||||
"python3.dll": ("Python", "Runtimes"),
|
||||
"msvcp100.dll": ("Visual C++ 2010", "Runtimes"),
|
||||
"msvcp110.dll": ("Visual C++ 2012", "Runtimes"),
|
||||
"msvcp120.dll": ("Visual C++ 2013", "Runtimes"),
|
||||
"msvcp140.dll": ("Visual C++ 2015-2022", "Runtimes"),
|
||||
"vcruntime140.dll": ("Visual C++ 2015-2022", "Runtimes"),
|
||||
"unityplayer.dll": ("Unity", "Engines"),
|
||||
"gameassembly.dll": ("Unity IL2CPP", "Engines"),
|
||||
"cryrenderd3d11.dll": ("CryEngine", "Engines"),
|
||||
"tier0.dll": ("Source", "Engines"),
|
||||
"libcef.dll": ("Chromium Embedded Framework", "Frameworks"),
|
||||
"electron.dll": ("Electron", "Frameworks"),
|
||||
"qt5core.dll": ("Qt 5", "Frameworks"),
|
||||
"qt6core.dll": ("Qt 6", "Frameworks"),
|
||||
"xinput1_3.dll": ("XInput 1.3", "Input"),
|
||||
"dinput8.dll": ("DirectInput", "Input"),
|
||||
"sdl2.dll": ("SDL2", "Input"),
|
||||
"xaudio2_7.dll": ("XAudio 2.7", "Audio"),
|
||||
"fmod.dll": ("FMOD", "Audio"),
|
||||
"fmod64.dll": ("FMOD", "Audio"),
|
||||
"binkw64.dll": ("Bink", "Audio"),
|
||||
"openal32.dll": ("OpenAL", "Audio"),
|
||||
"steam_api64.dll": ("Steamworks", "Social/DRM"),
|
||||
"galaxy64.dll": ("GOG Galaxy", "Social/DRM"),
|
||||
"eossdk-win64-shipping.dll": ("Epic Online Services", "Social/DRM"),
|
||||
"easyanticheat_x64.dll": ("Easy Anti-Cheat", "Protection"),
|
||||
"beclient_x64.dll": ("BattlEye", "Protection"),
|
||||
"vmprotectsdk64.dll": ("VMProtect", "Protection"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompatibilityLaunch:
|
||||
executable: str
|
||||
exit_code: int | None
|
||||
duration: float
|
||||
|
||||
|
||||
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 (
|
||||
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"):
|
||||
for entry in getattr(pe, directory_name, []):
|
||||
try:
|
||||
imports.add(entry.dll.decode("utf-8", errors="ignore").lower())
|
||||
except AttributeError:
|
||||
continue
|
||||
return imports
|
||||
|
||||
|
||||
def _load_rules() -> list[dict[str, Any]]:
|
||||
try:
|
||||
data = orjson.loads(COMPATIBILITY_RULES_PATH.read_bytes())
|
||||
except (OSError, orjson.JSONDecodeError) as error:
|
||||
logger.error("Compatibility report failed to load signature rules: %s", error)
|
||||
return []
|
||||
rules = data.get("rules", [])
|
||||
return rules if isinstance(rules, list) else []
|
||||
|
||||
|
||||
def _matched_pattern_ids(
|
||||
data: bytes,
|
||||
lowered: bytes,
|
||||
flattened_wide: bytes,
|
||||
patterns: list[dict[str, str]],
|
||||
) -> set[str]:
|
||||
matched = set()
|
||||
for pattern in patterns:
|
||||
identifier = pattern.get("id", "")
|
||||
text = pattern.get("text")
|
||||
if text is not None:
|
||||
needle = text.encode("utf-8", errors="ignore").lower()
|
||||
if needle in lowered or needle in flattened_wide:
|
||||
matched.add(identifier)
|
||||
continue
|
||||
expression = pattern.get("regex")
|
||||
if expression and re.search(expression.encode(), data, re.IGNORECASE):
|
||||
matched.add(identifier)
|
||||
return matched
|
||||
|
||||
|
||||
def _scan_file(
|
||||
file_path: str, rules: list[dict[str, Any]], source: str
|
||||
) -> list[dict[str, str]]:
|
||||
try:
|
||||
with open(file_path, "rb") as source_file:
|
||||
data = source_file.read(COMPATIBILITY_SCAN_BYTES)
|
||||
except OSError as error:
|
||||
logger.warning("Compatibility report failed to scan %s: %s", file_path, error)
|
||||
return []
|
||||
data = os.path.basename(file_path).encode(errors="ignore") + b"\n" + data
|
||||
lowered = data.lower()
|
||||
flattened_wide = lowered.replace(b"\x00", b"")
|
||||
findings = []
|
||||
for rule in rules:
|
||||
matched = _matched_pattern_ids(
|
||||
data,
|
||||
lowered,
|
||||
flattened_wide,
|
||||
rule.get("patterns", []),
|
||||
)
|
||||
required_sets = rule.get("required_sets", [])
|
||||
if not any(set(required).issubset(matched) for required in required_sets):
|
||||
continue
|
||||
findings.append({
|
||||
"category": str(rule.get("category", "Unknown")),
|
||||
"name": str(rule.get("name", rule.get("id", "Unknown"))),
|
||||
"description": str(rule.get("description", "")),
|
||||
"severity": str(rule.get("severity", "info")),
|
||||
"source": source,
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _scan_installer(
|
||||
installer_path: str, rules: list[dict[str, Any]]
|
||||
) -> list[dict[str, str]]:
|
||||
seven_zip = shutil.which("7z") or shutil.which("7za") or shutil.which("7zr")
|
||||
if seven_zip is None:
|
||||
return []
|
||||
findings = []
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="portprotonqt-compatibility-") as extract_dir:
|
||||
result = subprocess.run(
|
||||
[seven_zip, "x", "-y", f"-o{extract_dir}", installer_path],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("Compatibility report could not extract installer %s", installer_path)
|
||||
return []
|
||||
extracted = []
|
||||
for root, _directories, files in os.walk(extract_dir):
|
||||
extracted.extend(
|
||||
os.path.join(root, name)
|
||||
for name in files
|
||||
if name.lower().endswith((".exe", ".dll", ".msi", ".js", ".json", ".node"))
|
||||
)
|
||||
for file_path in extracted[:COMPATIBILITY_NEIGHBOR_SCAN_LIMIT]:
|
||||
findings.extend(
|
||||
_scan_file(file_path, rules, f"Installer: {os.path.basename(file_path)}")
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
logger.warning("Compatibility report installer scan failed for %s: %s", installer_path, error)
|
||||
return findings
|
||||
|
||||
|
||||
def _neighbor_files(executable: str) -> list[str]:
|
||||
executable_dir = os.path.realpath(os.path.dirname(executable))
|
||||
unsafe_dirs = {
|
||||
os.path.realpath(str(Path.home())),
|
||||
*(os.path.realpath(str(Path.home() / name))
|
||||
for name in ("Downloads", "Desktop", "Documents", "Templates")),
|
||||
}
|
||||
if executable_dir in unsafe_dirs:
|
||||
return []
|
||||
try:
|
||||
neighbors = [
|
||||
os.path.join(executable_dir, name)
|
||||
for name in os.listdir(executable_dir)
|
||||
if name.lower().endswith(".dll")
|
||||
]
|
||||
except OSError:
|
||||
return []
|
||||
executable_base = os.path.splitext(os.path.basename(executable))[0]
|
||||
plugins_dir = os.path.join(executable_dir, f"{executable_base}_Data", "Plugins")
|
||||
if os.path.isdir(plugins_dir):
|
||||
for root, _directories, files in os.walk(plugins_dir):
|
||||
neighbors.extend(
|
||||
os.path.join(root, name) for name in files if name.lower().endswith(".dll")
|
||||
)
|
||||
return neighbors[:COMPATIBILITY_NEIGHBOR_SCAN_LIMIT]
|
||||
|
||||
|
||||
def _add_directory_findings(executable: str, findings: dict[str, list[str]]) -> None:
|
||||
executable_dir = os.path.dirname(executable)
|
||||
executable_base = os.path.splitext(os.path.basename(executable))[0]
|
||||
markers = (
|
||||
("Engine", "Unreal", "Engines"),
|
||||
(f"{executable_base}_Data", "Unity", "Engines"),
|
||||
("renpy", "Ren'Py", "Engines"),
|
||||
("www", "RPG Maker", "Engines"),
|
||||
("resources/app.asar", "Electron", "Frameworks"),
|
||||
(f"{executable_base}.pck", "Godot", "Engines"),
|
||||
(f"{executable_base}.runtimeconfig.json", ".NET Core/5+", "Runtimes"),
|
||||
(f"{executable_base}.deps.json", ".NET Core/5+", "Runtimes"),
|
||||
)
|
||||
for relative_path, name, category in markers:
|
||||
if os.path.exists(os.path.join(executable_dir, relative_path)):
|
||||
values = findings.setdefault(category, [])
|
||||
if name not in values:
|
||||
values.append(name)
|
||||
|
||||
|
||||
def _analyze_pe(executable: str) -> tuple[str, dict[str, list[str]]]:
|
||||
findings: dict[str, list[str]] = {}
|
||||
try:
|
||||
pe = pefile.PE(executable, fast_load=True)
|
||||
pe.parse_data_directories(
|
||||
directories=[
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"],
|
||||
]
|
||||
)
|
||||
except (OSError, pefile.PEFormatError) as error:
|
||||
logger.warning("Compatibility report could not parse %s: %s", executable, error)
|
||||
return "Unknown", findings
|
||||
|
||||
machine = getattr(pe.FILE_HEADER, "Machine", None)
|
||||
architecture = "64-bit" if machine == PE_MACHINE_AMD64 else "32-bit"
|
||||
imports = _read_imports(pe)
|
||||
for dll_name, (name, category) in DLL_MAPPINGS.items():
|
||||
if dll_name in imports and name not in findings.setdefault(category, []):
|
||||
findings[category].append(name)
|
||||
try:
|
||||
optional_header = getattr(pe, "OPTIONAL_HEADER", None)
|
||||
data_directories = getattr(optional_header, "DATA_DIRECTORY", [])
|
||||
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):
|
||||
pass
|
||||
_add_directory_findings(executable, findings)
|
||||
return architecture, findings
|
||||
|
||||
|
||||
def _apply_signature_findings(
|
||||
findings: dict[str, list[str]], signature_findings: list[dict[str, str]]
|
||||
) -> None:
|
||||
for item in signature_findings:
|
||||
values = findings.setdefault(item["category"], [])
|
||||
if item["name"] not in values:
|
||||
values.append(item["name"])
|
||||
|
||||
|
||||
def _uses_integrated_gpu(environment: dict[str, str]) -> bool:
|
||||
selected_gpu = environment.get("PW_GPU_USE", "")
|
||||
if not selected_gpu or selected_gpu == "disabled":
|
||||
return False
|
||||
return any(
|
||||
entry["device_name"] == selected_gpu
|
||||
and entry.get("device_type") == "INTEGRATED_GPU"
|
||||
for entry in get_selectable_gpu_entries()
|
||||
)
|
||||
|
||||
|
||||
def _version_at_least(version: str, required: tuple[int, ...]) -> bool:
|
||||
parts = tuple(int(part) for part in re.findall(r"\d+", version)[:len(required)])
|
||||
normalized = parts + (0,) * (len(required) - len(parts))
|
||||
return bool(parts) and normalized >= required
|
||||
|
||||
|
||||
def _selected_gpu(environment: dict[str, str]) -> dict[str, str] | None:
|
||||
entries = get_selectable_gpu_entries()
|
||||
selected_name = environment.get("PW_GPU_USE", "")
|
||||
if selected_name and selected_name != "disabled":
|
||||
return next(
|
||||
(entry for entry in entries if entry["device_name"] == selected_name),
|
||||
None,
|
||||
)
|
||||
return entries[0] if entries else None
|
||||
|
||||
|
||||
def _dxvk_driver_requirement(
|
||||
gpu: dict[str, str], backend: str,
|
||||
) -> tuple[str, tuple[int, ...], str] | None:
|
||||
vendor_id = gpu.get("vendor_id", "").lower()
|
||||
driver_name = gpu.get("driver_name", "").lower()
|
||||
if vendor_id == "0x10de" and "nvk" in driver_name:
|
||||
return DXVK_NVK_REQUIREMENT
|
||||
if vendor_id == "0x10de" and backend == "6":
|
||||
return DXVK_NEWEST_NVIDIA_REQUIREMENT
|
||||
return DXVK_DRIVER_REQUIREMENTS.get(vendor_id)
|
||||
|
||||
|
||||
def _dxvk_vulkan_compatibility(
|
||||
environment: dict[str, str],
|
||||
) -> tuple[str | None, list[str]]:
|
||||
backend = environment.get("PW_VULKAN_USE", "")
|
||||
requirement = DXVK_VULKAN_REQUIREMENTS.get(backend)
|
||||
if requirement is None:
|
||||
return None, []
|
||||
backend_name, version_key, required_vulkan = requirement
|
||||
dxvk_version = environment.get(version_key, "unknown")
|
||||
gpu = _selected_gpu(environment)
|
||||
if gpu is None:
|
||||
return f"{backend_name} DXVK {dxvk_version}: Vulkan version unknown", []
|
||||
|
||||
api_version = gpu.get("api_version", "unknown")
|
||||
required_api = ".".join(map(str, required_vulkan))
|
||||
details = f"{backend_name} DXVK {dxvk_version}: Vulkan {api_version} (requires {required_api}+)"
|
||||
suggestions = []
|
||||
if not _version_at_least(api_version, required_vulkan):
|
||||
suggestions.append(
|
||||
f"Update the graphics driver: {backend_name} DXVK requires Vulkan {required_api}+."
|
||||
)
|
||||
if backend != "1" and _version_at_least(api_version, (1, 1)):
|
||||
suggestions.append("Switch to Sarek for Vulkan 1.1 drivers.")
|
||||
|
||||
driver_requirement = _dxvk_driver_requirement(gpu, backend)
|
||||
if backend not in {"6", "2"} or driver_requirement is None:
|
||||
return details, suggestions
|
||||
driver_name, required_driver, version_field = driver_requirement
|
||||
driver_version = gpu.get(version_field, "unknown")
|
||||
required_version = ".".join(map(str, required_driver))
|
||||
details += f"; {driver_name} {driver_version} (requires {required_version}+)"
|
||||
if not _version_at_least(driver_version, required_driver):
|
||||
fallback = "Stable DXVK" if backend == "6" else "Sarek"
|
||||
suggestions.append(
|
||||
f"Update {driver_name} to {required_version}+ or switch to {fallback}."
|
||||
)
|
||||
return details, suggestions
|
||||
|
||||
|
||||
def has_dxvk_vulkan_incompatibility(executable: str) -> bool:
|
||||
"""Return whether configured DXVK cannot run on the selected GPU."""
|
||||
environment = get_portproton_env(executable)
|
||||
_details, suggestions = _dxvk_vulkan_compatibility(environment)
|
||||
return bool(suggestions)
|
||||
|
||||
|
||||
def _glibc_32_compatibility() -> tuple[str, list[str]]:
|
||||
if os.path.isfile(I586_ELF_LOADER):
|
||||
return "available", []
|
||||
return (
|
||||
f"unavailable (missing {I586_ELF_LOADER})",
|
||||
[
|
||||
"Install or enable 32-bit glibc with its ELF loader."
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _compatibility_suggestions(
|
||||
findings: dict[str, list[str]],
|
||||
graphics: str,
|
||||
shipping_executable: str,
|
||||
environment: dict[str, str],
|
||||
) -> list[str]:
|
||||
suggestions = []
|
||||
if shipping_executable:
|
||||
suggestions.append(f"Try launching the game binary instead: {shipping_executable}")
|
||||
uses_directx_8_11 = any(
|
||||
version in graphics
|
||||
for version in ("DirectX 8", "DirectX 9", "DirectX 10", "DirectX 11")
|
||||
)
|
||||
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", [])
|
||||
prefix_name = environment.get("PW_PREFIX_NAME", "DEFAULT")
|
||||
needs_dotnet = any(".NET" in runtime for runtime in runtimes)
|
||||
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]
|
||||
for runtime, components in VCRUN_COMPONENTS.items()
|
||||
if runtime in runtimes
|
||||
and not any(component in winetricks_log for component in components)
|
||||
]
|
||||
if missing_vcrun:
|
||||
suggestions.append(
|
||||
f"Install {', '.join(missing_vcrun)} through Winetricks."
|
||||
)
|
||||
if "PhysX" in findings.get("Physics", []) and "physx" not in winetricks_log:
|
||||
suggestions.append("Install physx through Winetricks.")
|
||||
return list(dict.fromkeys(suggestions))
|
||||
|
||||
|
||||
def _format_signature_details(items: list[dict[str, str]]) -> list[str]:
|
||||
lines = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
key = (item["category"], item["name"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
severity = item["severity"].upper()
|
||||
lines.append(
|
||||
f"- [{severity}] {item['category']}: {item['name']} "
|
||||
f"({item['source']}) — {item['description']}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def analyze_launch(launch: CompatibilityLaunch, portproton_path: str) -> str:
|
||||
"""Build a local compatibility report for a failed launch."""
|
||||
architecture, findings = _analyze_pe(launch.executable)
|
||||
rules = _load_rules()
|
||||
signature_findings = _scan_file(launch.executable, rules, "Main executable")
|
||||
for neighbor in _neighbor_files(launch.executable):
|
||||
signature_findings.extend(_scan_file(neighbor, rules, f"Neighbor: {os.path.basename(neighbor)}"))
|
||||
has_installer = launch.executable.lower().endswith(".msi") or any(
|
||||
item["category"] == "Installer" for item in signature_findings
|
||||
)
|
||||
if has_installer:
|
||||
signature_findings.extend(_scan_installer(launch.executable, rules))
|
||||
_apply_signature_findings(findings, signature_findings)
|
||||
graphics = analyze_executable(launch.executable)
|
||||
graphics_executable = str(graphics.get("source", launch.executable))
|
||||
graphics_name = os.path.basename(graphics_executable).lower()
|
||||
shipping_executable = (
|
||||
graphics_executable if graphics_name.endswith(("-win64-shipping.exe", "-win32-shipping.exe"))
|
||||
else ""
|
||||
)
|
||||
detected_graphics = str(graphics["highest_directx"])
|
||||
if graphics["uses_opengl"]:
|
||||
detected_graphics = f"{detected_graphics}, OpenGL"
|
||||
environment = get_portproton_env(launch.executable)
|
||||
prefix_name = get_prefix_name(launch.executable)
|
||||
environment.setdefault("PW_PREFIX_NAME", prefix_name)
|
||||
environment["PW_WINETRICKS_LOG"] = get_winetricks_log(
|
||||
portproton_path, environment["PW_PREFIX_NAME"]
|
||||
)
|
||||
suggestions = _compatibility_suggestions(
|
||||
findings,
|
||||
detected_graphics,
|
||||
shipping_executable,
|
||||
environment,
|
||||
)
|
||||
dxvk_compatibility, version_suggestions = _dxvk_vulkan_compatibility(environment)
|
||||
suggestions.extend(version_suggestions)
|
||||
glibc_32_compatibility, glibc_32_suggestions = _glibc_32_compatibility()
|
||||
suggestions.extend(glibc_32_suggestions)
|
||||
lines = [
|
||||
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",
|
||||
f"Architecture: {architecture}",
|
||||
f"Prefix: {prefix_name}",
|
||||
f"Wine/Proton: {get_wine_version(portproton_path, launch.executable)}",
|
||||
f"Configured 3D API: {get_vulkan_use_info(portproton_path, launch.executable)}",
|
||||
f"Detected 3D API: {detected_graphics}",
|
||||
f"32-bit glibc: {glibc_32_compatibility}",
|
||||
]
|
||||
if dxvk_compatibility:
|
||||
lines.append(f"DXVK/Vulkan compatibility: {dxvk_compatibility}")
|
||||
if graphics_executable != launch.executable:
|
||||
lines.append(f"3D API source: {graphics_executable}")
|
||||
if environment.get("PW_WINDOWS_VER"):
|
||||
lines.append(f"Windows version: {environment['PW_WINDOWS_VER']}")
|
||||
for category, values in findings.items():
|
||||
if values:
|
||||
lines.append(f"Detected {category}: {', '.join(values)}")
|
||||
detail_lines = _format_signature_details(signature_findings)
|
||||
if detail_lines:
|
||||
lines.extend(("", "Detection details:", *detail_lines))
|
||||
if suggestions:
|
||||
lines.extend(("", "Possible fixes:", *(f"- {item}" for item in suggestions)))
|
||||
return "\n".join(lines)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,12 +24,14 @@ from portprotonqt.config.portproton import (
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
find_game_by_exe,
|
||||
get_custom_data_dir_name,
|
||||
get_portproton_location,
|
||||
get_portproton_scripts_path,
|
||||
get_portproton_start_command,
|
||||
migrate_legacy_shortcut,
|
||||
parse_desktop_entry,
|
||||
read_portdata_path_from_config,
|
||||
resolve_custom_data_dir,
|
||||
save_portdata_path_to_config,
|
||||
)
|
||||
from portprotonqt.config.proxy import ProxyConfig
|
||||
@@ -95,6 +97,8 @@ __all__ = [
|
||||
"parse_desktop_entry",
|
||||
"extract_exec_target_path",
|
||||
"find_game_by_exe",
|
||||
"get_custom_data_dir_name",
|
||||
"resolve_custom_data_dir",
|
||||
"create_desktop_file",
|
||||
"consume_download_counter_skip",
|
||||
"reset_main_config",
|
||||
|
||||
@@ -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):
|
||||
@@ -25,3 +25,21 @@ class GameConfig(BaseConfig):
|
||||
"""Set game display filter."""
|
||||
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")
|
||||
|
||||
def set_steam_account_id(self, account_id: str) -> None:
|
||||
"""Set selected Steam account ID or automatic selection."""
|
||||
validate_string(account_id, "steam_account_id", min_len=1, max_len=20)
|
||||
self._save_value("steam_account_id", account_id, "str")
|
||||
@@ -1,5 +1,6 @@
|
||||
"""PortProton configuration and launch helpers."""
|
||||
import configparser
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
@@ -163,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():
|
||||
@@ -222,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:]
|
||||
@@ -273,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("~")
|
||||
@@ -292,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"),
|
||||
@@ -433,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"]
|
||||
@@ -504,13 +561,43 @@ def find_game_by_exe(exe_path: str) -> configparser.SectionProxy | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_custom_data_dir_name(exe_path: str) -> str:
|
||||
"""Return a collision-safe custom data directory name for an executable."""
|
||||
normalized_path = os.path.normpath(os.path.abspath(os.path.expanduser(exe_path)))
|
||||
path_hash = hashlib.md5(
|
||||
normalized_path.encode("utf-8"), usedforsecurity=False
|
||||
).hexdigest()
|
||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
return f"{exe_name}_{path_hash}"
|
||||
|
||||
|
||||
def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
|
||||
"""Return hashed custom data directory, falling back to the legacy name."""
|
||||
hashed_dir = os.path.join(custom_data_root, get_custom_data_dir_name(exe_path))
|
||||
legacy_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
legacy_dir = os.path.join(custom_data_root, legacy_name)
|
||||
if not os.path.isdir(hashed_dir) and os.path.isdir(legacy_dir):
|
||||
return legacy_dir
|
||||
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,
|
||||
icon_source: str | None = None,
|
||||
) -> tuple[str, str, str] | None:
|
||||
"""Create desktop entry content, destination path and icon path for a game."""
|
||||
portproton_path = get_portproton_location()
|
||||
if 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:
|
||||
@@ -519,22 +606,34 @@ 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)
|
||||
icon_path = os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
has_icon_source = bool(icon_source and os.path.isfile(icon_source))
|
||||
icon_path = (
|
||||
"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")
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
if icon_path != "applications-games":
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
|
||||
flatpak_id = os.getenv("FLATPAK_ID")
|
||||
appimage_path = os.getenv("APPIMAGE", "").strip()
|
||||
if flatpak_id:
|
||||
exec_str = f'flatpak run {flatpak_id} --silent "{exe_path}"'
|
||||
log_exec = f'flatpak run {flatpak_id} --log "{exe_path}"'
|
||||
elif appimage_path and os.path.isfile(appimage_path):
|
||||
exec_str = shlex.join([appimage_path, "--silent", exe_path])
|
||||
log_exec = shlex.join([appimage_path, "--log", exe_path])
|
||||
else:
|
||||
exec_str = f'portprotonqt --silent "{exe_path}"'
|
||||
log_exec = f'portprotonqt --log "{exe_path}"'
|
||||
|
||||
comment = _('Launch "{name}" with PortProton').format(name=game_name)
|
||||
silent_name = _("Run in silent mode")
|
||||
log_name = _("Run in logging mode")
|
||||
desktop_entry = (
|
||||
"[Desktop Entry]\n"
|
||||
f"Name={game_name}\n"
|
||||
@@ -545,5 +644,14 @@ def create_desktop_file(
|
||||
"Categories=Game;\n"
|
||||
"StartupNotify=true\n"
|
||||
f"Icon={icon_path}\n"
|
||||
"Actions=RunSilent;RunLog;\n"
|
||||
"\n[Desktop Action RunSilent]\n"
|
||||
f"Name={silent_name}\n"
|
||||
f"Exec={exec_str}\n"
|
||||
f"Icon={icon_path}\n"
|
||||
"\n[Desktop Action RunLog]\n"
|
||||
f"Name={log_name}\n"
|
||||
f"Exec={log_exec}\n"
|
||||
f"Icon={icon_path}\n"
|
||||
)
|
||||
return desktop_entry, desktop_path, icon_path
|
||||
@@ -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."""
|
||||
@@ -446,6 +501,42 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "enable_theme_store")
|
||||
self._save_value("enable_theme_store", enabled, "bool")
|
||||
|
||||
def get_sounds_enabled(self) -> bool:
|
||||
"""Get UI sounds enabled setting."""
|
||||
return self._get_bool("sounds_enabled", True)
|
||||
|
||||
def set_sounds_enabled(self, enabled: bool):
|
||||
"""Set UI sounds enabled setting."""
|
||||
validate_bool(enabled, "sounds_enabled")
|
||||
self._save_value("sounds_enabled", enabled, "bool")
|
||||
|
||||
def get_crash_reports_enabled(self) -> bool:
|
||||
"""Get compatibility reports after game crashes setting."""
|
||||
return self._get_bool("crash_reports_enabled", True)
|
||||
|
||||
def set_crash_reports_enabled(self, enabled: bool) -> None:
|
||||
"""Set compatibility reports after game crashes setting."""
|
||||
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."""
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+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:
|
||||
|
||||
@@ -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,
|
||||
@@ -174,8 +175,13 @@ def get_selectable_gpu_entries() -> list[dict[str, str]]:
|
||||
device_id = gpu_info.get("device_id", "").strip()
|
||||
entry = {
|
||||
"device_name": device_name,
|
||||
"device_type": device_type,
|
||||
"vendor_id": vendor_id,
|
||||
"device_id": device_id,
|
||||
"driver_name": gpu_info.get("driver_name", "").strip(),
|
||||
"driver_info": gpu_info.get("driver_info", "").strip(),
|
||||
"driver_version": gpu_info.get("driver_version", "").strip(),
|
||||
"api_version": gpu_info.get("api_version", "").strip(),
|
||||
}
|
||||
|
||||
if device_type == "DISCRETE_GPU":
|
||||
|
||||
@@ -322,7 +322,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)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Detail pages for PortProtonQt."""
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
import weakref
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from shiboken6 import isValid
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QLabel,
|
||||
@@ -13,7 +16,7 @@ from PySide6.QtWidgets import (
|
||||
QBoxLayout,
|
||||
QSizePolicy,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from portprotonqt.detail_pages.widgets import (
|
||||
@@ -52,7 +55,14 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.animations import DetailPageAnimations
|
||||
from portprotonqt.debug_utils import DebugLogManager
|
||||
from portprotonqt.image_utils import cleanup_widget_animated_covers
|
||||
from portprotonqt.steam_api import get_steam_compat_tool, get_steam_home, get_steam_libs, safe_vdf_load
|
||||
from portprotonqt.icon_extractor import get_exe_icon_cache_path
|
||||
from portprotonqt.steam_api import (
|
||||
get_local_steam_cover,
|
||||
get_steam_compat_tool,
|
||||
get_steam_home,
|
||||
get_steam_libs,
|
||||
safe_vdf_load,
|
||||
)
|
||||
from portprotonqt.time_utils import format_playtime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -73,11 +83,21 @@ class DetailPageManager:
|
||||
self._autoinstall_status_controls = None
|
||||
self._current_detail_source: tuple[str, dict] | None = None
|
||||
self._resize_rebuild_pending = False
|
||||
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)
|
||||
detail_page.setProperty("fallbackExe", fallback_exe)
|
||||
detail_page.setProperty("fallbackIconPath", fallback_icon_path)
|
||||
@@ -127,19 +147,7 @@ class DetailPageManager:
|
||||
return "", ""
|
||||
if not exe_path.lower().endswith(".exe"):
|
||||
return "", ""
|
||||
xdg_cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache"),
|
||||
)
|
||||
game_name = os.path.basename(game_data.get("name", ""))
|
||||
icon_name = game_name or os.path.splitext(os.path.basename(exe_path))[0]
|
||||
icon_path = os.path.join(
|
||||
xdg_cache_home,
|
||||
"PortProtonQt",
|
||||
"images",
|
||||
f"{icon_name}.png",
|
||||
)
|
||||
return exe_path, icon_path
|
||||
return exe_path, get_exe_icon_cache_path(exe_path)
|
||||
|
||||
def _get_favorite_icon_name(self, name: str) -> str:
|
||||
return "star_fav_full" if name in favorites_config.get_games() else "star_fav"
|
||||
@@ -174,6 +182,15 @@ class DetailPageManager:
|
||||
buttons_layout: FlowLayout,
|
||||
) -> dict:
|
||||
cover_frame, image_label = cover_widgets
|
||||
cover_path = game_data.get("cover_path")
|
||||
if str(game_data.get("game_source", "")).lower() == "steam":
|
||||
appid = game_data.get("appid", "")
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", str(appid))
|
||||
if os.path.dirname(os.path.abspath(cover_path or "")) != custom_dir:
|
||||
cover_path = get_local_steam_cover(appid) or cover_path
|
||||
return {
|
||||
"cover_frame": cover_frame,
|
||||
"image_label": image_label,
|
||||
@@ -182,7 +199,7 @@ class DetailPageManager:
|
||||
"buttons_layout": buttons_layout,
|
||||
"name": game_data.get("name", ""),
|
||||
"exec_line": game_data.get("exec_line", ""),
|
||||
"cover_path": game_data.get("cover_path"),
|
||||
"cover_path": cover_path,
|
||||
}
|
||||
|
||||
def _is_compact_detail_layout(self) -> bool:
|
||||
@@ -254,7 +271,8 @@ class DetailPageManager:
|
||||
|
||||
def _create_protondb_badge(self, parent: QWidget, game_data: dict) -> dict | None:
|
||||
badge, visible = create_protondb_badge(
|
||||
parent, game_data.get("protondb_tier", ""), game_data.get("appid", ""),
|
||||
parent, game_data.get("protondb_tier", ""),
|
||||
game_data.get("protondb_appid", game_data.get("appid", "")),
|
||||
self.main_window
|
||||
)
|
||||
return {"label": badge, "visible": True} if badge and visible else None
|
||||
@@ -297,12 +315,167 @@ 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)
|
||||
self._setup_hltb_data(game_data.get("name", ""), hltb_layout, game_info_layout)
|
||||
|
||||
return game_info_layout
|
||||
|
||||
def _setup_gog_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() != "gog":
|
||||
return
|
||||
if not exec_line.startswith("gog://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]
|
||||
cached = self.main_window.gog_api.get_cached_download_sizes(app_id)
|
||||
if cached is not None:
|
||||
self._set_gog_size_values(values, cached)
|
||||
return
|
||||
request = self._gog_size_requests.get(app_id)
|
||||
if request is not None:
|
||||
request[1].append(values)
|
||||
return
|
||||
try:
|
||||
command = self.main_window.gog_api.build_command(
|
||||
["info", app_id, "--platform", "windows", "--skip-dlcs"]
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
logger.warning("Failed to get GOG download size: %s", error)
|
||||
return
|
||||
process = QProcess(self.main_window)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
environment.insert("GOGDL_CONFIG_PATH", str(self.main_window.gog_api.config_dir))
|
||||
process.setProcessEnvironment(environment)
|
||||
self._gog_size_requests[app_id] = (process, [values])
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._finish_gog_size_request(app_id, code)
|
||||
)
|
||||
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:
|
||||
return
|
||||
process, value_groups = request
|
||||
if code != 0:
|
||||
logger.warning("gogdl info exited with code %s", code)
|
||||
process.deleteLater()
|
||||
return
|
||||
try:
|
||||
sizes = self.main_window.gog_api.parse_download_sizes(
|
||||
bytes(process.readAllStandardOutput().data())
|
||||
)
|
||||
except (ValueError, TypeError) as error:
|
||||
logger.warning("Failed to parse GOG download size: %s", error)
|
||||
process.deleteLater()
|
||||
return
|
||||
self.main_window.gog_api.save_download_sizes(app_id, sizes)
|
||||
for value_refs in value_groups:
|
||||
self._set_gog_size_values(value_refs, sizes)
|
||||
process.deleteLater()
|
||||
|
||||
@staticmethod
|
||||
def _set_gog_size_values(
|
||||
value_refs: list[Callable[[], QLabel | None]], sizes: tuple[int, int]
|
||||
) -> None:
|
||||
for value_ref, size in zip(value_refs, sizes, strict=True):
|
||||
label = value_ref()
|
||||
if label is None or not isValid(label):
|
||||
continue
|
||||
divisor, unit = (
|
||||
(1024 ** 3, "GiB") if size >= 1024 ** 3 else (1024 ** 2, "MiB")
|
||||
)
|
||||
label.setText(f"{size / divisor:.1f} {unit}")
|
||||
|
||||
def _create_playtime_row(self, last_launch: str, formatted_playtime: str) -> QHBoxLayout:
|
||||
"""Create first row with last launch and playtime."""
|
||||
first_row = QHBoxLayout()
|
||||
@@ -434,9 +607,34 @@ class DetailPageManager:
|
||||
|
||||
current_exe = self._get_current_exe(exec_line)
|
||||
play_button = self._create_play_button(exec_line, current_exe)
|
||||
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 self._has_game_shortcut(game_name):
|
||||
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),
|
||||
)
|
||||
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(
|
||||
_("Edit Shortcut"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
@@ -447,7 +645,7 @@ class DetailPageManager:
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(edit_button)
|
||||
elif str(game_source).lower() == "steam" and appid:
|
||||
if str(game_source).lower() == "steam" and appid:
|
||||
open_folder_button = self._make_action_button(
|
||||
_("Open Folder"),
|
||||
self.main_window.theme_manager.get_icon("search", as_path=True),
|
||||
@@ -456,7 +654,10 @@ class DetailPageManager:
|
||||
lambda: self._open_steam_game_folder(str(appid))
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
else:
|
||||
elif (
|
||||
str(game_source).lower() not in {"gog", "egs"}
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
_("Add a shortcut"),
|
||||
self.main_window.theme_manager.get_icon("addgame", as_path=True),
|
||||
@@ -471,6 +672,47 @@ class DetailPageManager:
|
||||
# Show settings button for PortProton games or Steam games using PortProtonQt
|
||||
if str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
source in {"gog", "egs"}
|
||||
and appid
|
||||
and exec_line.startswith(f"{source}://launch/")
|
||||
):
|
||||
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
|
||||
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(
|
||||
store_exe, game_source=source,
|
||||
)
|
||||
)
|
||||
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(store_exe, log_button)
|
||||
)
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
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(store_exe)
|
||||
)
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "steam" and appid:
|
||||
try:
|
||||
compat_tool = get_steam_compat_tool(int(appid))
|
||||
@@ -496,6 +738,7 @@ class DetailPageManager:
|
||||
icon = self.main_window.theme_manager.get_icon("play", as_path=True)
|
||||
|
||||
play_button = self._make_action_button(text, icon)
|
||||
play_button.setProperty("sound_event", False)
|
||||
play_button.clicked.connect(lambda: self.main_window.toggleGame(exec_line, play_button))
|
||||
return play_button
|
||||
|
||||
@@ -575,9 +818,13 @@ class DetailPageManager:
|
||||
def _open_portproton_game_folder(self, exec_line: str) -> None:
|
||||
"""Open folder containing the PortProton game executable."""
|
||||
file_to_check = self._get_file_from_exec(exec_line)
|
||||
if not file_to_check or not os.path.exists(file_to_check):
|
||||
self._open_executable_folder(file_to_check)
|
||||
|
||||
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):
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(file_to_check))
|
||||
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:
|
||||
@@ -676,12 +923,39 @@ class DetailPageManager:
|
||||
|
||||
source_kind, source_data = source
|
||||
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:
|
||||
self.main_window.current_running_button = play_button
|
||||
self.main_window._set_running_button_stop()
|
||||
|
||||
def _finalize_detail_page(
|
||||
self,
|
||||
@@ -716,11 +990,16 @@ class DetailPageManager:
|
||||
main_layout.addStretch()
|
||||
|
||||
self.main_window.current_exec_line = page_data["exec_line"]
|
||||
cover_path = (
|
||||
None
|
||||
if detail_page.property("fallbackExe")
|
||||
else page_data["cover_path"]
|
||||
)
|
||||
self._setup_detail_page_animation(
|
||||
detail_page,
|
||||
page_data["image_label"],
|
||||
detail_page,
|
||||
page_data["cover_path"],
|
||||
cover_path,
|
||||
)
|
||||
|
||||
def _populate_compact_game_layout(
|
||||
@@ -747,6 +1026,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(
|
||||
@@ -821,8 +1103,8 @@ class DetailPageManager:
|
||||
return None
|
||||
|
||||
def _is_action_button_text(self, text: str) -> bool:
|
||||
"""Check if button text is Play or Stop."""
|
||||
return text in (_("Start"), _("Stop"))
|
||||
"""Check if button text is a primary game action."""
|
||||
return text in (_("Start"), _("Stop"), _("Install"))
|
||||
|
||||
def _on_favorite_click(self, name: str) -> str:
|
||||
"""Handle favorite toggle click."""
|
||||
@@ -922,6 +1204,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:
|
||||
@@ -1049,7 +1334,7 @@ class DetailPageManager:
|
||||
self.main_window.currentDetailPage = None
|
||||
self._current_detail_page = None
|
||||
|
||||
def _game_tuple_to_data(self, game_tuple: tuple) -> dict:
|
||||
def _game_tuple_to_data(self, game_tuple: Sequence[object]) -> dict:
|
||||
return {
|
||||
"name": game_tuple[0],
|
||||
"description": game_tuple[1],
|
||||
@@ -1066,6 +1351,7 @@ class DetailPageManager:
|
||||
"anticheat_slug": game_tuple[13] if len(game_tuple) > 13 else "",
|
||||
"ppdb_id": game_tuple[14] if len(game_tuple) > 14 else "",
|
||||
"ppdb_rating": game_tuple[15] if len(game_tuple) > 15 else "",
|
||||
"protondb_appid": game_tuple[16] if len(game_tuple) > 16 else game_tuple[3],
|
||||
}
|
||||
|
||||
def _finalize_autoinstall_page(
|
||||
@@ -1171,6 +1457,8 @@ class DetailPageManager:
|
||||
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
|
||||
if resolved_exe_path is None:
|
||||
return
|
||||
if not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
|
||||
start_command = get_portproton_start_command()
|
||||
if not start_command:
|
||||
@@ -1195,7 +1483,7 @@ class DetailPageManager:
|
||||
msg_box.setWindowTitle(_("Log Saved"))
|
||||
msg_box.setText(_("Debug log saved to:") + f"\n{log_file}")
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
|
||||
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
|
||||
|
||||
result = msg_box.exec()
|
||||
|
||||
@@ -1335,7 +1623,7 @@ class DetailPageManager:
|
||||
if card is None or not card.isVisible():
|
||||
return
|
||||
self._clear_current_card_state(card)
|
||||
card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
card.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
|
||||
self.main_window.current_focused_card = card
|
||||
card.update()
|
||||
|
||||
@@ -1353,14 +1641,19 @@ 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()))
|
||||
cards[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
cards[0].setFocus(Qt.FocusReason.ActiveWindowFocusReason)
|
||||
scroll_area = container.parentWidget()
|
||||
while scroll_area and not isinstance(scroll_area, QScrollArea):
|
||||
scroll_area = scroll_area.parentWidget()
|
||||
|
||||
@@ -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
|
||||
@@ -16,10 +16,12 @@ from portprotonqt.config import (
|
||||
parse_desktop_entry,
|
||||
)
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.animations.detail_background import DetailBackgroundAnimations
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_pixmap_relays: WeakKeyDictionary[QWidget, "_PixmapReadyRelay"] = WeakKeyDictionary()
|
||||
_cover_reveal_animations: WeakKeyDictionary[QLabel, QPropertyAnimation] = WeakKeyDictionary()
|
||||
_detail_backgrounds = DetailBackgroundAnimations()
|
||||
|
||||
|
||||
class _PixmapReadyRelay(QObject):
|
||||
@@ -64,6 +66,7 @@ def setup_image_loading(
|
||||
cover_width,
|
||||
cover_height,
|
||||
lambda pixmap: relay.pixmap_ready.emit(pixmap),
|
||||
app_name=str(detail_page.property("coverCacheKey") or ""),
|
||||
fallback_exe=fallback_exe,
|
||||
fallback_icon_path=fallback_icon_path,
|
||||
)
|
||||
@@ -82,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
|
||||
|
||||
@@ -96,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")
|
||||
|
||||
@@ -115,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())
|
||||
@@ -126,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:
|
||||
@@ -169,7 +231,7 @@ def _resolve_gradient_stops(theme, dark_palette: list) -> str:
|
||||
- list of float: palette colors at specified positions
|
||||
- list of dict/tuple/str: custom stops with position+color
|
||||
"""
|
||||
gradient_stops = getattr(theme, "DETAIL_PAGE_GRADIENT", None)
|
||||
gradient_stops = _resolve_gradient_config(theme).get("stops")
|
||||
if gradient_stops is None:
|
||||
return _build_palette_stops(dark_palette)
|
||||
if isinstance(gradient_stops, str):
|
||||
@@ -193,21 +255,47 @@ def _resolve_gradient_stops(theme, dark_palette: list) -> str:
|
||||
return ",\n".join(stop_parts)
|
||||
|
||||
|
||||
def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window) -> None:
|
||||
def _resolve_gradient_config(theme: object) -> dict:
|
||||
"""Resolve nested gradient settings with legacy theme compatibility."""
|
||||
backgrounds = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
|
||||
config = dict(backgrounds.get("gradient", {})) if isinstance(backgrounds, dict) else {}
|
||||
legacy_names = {
|
||||
"stops": "DETAIL_PAGE_GRADIENT",
|
||||
"type": "DETAIL_PAGE_GRADIENT_TYPE",
|
||||
"x1": "DETAIL_PAGE_GRADIENT_X1",
|
||||
"y1": "DETAIL_PAGE_GRADIENT_Y1",
|
||||
"x2": "DETAIL_PAGE_GRADIENT_X2",
|
||||
"y2": "DETAIL_PAGE_GRADIENT_Y2",
|
||||
"cx": "DETAIL_PAGE_GRADIENT_CX",
|
||||
"cy": "DETAIL_PAGE_GRADIENT_CY",
|
||||
"radius": "DETAIL_PAGE_GRADIENT_RADIUS",
|
||||
"fx": "DETAIL_PAGE_GRADIENT_FX",
|
||||
"fy": "DETAIL_PAGE_GRADIENT_FY",
|
||||
}
|
||||
for key, legacy_name in legacy_names.items():
|
||||
if hasattr(theme, legacy_name):
|
||||
config[key] = getattr(theme, legacy_name)
|
||||
return config
|
||||
|
||||
|
||||
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
|
||||
]
|
||||
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
|
||||
gradient_type = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_TYPE', 'linear')
|
||||
gradient_config = _resolve_gradient_config(theme)
|
||||
stops = _resolve_gradient_stops(theme, dark_palette)
|
||||
gradient_type = gradient_config.get("type", "linear")
|
||||
|
||||
if gradient_type == 'radial':
|
||||
cx = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_CX', 0.5)
|
||||
cy = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_CY', 0.5)
|
||||
radius = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_RADIUS', 0.5)
|
||||
fx = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_FX', 0.5)
|
||||
fy = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_FY', 0.5)
|
||||
cx = gradient_config.get("cx", 0.5)
|
||||
cy = gradient_config.get("cy", 0.5)
|
||||
radius = gradient_config.get("radius", 0.5)
|
||||
fx = gradient_config.get("fx", 0.5)
|
||||
fy = gradient_config.get("fy", 0.5)
|
||||
|
||||
stylesheet = f"""
|
||||
QWidget {{
|
||||
@@ -218,10 +306,10 @@ def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window)
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
x1 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_X1', 0)
|
||||
y1 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_Y1', 0)
|
||||
x2 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_X2', 1)
|
||||
y2 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_Y2', 1)
|
||||
x1 = gradient_config.get("x1", 0)
|
||||
y1 = gradient_config.get("y1", 0)
|
||||
x2 = gradient_config.get("x2", 1)
|
||||
y2 = gradient_config.get("y2", 1)
|
||||
|
||||
stylesheet = f"""
|
||||
QWidget {{
|
||||
@@ -232,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:
|
||||
@@ -246,11 +334,19 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
|
||||
"""Setup wave background overlay based on theme DETAIL_PAGE_BG_MODE."""
|
||||
bg_mode = getattr(theme, "DETAIL_PAGE_BG_MODE", "gradient")
|
||||
|
||||
if bg_mode == "gradient":
|
||||
if _detail_backgrounds.setup(detail_page, dark_palette, theme):
|
||||
_remove_wave_background(detail_page)
|
||||
return
|
||||
_detail_backgrounds.remove(detail_page)
|
||||
|
||||
if bg_mode not in {"waves", "static_waves"}:
|
||||
_remove_wave_background(detail_page)
|
||||
return
|
||||
|
||||
wave_config = getattr(theme, "DETAIL_PAGE_WAVES", {})
|
||||
backgrounds = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
|
||||
wave_config = backgrounds.get("waves", {}) if isinstance(backgrounds, dict) else {}
|
||||
if hasattr(theme, "DETAIL_PAGE_WAVES"):
|
||||
wave_config = theme.DETAIL_PAGE_WAVES
|
||||
state = _wave_states.get(detail_page)
|
||||
if state is None:
|
||||
state = {
|
||||
@@ -364,6 +460,7 @@ def _apply_no_cover_style(detail_page: QWidget, theme) -> None:
|
||||
try:
|
||||
detail_page.setStyleSheet(theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||
_remove_wave_background(detail_page)
|
||||
_detail_backgrounds.remove(detail_page)
|
||||
detail_page.update()
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
|
||||
@@ -608,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):
|
||||
@@ -653,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:
|
||||
|
||||
@@ -72,6 +72,7 @@ def create_back_button(
|
||||
back_button = AutoSizeButton(_("Back"), icon=theme_manager.get_icon("back", as_path=True))
|
||||
back_button.setFixedWidth(100)
|
||||
back_button.setStyleSheet(theme.ADDGAME_BACK_BUTTON_STYLE)
|
||||
back_button.setProperty("sound_event", "back")
|
||||
back_button.clicked.connect(on_click)
|
||||
parent_layout.addWidget(back_button, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
return back_button
|
||||
@@ -83,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)
|
||||
@@ -138,10 +140,10 @@ def _add_source_corner(
|
||||
) -> None:
|
||||
"""Add source corner badge to cover frame."""
|
||||
source = game_source.lower()
|
||||
if source not in ("steam", "portproton") or theme_manager is None:
|
||||
if source not in ("steam", "gog", "egs", "portproton") or theme_manager is None:
|
||||
return
|
||||
|
||||
icon_name = "badge_steam" if source == "steam" else "badge_portproton"
|
||||
icon_name = f"badge_{source}"
|
||||
icon = theme_manager.get_icon(icon_name, as_path=True)
|
||||
if not icon:
|
||||
return
|
||||
@@ -181,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)
|
||||
@@ -191,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)
|
||||
@@ -200,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(
|
||||
@@ -217,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(
|
||||
@@ -322,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,
|
||||
@@ -365,7 +349,7 @@ def create_portproton_badge(
|
||||
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://ppdb.linux-gaming.ru/game/{ppdb_id}"))
|
||||
lambda: QDesktopServices.openUrl(QUrl(f"https://linux-gaming.ru/game/{ppdb_id}"))
|
||||
)
|
||||
return badge
|
||||
|
||||
@@ -418,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)
|
||||
@@ -440,7 +425,11 @@ def create_details_widget(
|
||||
_add_details_description(details_layout, main_window.theme, description)
|
||||
|
||||
if game_info_layout:
|
||||
details_layout.addLayout(game_info_layout)
|
||||
details_layout.addWidget(
|
||||
create_compact_layout_panel(
|
||||
parent, main_window.theme, game_info_layout
|
||||
)
|
||||
)
|
||||
|
||||
if controller_support:
|
||||
_add_controller_support(details_layout, main_window.theme, controller_support)
|
||||
@@ -456,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)
|
||||
|
||||
+502
-14
@@ -1,25 +1,36 @@
|
||||
"""Base dialogs for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
import tempfile
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
import psutil
|
||||
import orjson
|
||||
import pefile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
from portprotonqt.config import (
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
THEMED_LAUNCH_ICON_NAMES,
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
get_portproton_location,
|
||||
parse_desktop_entry,
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
@@ -28,6 +39,7 @@ from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS, set_animated_cover
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
|
||||
logger = get_logger(__name__)
|
||||
theme_manager = ThemeManager()
|
||||
@@ -125,16 +137,241 @@ class GameLaunchDialog(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
class GameFolderScanWorker(QThread):
|
||||
"""Find executable files recursively and report scanned directory counts."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, folder: str, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.folder = folder
|
||||
self.games: list[tuple[str, str]] = []
|
||||
self.errors: list[str] = []
|
||||
|
||||
MAX_SCAN_DEPTH = 5
|
||||
MAX_SCAN_DIRECTORIES = 400
|
||||
GAME_SUBDIRECTORIES = frozenset((
|
||||
"bin", "bin32", "bin64", "binaries", "win32", "win64", "x64", "x86", "game", "disccontentpc", "api",
|
||||
))
|
||||
|
||||
def run(self) -> None:
|
||||
for directory, paths in self._find_executables(self.folder).items():
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
executable = max(paths, key=lambda path: self._executable_score(path, directory))
|
||||
self.games.append((self._game_name(executable, directory), executable))
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
self.games.sort(key=lambda game: game[1].casefold())
|
||||
|
||||
def _executable_score(self, executable: str, directory: str) -> tuple[int, int, int]:
|
||||
name = Path(executable).stem.casefold()
|
||||
folder = re.sub(r"[\W_]", "", os.path.basename(directory).casefold())
|
||||
key = re.sub(r"[\W_]", "", name)
|
||||
match = int(key == folder) + int(bool(key and folder and (key in folder or folder in key)))
|
||||
launcher = name.endswith(("launcher", "redirector")) or name in ("play", "start", "run")
|
||||
depth = len(Path(executable).relative_to(directory).parts)
|
||||
return match - int(launcher), -depth, os.path.getsize(executable)
|
||||
|
||||
def _executable_name(self, executable: str) -> str:
|
||||
try:
|
||||
with pefile.PE(executable, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
tables = [table for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", [])]
|
||||
metadata = {key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for table in tables for key, value in table.entries.items()}
|
||||
except pefile.PEFormatError:
|
||||
return ""
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", executable, error)
|
||||
return ""
|
||||
for field in ("ProductName", "FileDescription"):
|
||||
value = metadata.get(field, "").strip()
|
||||
if value and not re.fullmatch(
|
||||
r"(?i)(?:BootstrapPackagedGame|game|application|launcher|.*launcher.*|.*redistributable.*)", value,
|
||||
):
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _game_name(self, executable: str, directory: str) -> str:
|
||||
name = ""
|
||||
for info in sorted(Path(directory).glob("goggame-*.info")):
|
||||
try:
|
||||
data = orjson.loads(info.read_bytes().removeprefix(b"\xef\xbb\xbf"))
|
||||
if (isinstance(data, dict) and isinstance(data.get("name"), str)
|
||||
and data.get("gameId") == data.get("rootGameId")):
|
||||
name = data["name"]
|
||||
break
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read game metadata %s: %s", info, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid game metadata %s: %s", info, error)
|
||||
if not name:
|
||||
name = self._executable_name(executable) or Path(directory).name
|
||||
name = re.sub(r"(?i)[._ ]+(?:v?\d+(?:[._]\d+)+|build[._ ]?\d+).*$", "", name)
|
||||
name = name.replace(".", " ")
|
||||
name = re.sub(r"[\\/\r\n]", " ", name).replace(":", " - ")
|
||||
return " ".join(name.replace("_", " ").split()).strip(" .") or Path(executable).stem
|
||||
|
||||
def _record_error(self, error: OSError) -> None:
|
||||
logger.warning("Failed to scan game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _is_runtime_directory(self, path: str) -> bool:
|
||||
name = os.path.basename(path).casefold()
|
||||
parent = os.path.basename(os.path.dirname(path)).casefold()
|
||||
runtime = r"(?:i386-windows|x86_64-windows|mono|monobleedingedge|wine-mono.*|jre[\d._-]*|jdk[\d._-]*|java-runtime.*|_?commonredist|__installer)"
|
||||
if re.fullmatch(runtime, name) or name in ("$recycle.bin", "system volume information", "config.msi", "msdownld.tmp"):
|
||||
return True
|
||||
if name == "windows" and parent == "drive_c":
|
||||
return True
|
||||
if name in ("windows media player", "internet explorer", "windows nt") and parent.startswith("program files"):
|
||||
return True
|
||||
if "proton" in name or name.startswith("wine"):
|
||||
return os.path.isfile(os.path.join(path, "proton")) or os.path.isdir(os.path.join(path, "lib", "wine"))
|
||||
return False
|
||||
|
||||
def _find_executables(self, folder: str) -> dict[str, list[str]]:
|
||||
games: dict[str, list[str]] = {}
|
||||
pending = deque([] if self._is_runtime_directory(folder) else [(folder, 0, folder)])
|
||||
visited: dict[str, int] = {}
|
||||
completed, total = 0, len(pending)
|
||||
ignored = r"(?i)(?:unins.*|setup.*|install.*|.*redist.*|dxsetup.*|directx.*|dotnet.*|.*crash.*|.*report.*|.*prereq.*|.*updater?.*|.*config.*|.*settings.*|.*helper.*|.*benchmark.*|.*-win(?:32|64)-shipping|.*easyanticheat.*|.*battleye.*|oalinst|overlay_test.*|.*[-_](?:setup|installer).*|.*errormonitor|pluginhost|.*_api)"
|
||||
skipped = {"engine", "melonloader", "mods", "plugins", "userdata", "userlibs", "reshade_shaders",
|
||||
"assets", "data", "resources", "res", "res_mods", "replays", "movies", "cache", "logs"}
|
||||
while pending:
|
||||
if self.isInterruptionRequested():
|
||||
return {}
|
||||
root, depth, branch = pending.popleft()
|
||||
label = _("Scanning: {folder}").format(folder=root)
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
try:
|
||||
with os.scandir(root) as entries:
|
||||
entries_list = sorted(entries, key=lambda entry: entry.name.casefold())
|
||||
files = [entry.path for entry in entries_list if entry.name.casefold().endswith(".exe")
|
||||
and not re.fullmatch(ignored, entry.name[:-len(".exe")])
|
||||
and not entry.is_symlink() and entry.is_file(follow_symlinks=False)]
|
||||
directory = root
|
||||
while directory != folder and Path(directory).name.casefold() in self.GAME_SUBDIRECTORIES:
|
||||
directory = os.path.dirname(directory)
|
||||
if files:
|
||||
games.setdefault(directory, []).extend(files)
|
||||
for entry in entries_list:
|
||||
name = entry.name.casefold()
|
||||
if (depth >= self.MAX_SCAN_DEPTH or entry.is_symlink() or not entry.is_dir(follow_symlinks=False)
|
||||
or name.startswith((".", "$")) or name in skipped or name.endswith("_data")
|
||||
or re.fullmatch(ignored, name) or self._is_runtime_directory(entry.path)):
|
||||
continue
|
||||
if files and root != folder and name not in self.GAME_SUBDIRECTORIES:
|
||||
continue
|
||||
owner = entry.path if root == folder or Path(root).name.casefold() == "common" else branch
|
||||
if visited.get(owner, 0) >= self.MAX_SCAN_DIRECTORIES:
|
||||
self._record_error(OSError(f"Game folder scan directory limit reached: {owner}"))
|
||||
break
|
||||
visited[owner] = visited.get(owner, 0) + 1
|
||||
next_depth = 0 if owner == entry.path or name in ("steamapps", "common") else depth + 1
|
||||
pending.append((entry.path, next_depth, owner))
|
||||
total += 1
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
completed += 1
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
return games
|
||||
|
||||
|
||||
class GameFolderImportWorker(QThread):
|
||||
"""Create selected shortcuts without overwriting existing entries."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, games: list[tuple[str, str]], parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.games = games
|
||||
self.added = 0
|
||||
self.skipped = 0
|
||||
self.errors: list[str] = []
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
raise FileNotFoundError("PortProton directory not found")
|
||||
existing = set()
|
||||
with os.scandir(location) as entries:
|
||||
paths = [entry.path for entry in entries if entry.name.endswith(".desktop") and entry.is_file()]
|
||||
for index, path in enumerate(paths):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(paths), _("Checking existing shortcuts: {path}").format(path=path)))
|
||||
entry = parse_desktop_entry(path)
|
||||
target = extract_exec_target_path(entry.get("Exec", "")) if entry else None
|
||||
if target:
|
||||
existing.add(os.path.realpath(target))
|
||||
for index, (name, executable) in enumerate(self.games):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
self._import_game(name, executable, existing)
|
||||
self.progressChanged.emit((index + 1, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _import_game(self, name: str, executable: str, existing: set[str]) -> None:
|
||||
target = os.path.realpath(executable)
|
||||
if target in existing:
|
||||
self.skipped += 1
|
||||
return
|
||||
created = False
|
||||
desktop_path = ""
|
||||
try:
|
||||
name = " ".join(re.sub(r"[\\/]", " ", name).split()).strip(" .")
|
||||
result = create_desktop_file(executable, name)
|
||||
if result is None:
|
||||
raise OSError(_("Failed to create shortcut: {name}").format(name=name))
|
||||
content, desktop_path, icon_path = result
|
||||
if os.path.exists(desktop_path):
|
||||
self.skipped += 1
|
||||
return
|
||||
if not os.path.exists(icon_path) and not generate_thumbnail(executable, icon_path, size=128):
|
||||
content = content.replace(f"Icon={icon_path}\n", "Icon=\n")
|
||||
with open(desktop_path, "x", encoding="utf-8") as desktop:
|
||||
created = True
|
||||
desktop.write(content)
|
||||
os.chmod(desktop_path, 0o755)
|
||||
existing.add(target)
|
||||
self.added += 1
|
||||
except FileExistsError:
|
||||
self.skipped += 1
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import '%s': %s", executable, error)
|
||||
self.errors.append(f"{name}: {error}")
|
||||
if created:
|
||||
try:
|
||||
os.unlink(desktop_path)
|
||||
except OSError as cleanup_error:
|
||||
logger.warning("Failed to remove incomplete shortcut: %s", cleanup_error)
|
||||
|
||||
|
||||
class AddGameDialog(DraggableDialog):
|
||||
"""Dialog for adding or editing a game."""
|
||||
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
|
||||
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"))
|
||||
@@ -156,11 +393,9 @@ class AddGameDialog(DraggableDialog):
|
||||
name_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
layout.addRow(name_label, self.nameEdit)
|
||||
|
||||
exe_label = QLabel(_("Path to Executable:"))
|
||||
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
|
||||
self.exeEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.exeEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.exeEdit.setVisible(steam_appid is None)
|
||||
if exe_path:
|
||||
self.exeEdit.setText(exe_path)
|
||||
|
||||
@@ -168,12 +403,15 @@ class AddGameDialog(DraggableDialog):
|
||||
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
exeBrowseButton.clicked.connect(self.browseExe)
|
||||
exeBrowseButton.setObjectName("exeBrowseButton")
|
||||
exeBrowseButton.setVisible(steam_appid is None)
|
||||
if steam_appid is None:
|
||||
exe_label = QLabel(_("Path to Executable:"))
|
||||
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
layout.addRow(exe_label, self.exeEdit)
|
||||
|
||||
layout.addRow(exe_label, self.exeEdit)
|
||||
|
||||
empty_label = QLabel("")
|
||||
empty_label.setFixedWidth(exe_label.sizeHint().width())
|
||||
layout.addRow(empty_label, exeBrowseButton)
|
||||
empty_label = QLabel("")
|
||||
empty_label.setFixedWidth(exe_label.sizeHint().width())
|
||||
layout.addRow(empty_label, exeBrowseButton)
|
||||
|
||||
cover_label = QLabel(_("Custom Cover:"))
|
||||
cover_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
@@ -190,7 +428,22 @@ class AddGameDialog(DraggableDialog):
|
||||
coverBrowseButton.setObjectName("coverBrowseButton")
|
||||
|
||||
layout.addRow(cover_label, self.coverEdit)
|
||||
layout.addRow(empty_label, coverBrowseButton)
|
||||
cover_button_label = QLabel("")
|
||||
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()
|
||||
@@ -210,6 +463,7 @@ class AddGameDialog(DraggableDialog):
|
||||
self.add_to_steam_checkbox.setIconSize(QSize(20, 20))
|
||||
self.add_to_menu_checkbox.setIconSize(QSize(20, 20))
|
||||
self.add_to_desktop_checkbox.setIconSize(QSize(20, 20))
|
||||
self.add_to_steam_checkbox.setVisible(steam_appid is None)
|
||||
options_layout = QHBoxLayout()
|
||||
options_layout.setSpacing(12)
|
||||
options_layout.addWidget(self.add_to_steam_checkbox)
|
||||
@@ -218,9 +472,12 @@ class AddGameDialog(DraggableDialog):
|
||||
options_layout.addStretch()
|
||||
options_widget = QWidget(self)
|
||||
options_widget.setLayout(options_layout)
|
||||
options_widget.setVisible(steam_appid is None)
|
||||
options_label = QLabel(_("Add shortcut to:"))
|
||||
options_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
layout.addRow(options_label, options_widget)
|
||||
options_label.setVisible(steam_appid is None)
|
||||
if steam_appid is None:
|
||||
layout.addRow(options_label, options_widget)
|
||||
|
||||
current_name = game_name.strip() if game_name else self.nameEdit.text().strip()
|
||||
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
|
||||
@@ -320,10 +577,44 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
def accept(self):
|
||||
"""Handle Apply button."""
|
||||
icon_path = self.iconEdit.text().strip()
|
||||
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
|
||||
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
image = QPixmap(icon_path)
|
||||
if image.isNull():
|
||||
QMessageBox.warning(self, _("Error"), _("Invalid image"))
|
||||
return
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
temporary_path = None
|
||||
try:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
|
||||
temporary_path = temporary.name
|
||||
if not image.save(temporary_path, "PNG"):
|
||||
raise OSError("Failed to save icon")
|
||||
os.replace(temporary_path, os.path.join(folder, "icon.png"))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save custom icon: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
finally:
|
||||
if temporary_path:
|
||||
Path(temporary_path).unlink(missing_ok=True)
|
||||
if hasattr(self, 'keyboard'):
|
||||
self.keyboard.hide()
|
||||
super().accept()
|
||||
|
||||
def browseIcon(self) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
|
||||
initial_path=os.path.dirname(self.iconEdit.text()) or None)
|
||||
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
|
||||
explorer.show()
|
||||
|
||||
def browseExe(self):
|
||||
"""Open file manager to select executable or disc image."""
|
||||
try:
|
||||
@@ -331,6 +622,12 @@ class AddGameDialog(DraggableDialog):
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
|
||||
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
|
||||
if not self.edit_mode:
|
||||
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
folder_button.setObjectName("gamepadAddButton")
|
||||
folder_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
folder_button.clicked.connect(partial(self._select_game_folder, file_explorer))
|
||||
file_explorer.button_layout.insertWidget(0, folder_button)
|
||||
|
||||
parent_widget = self.parentWidget()
|
||||
if parent_widget:
|
||||
@@ -344,6 +641,195 @@ class AddGameDialog(DraggableDialog):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in browseExe: {e}")
|
||||
|
||||
def _select_game_folder(self, explorer: "FileExplorer") -> None:
|
||||
folder = explorer.current_path
|
||||
item = explorer.file_list.currentItem()
|
||||
if item and item.text() != "../":
|
||||
selected = os.path.normpath(os.path.join(folder, item.text()))
|
||||
if os.path.isdir(selected):
|
||||
folder = selected
|
||||
self._game_folder_root = folder
|
||||
explorer.accept()
|
||||
self._start_folder_worker(GameFolderScanWorker(folder, self.parentWidget() or self))
|
||||
|
||||
def _start_folder_worker(self, worker: GameFolderScanWorker | GameFolderImportWorker) -> None:
|
||||
owner = cast(Any, self.parentWidget() or self)
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
owner._gameFolderWorkers = workers
|
||||
workers.append(worker)
|
||||
progress = QProgressDialog(_("Processing games…"), _("Cancel"), 0, 1, self)
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
progress.setValue(0)
|
||||
progress.canceled.connect(worker.requestInterruption)
|
||||
worker.progressChanged.connect(partial(self._update_folder_progress, progress), Qt.ConnectionType.QueuedConnection)
|
||||
worker.finished.connect(partial(self._finish_folder_worker, worker, progress), Qt.ConnectionType.QueuedConnection)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _update_folder_progress(self, progress: QProgressDialog, state: tuple[int, int, str]) -> None:
|
||||
if progress.wasCanceled():
|
||||
return
|
||||
completed, total, current = state
|
||||
progress.setLabelText(_("Processed {completed}/{total}\n{current}").format(
|
||||
completed=completed, total=total, current=current,
|
||||
))
|
||||
progress.setRange(0, max(total, 1))
|
||||
progress.setValue(completed)
|
||||
|
||||
def _finish_folder_worker(self, worker: QThread, progress: QProgressDialog) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
owner = self.parentWidget() or self
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
cancelled = progress.wasCanceled()
|
||||
progress.close()
|
||||
progress.deleteLater()
|
||||
worker.deleteLater()
|
||||
if worker not in workers:
|
||||
return
|
||||
workers.remove(worker)
|
||||
if isinstance(worker, GameFolderImportWorker):
|
||||
if worker.added:
|
||||
self.gamesAdded.emit()
|
||||
if cancelled:
|
||||
return
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
self.reject()
|
||||
elif isinstance(worker, GameFolderScanWorker) and not cancelled:
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
if worker.games:
|
||||
self._show_folder_games(worker.games)
|
||||
else:
|
||||
QMessageBox.information(self, _("Information"), _("No games found in the selected folder."))
|
||||
|
||||
def _create_folder_game_row(
|
||||
self, layout: QVBoxLayout, game: tuple[str, str],
|
||||
) -> tuple[QLineEdit, QLineEdit, QCheckBox]:
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
|
||||
name, executable = game
|
||||
content = layout.parentWidget()
|
||||
row = QHBoxLayout()
|
||||
checkbox = QCheckBox(content)
|
||||
checkbox.setObjectName("gameFolderExecutable")
|
||||
checkbox.setAccessibleName(executable)
|
||||
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
checkbox.setChecked(True)
|
||||
title = CustomLineEdit(name, content, theme=self.theme)
|
||||
title.setAccessibleName(_("Name:"))
|
||||
title.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
path = CustomLineEdit(executable, content, theme=self.theme)
|
||||
path.setObjectName("gameFolderPath")
|
||||
path.setAccessibleName(_("Path to Executable:"))
|
||||
path.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
browse.setAutoDefault(False)
|
||||
browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
browse.clicked.connect(partial(self._browse_folder_exe, path))
|
||||
path_row = QHBoxLayout()
|
||||
path_row.addWidget(path)
|
||||
path_row.addWidget(browse)
|
||||
details = QVBoxLayout()
|
||||
details.addWidget(title)
|
||||
details.addLayout(path_row)
|
||||
row.addWidget(checkbox)
|
||||
row.addLayout(details)
|
||||
layout.addLayout(row)
|
||||
QWidget.setTabOrder(checkbox, title)
|
||||
QWidget.setTabOrder(title, path)
|
||||
QWidget.setTabOrder(path, browse)
|
||||
return title, path, checkbox
|
||||
|
||||
def _browse_folder_exe(self, field: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(
|
||||
field.window(), theme=self.theme, file_filter=(".exe",),
|
||||
initial_path=os.path.dirname(os.path.expanduser(field.text().strip())),
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(field.setText)
|
||||
explorer.exec()
|
||||
field.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
explorer.deleteLater()
|
||||
|
||||
def _accept_folder_games(
|
||||
self, dialog: QDialog, rows: list[tuple[QLineEdit, QLineEdit, QCheckBox]],
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
for title, path, checkbox in rows:
|
||||
if not checkbox.isChecked():
|
||||
continue
|
||||
if not title.text().strip():
|
||||
QMessageBox.warning(dialog, _("Error"), _("Game name and executable path are required"))
|
||||
title.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
executable = os.path.expanduser(path.text().strip())
|
||||
if not os.path.isfile(executable):
|
||||
QMessageBox.warning(dialog, _("Error"), _("Executable not found: {0}").format(executable))
|
||||
path.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
path.setText(executable)
|
||||
dialog.accept()
|
||||
|
||||
def _scroll_folder_focus(self, scroll: QScrollArea, _old: QWidget | None, focused: QWidget | None) -> None:
|
||||
if focused is not None and scroll.isAncestorOf(focused):
|
||||
scroll.ensureWidgetVisible(focused)
|
||||
|
||||
def _show_folder_games(self, games: list[tuple[str, str]]) -> None:
|
||||
dialog = cast(Any, DraggableDialog(self))
|
||||
dialog.setWindowTitle(_("Select games"))
|
||||
dialog.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
dialog.resize(*self.theme.GAME_FOLDER_IMPORT_DIALOG_SIZE)
|
||||
layout = QVBoxLayout(dialog)
|
||||
scroll = QScrollArea(dialog)
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget(scroll)
|
||||
rows_layout = QVBoxLayout(content)
|
||||
rows = [self._create_folder_game_row(rows_layout, game) for game in games]
|
||||
rows_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
buttons = QHBoxLayout()
|
||||
apply_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply", as_path=True))
|
||||
apply_button.setObjectName("gamepadAddButton")
|
||||
cancel_button = AutoSizeButton(_("Close"), icon=theme_manager.get_icon("cancel", as_path=True))
|
||||
for button in (apply_button, cancel_button):
|
||||
button.setAutoDefault(False)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
buttons.addWidget(button)
|
||||
apply_button.clicked.connect(partial(self._accept_folder_games, dialog, rows))
|
||||
cancel_button.clicked.connect(dialog.reject)
|
||||
layout.addLayout(buttons)
|
||||
main_window = self.parentWidget()
|
||||
input_manager = getattr(main_window, "input_manager", None)
|
||||
hints, labels = create_dialog_hints_widget(self.theme, main_window, input_manager, context='folder_import')
|
||||
layout.addWidget(hints)
|
||||
update_dialog_hints(labels, main_window, input_manager, theme_manager, ui_config.get_theme())
|
||||
dialog.keyboard = VirtualKeyboard(dialog, theme=self.theme)
|
||||
dialog.finished.connect(dialog.keyboard.hide)
|
||||
if rows:
|
||||
QTimer.singleShot(0, rows[0][2].setFocus)
|
||||
app = cast(QApplication, QApplication.instance())
|
||||
scroll_focus = partial(self._scroll_folder_focus, scroll)
|
||||
app.focusChanged.connect(scroll_focus)
|
||||
try:
|
||||
accepted = dialog.exec() == QDialog.DialogCode.Accepted
|
||||
finally:
|
||||
app.focusChanged.disconnect(scroll_focus)
|
||||
if accepted:
|
||||
selected = [(title.text().strip(), path.text().strip()) for title, path, checkbox in rows
|
||||
if checkbox.isChecked()]
|
||||
if selected:
|
||||
self._start_folder_worker(GameFolderImportWorker(selected, self.parentWidget() or self))
|
||||
dialog.deleteLater()
|
||||
|
||||
def onExeSelected(self, file_path):
|
||||
"""Handle file selection in FileExplorer."""
|
||||
self.exeEdit.setText(file_path)
|
||||
@@ -490,6 +976,8 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
file_icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(launch_path)[1].lower())
|
||||
file_icon_path = theme_manager.get_icon(file_icon_name, as_path=True) if file_icon_name else ""
|
||||
if get_custom_icon_path(exe_path):
|
||||
return desktop_entry, desktop_path
|
||||
if not self.last_cover_path and file_icon_path:
|
||||
desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", f"Icon={file_icon_path}\n")
|
||||
elif not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128):
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Compatibility report dialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
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,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
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):
|
||||
"""Display a readable compatibility report."""
|
||||
|
||||
def __init__(self, parent, theme, report: str) -> None:
|
||||
super().__init__(parent)
|
||||
metrics = theme.COMPATIBILITY_REPORT_DIALOG
|
||||
self.setWindowTitle(_("Compatibility report"))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(metrics["minimum_width"], metrics["minimum_height"])
|
||||
self.setStyleSheet(
|
||||
theme.MAIN_WINDOW_STYLE + theme.COMPATIBILITY_REPORT_DIALOG_STYLE
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(*([metrics["margin"]] * 4))
|
||||
layout.setSpacing(metrics["spacing"])
|
||||
layout.addWidget(self._create_header())
|
||||
|
||||
report_view = QPlainTextEdit()
|
||||
report_view.setObjectName("compatibilityReport")
|
||||
report_view.setReadOnly(True)
|
||||
report_view.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
|
||||
report_view.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
report_view.setStyleSheet(theme.SCROLL_STYLE)
|
||||
report_view.setPlainText(report)
|
||||
report_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
report_view.customContextMenuRequested.connect(
|
||||
lambda position: self._show_context_menu(report_view, theme, position)
|
||||
)
|
||||
layout.addWidget(report_view, stretch=1)
|
||||
layout.addLayout(self._create_buttons(theme))
|
||||
|
||||
def _create_header(self) -> QFrame:
|
||||
header = QFrame()
|
||||
header.setObjectName("compatibilityHeader")
|
||||
layout = QVBoxLayout(header)
|
||||
title = QLabel(_("Compatibility report"))
|
||||
title.setObjectName("compatibilityTitle")
|
||||
summary = QLabel(
|
||||
_("The program stopped. Compatibility details were collected automatically.")
|
||||
)
|
||||
summary.setObjectName("compatibilitySummary")
|
||||
summary.setWordWrap(True)
|
||||
layout.addWidget(title)
|
||||
layout.addWidget(summary)
|
||||
return header
|
||||
|
||||
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)
|
||||
close_button.setDefault(True)
|
||||
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:
|
||||
menu = report_view.createStandardContextMenu()
|
||||
menu.setWindowFlags(
|
||||
menu.windowFlags()
|
||||
| Qt.WindowType.Popup
|
||||
| Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
menu.setStyleSheet(theme.CONTEXT_MENU_STYLE)
|
||||
menu.exec(report_view.viewport().mapToGlobal(position))
|
||||
@@ -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
|
||||
@@ -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):
|
||||
@@ -170,12 +193,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):
|
||||
@@ -225,15 +248,41 @@ class FileExplorer(DraggableDialog):
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
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 +358,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 +580,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 +598,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 +621,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 +629,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)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Proton Manager dialog for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
@@ -8,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
|
||||
@@ -28,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):
|
||||
@@ -46,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()
|
||||
@@ -60,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 = {}
|
||||
@@ -189,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)
|
||||
@@ -205,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()
|
||||
@@ -248,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()
|
||||
@@ -283,7 +374,15 @@ class ProtonManager(DraggableDialog):
|
||||
return True
|
||||
|
||||
def filter_entries_by_cpu_level(self, entries, source_name):
|
||||
if source_name.lower() != 'proton_cachyos':
|
||||
is_arm = platform.machine().lower().startswith(('arm', 'aarch64'))
|
||||
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
|
||||
@@ -331,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', '')
|
||||
@@ -347,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"
|
||||
@@ -496,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
|
||||
@@ -508,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):
|
||||
@@ -552,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()):
|
||||
@@ -567,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
|
||||
@@ -856,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
|
||||
@@ -942,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']}"
|
||||
@@ -1016,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):
|
||||
@@ -1031,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):
|
||||
@@ -1050,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)
|
||||
@@ -1144,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
|
||||
Loaded 100 of 316 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user