Compare commits
	
		
			293 Commits
		
	
	
		
			233dab1269
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						e07f3f06bc
	
				 | 
					
					
						|||
| 
						
						
							
						
						16a3f4e09a
	
				 | 
					
					
						|||
| 
						
						
							
						
						a448ba29b0
	
				 | 
					
					
						|||
| 
						
						
							
						
						06e55db54d
	
				 | 
					
					
						|||
| 
						
						
							
						
						5fce23f261
	
				 | 
					
					
						|||
| 
						 | 
					96ad40d625 | ||
| 
						 | 
					a30f6f2e74 | ||
| 
						
						
							
						
						0231073b19
	
				 | 
					
					
						|||
| 
						
						
							
						
						dec24429f5
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a758f3b3c
	
				 | 
					
					
						|||
| 
						
						
							
						
						0853dd1579
	
				 | 
					
					
						|||
| 
						
						
							
						
						bbb87c0455
	
				 | 
					
					
						|||
| 
						
						
							
						
						b32a71a125
	
				 | 
					
					
						|||
| 
						 | 
					bddf9f850a | ||
| 
						 | 
					a9c3cfa167 | ||
| 
						
						
							
						
						7675bc4cdc
	
				 | 
					
					
						|||
| 
						
						
							
						
						ffa203f019
	
				 | 
					
					
						|||
| 
						
						
							
						
						3eed25ecee
	
				 | 
					
					
						|||
| 
						
						
							
						
						3736bb279e
	
				 | 
					
					
						|||
| 
						 | 
					b59ee5ae8e | ||
| 
						
						
							
						
						33176590fd
	
				 | 
					
					
						|||
| 
						
						
							
						
						8046065929
	
				 | 
					
					
						|||
| 
						 | 
					fbad5add6c | ||
| 
						
						
							
						
						438e9737ea
	
				 | 
					
					
						|||
| 
						
						
							
						
						2d39a4c740
	
				 | 
					
					
						|||
| 
						
						
							
						
						567203b0b0
	
				 | 
					
					
						|||
| 
						
						
							
						
						502cbc5030
	
				 | 
					
					
						|||
| 
						
						
							
						
						9b61215152
	
				 | 
					
					
						|||
| 
						
						
							
						
						10d3fe8ab4
	
				 | 
					
					
						|||
| 
						
						
							
						
						a568ad9ef8
	
				 | 
					
					
						|||
| 
						
						
							
						
						f074843fc8
	
				 | 
					
					
						|||
| 
						
						
							
						
						4ab078b93e
	
				 | 
					
					
						|||
| 
						
						
							
						
						7df6ad3b80
	
				 | 
					
					
						|||
| 
						
						
							
						
						464ad0fe9c
	
				 | 
					
					
						|||
| 
						
						
							
						
						cde92885d4
	
				 | 
					
					
						|||
| 
						
						
							
						
						120c7b319c
	
				 | 
					
					
						|||
| 
						
						
							
						
						596aed0077
	
				 | 
					
					
						|||
| 
						
						
							
						
						6fc6cb1e02
	
				 | 
					
					
						|||
| 
						
						
							
						
						186e28a19b
	
				 | 
					
					
						|||
| 
						
						
							
						
						28e4d1e77c
	
				 | 
					
					
						|||
| 
						
						
							
						
						fff1f888c4
	
				 | 
					
					
						|||
| 
						
						
							
						
						fdd5a0a3d5
	
				 | 
					
					
						|||
| 
						
						
							
						
						792e52d981
	
				 | 
					
					
						|||
| 
						
						
							
						
						84d5e46a74
	
				 | 
					
					
						|||
| 
						
						
							
						
						4bc764d568
	
				 | 
					
					
						|||
| 
						
						
							
						
						9a18aa037e
	
				 | 
					
					
						|||
| 
						
						
							
						
						ed62d2d1c4
	
				 | 
					
					
						|||
| 
						
						
							
						
						accc9b18b6
	
				 | 
					
					
						|||
| 
						
						
							
						
						82249d7eab
	
				 | 
					
					
						|||
| 
						
						
							
						
						476c896940
	
				 | 
					
					
						|||
| 
						
						
							
						
						b1047ba18e
	
				 | 
					
					
						|||
| 
						
						
							
						
						987199d8e6
	
				 | 
					
					
						|||
| 
						 | 
					ef1acd4581 | ||
| 
						
						
							
						
						96f884904c
	
				 | 
					
					
						|||
| 
						
						
							
						
						b856a2afae
	
				 | 
					
					
						|||
| 
						
						
							
						
						55ef0030e6
	
				 | 
					
					
						|||
| 
						
						
							
						
						8aaeaa4824
	
				 | 
					
					
						|||
| 
						
						
							
						
						f55372b480
	
				 | 
					
					
						|||
| 
						
						
							
						
						4d6f32f053
	
				 | 
					
					
						|||
| 
						
						
							
						
						a2f5141b20
	
				 | 
					
					
						|||
| 
						
						
							
						
						e3cb2857e7
	
				 | 
					
					
						|||
| 
						
						
							
						
						efe8a35832
	
				 | 
					
					
						|||
| 
						
						
							
						
						61fae97dad
	
				 | 
					
					
						|||
| 
						
						
							
						
						5442100f64
	
				 | 
					
					
						|||
| 
						
						
							
						
						2d6ef84798
	
				 | 
					
					
						|||
| 
						 | 
					f4aee15b5d | ||
| 
						
						
							
						
						87a65108a5
	
				 | 
					
					
						|||
| 
						
						
							
						
						bb617708ac
	
				 | 
					
					
						|||
| 
						
						
							
						
						1cf332cd87
	
				 | 
					
					
						|||
| 
						
						
							
						
						577ad4d3a3
	
				 | 
					
					
						|||
| 
						
						
							
						
						ef3f2d6e96
	
				 | 
					
					
						|||
| 
						
						
							
						
						657d7728a6
	
				 | 
					
					
						|||
| 9452bfda2e | |||
| 7eb2db0d68 | |||
| 6ef7a03366 | |||
| e5af354b56 | |||
| e6e5f6c8ea | |||
| 84306bb31b | |||
| 60af4d1482 | |||
| 692e11b21d | |||
| b1a804811e | |||
| 9a30cfaea7 | |||
| 5dd2f71f5e | |||
| 
						
						
							
						
						dba172361b
	
				 | 
					
					
						|||
| 
						
						
							
						
						a9c70b8818
	
				 | 
					
					
						|||
| 
						
						
							
						
						135ace732f
	
				 | 
					
					
						|||
| 
						
						
							
						
						8b727f64e1
	
				 | 
					
					
						|||
| 
						
						
							
						
						a8eb591da5
	
				 | 
					
					
						|||
| 
						
						
							
						
						fe4ca1ee87
	
				 | 
					
					
						|||
| 
						
						
							
						
						ffe3e9d3d6
	
				 | 
					
					
						|||
| 
						
						
							
						
						49d39b5d61
	
				 | 
					
					
						|||
| 
						 | 
					
						
						
							
						
						03566da704
	
				 | 
					
					
						||
| 
						 | 
					7f996ab6a0 | ||
| 
						 | 
					9e17978155 | ||
| 
						
						
							
						
						5d0185b1b4
	
				 | 
					
					
						|||
| 
						
						
							
						
						5c134be04e
	
				 | 
					
					
						|||
| 
						
						
							
						
						8c66695192
	
				 | 
					
					
						|||
| 
						
						
							
						
						7a141d8e46
	
				 | 
					
					
						|||
| 
						
						
							
						
						abb2377fb7
	
				 | 
					
					
						|||
| 
						
						
							
						
						75f4f346de
	
				 | 
					
					
						|||
| 
						
						
							
						
						87a9f85272
	
				 | 
					
					
						|||
| 
						
						
							
						
						240f685ece
	
				 | 
					
					
						|||
| 
						
						
							
						
						af4e3e95bb
	
				 | 
					
					
						|||
| 
						
						
							
						
						017d9a42cf
	
				 | 
					
					
						|||
| 
						
						
							
						
						18b7c4054b
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd7f71b70a
	
				 | 
					
					
						|||
| 
						
						
							
						
						8fd44c575b
	
				 | 
					
					
						|||
| 
						
						
							
						
						65b43c1572
	
				 | 
					
					
						|||
| 
						
						
							
						
						f35276abfe
	
				 | 
					
					
						|||
| 
						
						
							
						
						6fea9a9a7e
	
				 | 
					
					
						|||
| 
						
						
							
						
						5189474631
	
				 | 
					
					
						|||
| 
						 | 
					416cc6a268 | ||
| 
						 | 
					3b44ed5252 | ||
| 
						
						
							
						
						c8c45dda06
	
				 | 
					
					
						|||
| 
						
						
							
						
						3f9f794e6f
	
				 | 
					
					
						|||
| 
						
						
							
						
						ba9d8b76d8
	
				 | 
					
					
						|||
| 
						
						
							
						
						e99c71c1f8
	
				 | 
					
					
						|||
| 
						
						
							
						
						baec62d1cb
	
				 | 
					
					
						|||
| 
						
						
							
						
						cb76961e4f
	
				 | 
					
					
						|||
| 
						 | 
					081cd07253 | ||
| 
						
						
							
						
						b5efee29ea
	
				 | 
					
					
						|||
| 69360f7e7e | |||
| 
						 | 
					39712f0591 | ||
| 
						 | 
					60b508af18 | ||
| 
						 | 
					b6637b4163 | ||
| 
						 | 
					6d9eed42f8 | ||
| 
						
						
							
						
						7372e3b7f5
	
				 | 
					
					
						|||
| e0d5bd7993 | |||
| 
						 | 
					12f8067af1 | ||
| 
						 | 
					716a813ca9 | ||
| 
						
						
							
						
						c62cc6853f
	
				 | 
					
					
						|||
| 
						
						
							
						
						2e018b4690
	
				 | 
					
					
						|||
| 
						
						
							
						
						ad5b25f713
	
				 | 
					
					
						|||
| 
						
						
							
						
						3fb8201305
	
				 | 
					
					
						|||
| 
						
						
							
						
						04d8302d6c
	
				 | 
					
					
						|||
| 
						 | 
					f868b21178 | ||
| 
						 | 
					ebe25b41d8 | ||
| 
						 | 
					fae6cad52d | ||
| 
						 | 
					42bce11ada | ||
| 
						
						
							
						
						f088c01768
	
				 | 
					
					
						|||
| 
						
						
							
						
						e7eee85ed4
	
				 | 
					
					
						|||
| 
						
						
							
						
						ecfe252ae3
	
				 | 
					
					
						|||
| 
						
						
							
						
						1ad19bff6a
	
				 | 
					
					
						|||
| 
						
						
							
						
						98f07a9792
	
				 | 
					
					
						|||
| 
						
						
							
						
						d5c53ed1aa
	
				 | 
					
					
						|||
| 
						
						
							
						
						5a2ab36b60
	
				 | 
					
					
						|||
| 
						
						
							
						
						8e25c04f56
	
				 | 
					
					
						|||
| 
						
						
							
						
						f249b01dc6
	
				 | 
					
					
						|||
| 
						
						
							
						
						9f32afe6a3
	
				 | 
					
					
						|||
| 
						
						
							
						
						f475e6e0b2
	
				 | 
					
					
						|||
| 
						
						
							
						
						43a7c37e91
	
				 | 
					
					
						|||
| 
						
						
							
						
						f1cf0ffd68
	
				 | 
					
					
						|||
| 
						
						
							
						
						70ed3abcb5
	
				 | 
					
					
						|||
| 
						
						
							
						
						f061b1597e
	
				 | 
					
					
						|||
| 
						
						
							
						
						0f37a8fc6f
	
				 | 
					
					
						|||
| 
						
						
							
						
						850bc57a16
	
				 | 
					
					
						|||
| 
						
						
							
						
						0dcc3ea13f
	
				 | 
					
					
						|||
| 
						
						
							
						
						1c82b34e36
	
				 | 
					
					
						|||
| 
						
						
							
						
						a8c4ae6f7b
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd4f658b66
	
				 | 
					
					
						|||
| 
						
						
							
						
						bff6b7fd34
	
				 | 
					
					
						|||
| 
						
						
							
						
						1e191bbba3
	
				 | 
					
					
						|||
| 
						
						
							
						
						4356e653b8
	
				 | 
					
					
						|||
| 
						
						
							
						
						4fc95511f1
	
				 | 
					
					
						|||
| 
						
						
							
						
						4d4e14ea52
	
				 | 
					
					
						|||
| 
						
						
							
						
						c39f5ad83b
	
				 | 
					
					
						|||
| 
						
						
							
						
						f3325ca35f
	
				 | 
					
					
						|||
| 
						
						
							
						
						50645066dd
	
				 | 
					
					
						|||
| 
						
						
							
						
						7945dd8980
	
				 | 
					
					
						|||
| 
						
						
							
						
						59c38f9c57
	
				 | 
					
					
						|||
| 
						
						
							
						
						a2d5d28884
	
				 | 
					
					
						|||
| 
						
						
							
						
						16af4b410a
	
				 | 
					
					
						|||
| 
						
						
							
						
						e8e42b5a86
	
				 | 
					
					
						|||
| 
						
						
							
						
						d16e2cdf43
	
				 | 
					
					
						|||
| 
						 | 
					b60fd0d593 | ||
| 
						
						
							
						
						d93f23fe8c
	
				 | 
					
					
						|||
| 
						
						
							
						
						5423ada8f1
	
				 | 
					
					
						|||
| 
						
						
							
						
						2547c7c78d
	
				 | 
					
					
						|||
| 
						
						
							
						
						2e93073446
	
				 | 
					
					
						|||
| 
						 | 
					9657ff20d3 | ||
| 
						
						
							
						
						849333c283
	
				 | 
					
					
						|||
| 
						
						
							
						
						8e11dac987
	
				 | 
					
					
						|||
| 
						
						
							
						
						358afbdbdb
	
				 | 
					
					
						|||
| 
						
						
							
						
						83730499e2
	
				 | 
					
					
						|||
| 
						
						
							
						
						84f560ed30
	
				 | 
					
					
						|||
| 
						
						
							
						
						888c9ac387
	
				 | 
					
					
						|||
| 
						
						
							
						
						68d06ca05c
	
				 | 
					
					
						|||
| 
						
						
							
						
						6923a5f05c
	
				 | 
					
					
						|||
| 
						
						
							
						
						f3f85441d8
	
				 | 
					
					
						|||
| 
						
						
							
						
						eb90836710
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd125c975b
	
				 | 
					
					
						|||
| 
						
						
							
						
						4521d3ca1c
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd044dbd95
	
				 | 
					
					
						|||
| 
						
						
							
						
						0047b29cd2
	
				 | 
					
					
						|||
| 
						
						
							
						
						d0fbc79168
	
				 | 
					
					
						|||
| 
						
						
							
						
						57f6ac9c4b
	
				 | 
					
					
						|||
| 
						
						
							
						
						60271f7a13
	
				 | 
					
					
						|||
| 
						
						
							
						
						38ab4acc86
	
				 | 
					
					
						|||
| 
						
						
							
						
						8f54f4814c
	
				 | 
					
					
						|||
| 
						
						
							
						
						37254b89f1
	
				 | 
					
					
						|||
| 
						
						
							
						
						893e33bdce
	
				 | 
					
					
						|||
| 
						
						
							
						
						1ee784d890
	
				 | 
					
					
						|||
| 
						
						
							
						
						39f505079c
	
				 | 
					
					
						|||
| 
						
						
							
						
						46253115ff
	
				 | 
					
					
						|||
| 
						
						
							
						
						31a7ef3e7e
	
				 | 
					
					
						|||
| 
						 | 
					cb07904c1b | ||
| 
						
						
							
						
						05e0d9d846
	
				 | 
					
					
						|||
| 
						
						
							
						
						81433d3c56
	
				 | 
					
					
						|||
| 
						
						
							
						
						0ff66e282b
	
				 | 
					
					
						|||
| 
						
						
							
						
						831b7739ba
	
				 | 
					
					
						|||
| 
						
						
							
						
						50e1dfda57
	
				 | 
					
					
						|||
| 
						
						
							
						
						fcf04e521d
	
				 | 
					
					
						|||
| 
						
						
							
						
						74d0700d7c
	
				 | 
					
					
						|||
| 
						
						
							
						
						0435c77630
	
				 | 
					
					
						|||
| 
						
						
							
						
						1cf93a60c8
	
				 | 
					
					
						|||
| 
						
						
							
						
						31247d21c3
	
				 | 
					
					
						|||
| 
						
						
							
						
						c6017a7dce
	
				 | 
					
					
						|||
| 
						
						
							
						
						c74d209dbd
	
				 | 
					
					
						|||
| 
						
						
							
						
						5b257d3b62
	
				 | 
					
					
						|||
| 
						
						
							
						
						4dcf1dbe6d
	
				 | 
					
					
						|||
| 
						
						
							
						
						8d6fe4aa65
	
				 | 
					
					
						|||
| 
						
						
							
						
						022eb3f1e9
	
				 | 
					
					
						|||
| 
						
						
							
						
						11b847ed05
	
				 | 
					
					
						|||
| 
						
						
							
						
						1e4e0127a4
	
				 | 
					
					
						|||
| 
						
						
							
						
						c045aa7a56
	
				 | 
					
					
						|||
| 
						
						
							
						
						f18e7bae6b
	
				 | 
					
					
						|||
| 
						
						
							
						
						dcf8904037
	
				 | 
					
					
						|||
| 
						
						
							
						
						f9d24e385d
	
				 | 
					
					
						|||
| 
						
						
							
						
						09028931be
	
				 | 
					
					
						|||
| 
						
						
							
						
						0294c90c54
	
				 | 
					
					
						|||
| 
						
						
							
						
						17dfef2d27
	
				 | 
					
					
						|||
| 
						 | 
					
						
						
							
						
						f0690f8811
	
				 | 
					
					
						||
| 
						
						
							
						
						ac20447ba3
	
				 | 
					
					
						|||
| 
						
						
							
						
						ba143c15a8
	
				 | 
					
					
						|||
| 
						
						
							
						
						13068f3959
	
				 | 
					
					
						|||
| 
						 | 
					c8360d08ca | ||
| 
						
						
							
						
						b070ff1fca
	
				 | 
					
					
						|||
| 
						
						
							
						
						b5a2f41bdf
	
				 | 
					
					
						|||
| 
						
						
							
						
						9a37f31841
	
				 | 
					
					
						|||
| 
						
						
							
						
						aeed0112cd
	
				 | 
					
					
						|||
| 
						
						
							
						
						027ae68d4d
	
				 | 
					
					
						|||
| 
						
						
							
						
						37d41fef8d
	
				 | 
					
					
						|||
| 
						
						
							
						
						e37422fc95
	
				 | 
					
					
						|||
| 
						
						
							
						
						d7951e8587
	
				 | 
					
					
						|||
| 
						
						
							
						
						556533785a
	
				 | 
					
					
						|||
| 
						
						
							
						
						a13aca4d84
	
				 | 
					
					
						|||
| 
						
						
							
						
						35736e1723
	
				 | 
					
					
						|||
| 
						 | 
					
						
						
							
						
						24a7c2e657
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						279f7ec36b
	
				 | 
					
					
						||
| 
						
						
							
						
						41f6943998
	
				 | 
					
					
						|||
| 
						
						
							
						
						3bf10dc4cd
	
				 | 
					
					
						|||
| 
						
						
							
						
						33b96d3185
	
				 | 
					
					
						|||
| 
						
						
							
						
						3573b8e373
	
				 | 
					
					
						|||
| 
						
						
							
						
						582ddd2218
	
				 | 
					
					
						|||
| 
						
						
							
						
						2753e53a4d
	
				 | 
					
					
						|||
| 
						
						
							
						
						46973f35e1
	
				 | 
					
					
						|||
| 
						
						
							
						
						8e34c92385
	
				 | 
					
					
						|||
| 
						
						
							
						
						d50b63bca7
	
				 | 
					
					
						|||
| 
						
						
							
						
						6966253e9b
	
				 | 
					
					
						|||
| 
						
						
							
						
						13f3af7a42
	
				 | 
					
					
						|||
| 
						
						
							
						
						c7bed80570
	
				 | 
					
					
						|||
| 
						
						
							
						
						6fde7c18db
	
				 | 
					
					
						|||
| 
						
						
							
						
						37782d4375
	
				 | 
					
					
						|||
| 
						
						
							
						
						0a8a7c538c
	
				 | 
					
					
						|||
| 
						 | 
					9cc4b8c51d | ||
| 
						
						
							
						
						397dede2be
	
				 | 
					
					
						|||
| 
						
						
							
						
						6a66f37ba1
	
				 | 
					
					
						|||
| 
						
						
							
						
						4db1cce32c
	
				 | 
					
					
						|||
| 
						
						
							
						
						edaeca4f11
	
				 | 
					
					
						|||
| 
						
						
							
						
						11d44f091d
	
				 | 
					
					
						|||
| 
						
						
							
						
						09d9c6510a
	
				 | 
					
					
						|||
| 
						
						
							
						
						272be51bb0
	
				 | 
					
					
						|||
| 
						
						
							
						
						63933172f9
	
				 | 
					
					
						|||
| 
						
						
							
						
						85e9aba836
	
				 | 
					
					
						|||
| 
						
						
							
						
						4d3499d2c1
	
				 | 
					
					
						|||
| 
						
						
							
						
						a13c15bc28
	
				 | 
					
					
						|||
| 
						
						
							
						
						83076d3dfc
	
				 | 
					
					
						|||
| 
						
						
							
						
						04aaf68e36
	
				 | 
					
					
						|||
| 
						
						
							
						
						e91037708a
	
				 | 
					
					
						|||
| 
						
						
							
						
						1b743026c2
	
				 | 
					
					
						|||
| 
						
						
							
						
						30b4cec4d1
	
				 | 
					
					
						|||
| 
						
						
							
						
						db68c9050c
	
				 | 
					
					
						|||
| 
						
						
							
						
						1a93d5b82c
	
				 | 
					
					
						|||
| 
						
						
							
						
						cc0690cf9e
	
				 | 
					
					
						|||
| 
						
						
							
						
						809ba2c976
	
				 | 
					
					
						|||
| 
						
						
							
						
						68c9636e10
	
				 | 
					
					
						|||
| 
						
						
							
						
						f0df1f89be
	
				 | 
					
					
						|||
| 
						
						
							
						
						f25224b668
	
				 | 
					
					
						|||
| 
						
						
							
						
						0cda47fdfd
	
				 | 
					
					
						|||
| 
						
						
							
						
						1a8c733580
	
				 | 
					
					
						|||
| 
						
						
							
						
						2476bea32a
	
				 | 
					
					
						|||
| 
						
						
							
						
						1bbc95a5c1
	
				 | 
					
					
						|||
| 
						
						
							
						
						d12b801191
	
				 | 
					
					
						
@@ -12,15 +12,27 @@ jobs:
 | 
			
		||||
    name: Build AppImage
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
            sudo apt update
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
 | 
			
		||||
 | 
			
		||||
      - name: Install tools
 | 
			
		||||
        run: pip3 install appimage-builder uv
 | 
			
		||||
      - name: Upgrade pip toolchain
 | 
			
		||||
        run: |
 | 
			
		||||
          python3 -m pip install --upgrade \
 | 
			
		||||
            pip setuptools setuptools-scm wheel packaging build
 | 
			
		||||
 | 
			
		||||
      - name: Install appimage-builder
 | 
			
		||||
        run: |
 | 
			
		||||
          git clone https://github.com/Boria138/appimage-builder
 | 
			
		||||
          cd appimage-builder
 | 
			
		||||
          pip install .
 | 
			
		||||
 | 
			
		||||
      - name: Install uv
 | 
			
		||||
        run: |
 | 
			
		||||
          pip install uv
 | 
			
		||||
 | 
			
		||||
      - name: Build AppImage
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -40,7 +52,7 @@ jobs:
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        fedora_version: [41, 42, rawhide]
 | 
			
		||||
        fedora_version: [41, 42, 43, rawhide]
 | 
			
		||||
 | 
			
		||||
    container:
 | 
			
		||||
      image: fedora:${{ matrix.fedora_version }}
 | 
			
		||||
@@ -50,7 +62,7 @@ jobs:
 | 
			
		||||
      - name: Install build dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
 | 
			
		||||
                         python3-build pyproject-rpm-macros python3-setuptools \
 | 
			
		||||
                         python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
 | 
			
		||||
                         redhat-rpm-config nodejs npm
 | 
			
		||||
 | 
			
		||||
      - name: Setup rpmbuild environment
 | 
			
		||||
@@ -61,7 +73,7 @@ jobs:
 | 
			
		||||
          echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
 | 
			
		||||
 | 
			
		||||
      - name: Checkout repo
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Copy fedora.spec
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -82,7 +94,7 @@ jobs:
 | 
			
		||||
    name: Build Arch Package
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel
 | 
			
		||||
      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /usr:/usr-host
 | 
			
		||||
        - /opt:/opt-host
 | 
			
		||||
@@ -122,7 +134,7 @@ jobs:
 | 
			
		||||
          su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
 | 
			
		||||
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Upload Arch package
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-upload-artifact@v4
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ on:
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  # Common version, will be used for tagging the release
 | 
			
		||||
  VERSION: 0.1.3
 | 
			
		||||
  VERSION: 0.1.8
 | 
			
		||||
  PKGDEST: "/tmp/portprotonqt"
 | 
			
		||||
  PACKAGE: "portprotonqt"
 | 
			
		||||
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
 | 
			
		||||
@@ -23,10 +23,22 @@ jobs:
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
            sudo apt update
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
 | 
			
		||||
 | 
			
		||||
      - name: Install tools
 | 
			
		||||
        run: pip3 install appimage-builder uv
 | 
			
		||||
      - name: Upgrade pip toolchain
 | 
			
		||||
        run: |
 | 
			
		||||
          python3 -m pip install --upgrade \
 | 
			
		||||
            pip setuptools setuptools-scm wheel packaging build
 | 
			
		||||
 | 
			
		||||
      - name: Install appimage-builder
 | 
			
		||||
        run: |
 | 
			
		||||
          git clone https://github.com/Boria138/appimage-builder
 | 
			
		||||
          cd appimage-builder
 | 
			
		||||
          pip install .
 | 
			
		||||
 | 
			
		||||
      - name: Install uv
 | 
			
		||||
        run: |
 | 
			
		||||
          pip install uv
 | 
			
		||||
 | 
			
		||||
      - name: Build AppImage
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -97,7 +109,7 @@ jobs:
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        fedora_version: [41, 42, rawhide]
 | 
			
		||||
        fedora_version: [41, 42, 43, rawhide]
 | 
			
		||||
 | 
			
		||||
    container:
 | 
			
		||||
      image: fedora:${{ matrix.fedora_version }}
 | 
			
		||||
@@ -107,7 +119,7 @@ jobs:
 | 
			
		||||
      - name: Install build dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
 | 
			
		||||
                         python3-build pyproject-rpm-macros python3-setuptools \
 | 
			
		||||
                         python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
 | 
			
		||||
                         redhat-rpm-config nodejs npm
 | 
			
		||||
 | 
			
		||||
      - name: Setup rpmbuild environment
 | 
			
		||||
@@ -157,6 +169,7 @@ jobs:
 | 
			
		||||
          mkdir -p extracted
 | 
			
		||||
          find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
 | 
			
		||||
          find extracted/ -type f -exec mv {} release/ \;
 | 
			
		||||
          find release/ -name '*.zip' -delete
 | 
			
		||||
          rm -rf extracted/
 | 
			
		||||
 | 
			
		||||
      - name: Extract changelog for version
 | 
			
		||||
@@ -167,10 +180,12 @@ jobs:
 | 
			
		||||
 | 
			
		||||
      - name: Release
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-release-action@v1
 | 
			
		||||
        env:
 | 
			
		||||
            NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
 | 
			
		||||
        with:
 | 
			
		||||
          body_path: changelog.txt
 | 
			
		||||
          token: ${{ env.GITEA_TOKEN }}
 | 
			
		||||
          tag_name: v${{ env.VERSION }}
 | 
			
		||||
          prerelease: true
 | 
			
		||||
          files: release/**/*
 | 
			
		||||
          sha256sum: true
 | 
			
		||||
          sha256sum: false
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
name: Check Translations
 | 
			
		||||
name: Check Translations (disabled until yaspeller is fixed)
 | 
			
		||||
run-name: Check spelling in translation files
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
@@ -12,13 +12,14 @@ on:
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  check-translations:
 | 
			
		||||
    if: false
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Set up Python
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@v5
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version-file: "pyproject.toml"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										187
									
								
								.gitea/workflows/code-build.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,187 @@
 | 
			
		||||
name: Build Check - AppImage, Arch, Fedora
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  workflow_dispatch:
 | 
			
		||||
  pull_request:
 | 
			
		||||
    paths:
 | 
			
		||||
      - 'build-aux/**'
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  PKGDEST: "/tmp/portprotonqt"
 | 
			
		||||
  PACKAGE: "portprotonqt"
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  changes:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    outputs:
 | 
			
		||||
      appimage: ${{ steps.check.outputs.appimage }}
 | 
			
		||||
      fedora:   ${{ steps.check.outputs.fedora }}
 | 
			
		||||
      arch:     ${{ steps.check.outputs.arch }}
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 0
 | 
			
		||||
 | 
			
		||||
      - name: Ensure git is installed
 | 
			
		||||
        run: |
 | 
			
		||||
          sudo apt update
 | 
			
		||||
          sudo apt install -y git
 | 
			
		||||
 | 
			
		||||
      - name: Check changed files
 | 
			
		||||
        id: check
 | 
			
		||||
        run: |
 | 
			
		||||
          # Get changed files
 | 
			
		||||
          git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} > changed_files.txt
 | 
			
		||||
 | 
			
		||||
          echo "Changed files:"
 | 
			
		||||
          cat changed_files.txt
 | 
			
		||||
 | 
			
		||||
          # Check AppImage files
 | 
			
		||||
          if grep -q "build-aux/AppImageBuilder.yml" changed_files.txt; then
 | 
			
		||||
            echo "appimage=true" >> $GITHUB_OUTPUT
 | 
			
		||||
          else
 | 
			
		||||
            echo "appimage=false" >> $GITHUB_OUTPUT
 | 
			
		||||
          fi
 | 
			
		||||
 | 
			
		||||
          # Check Fedora spec files (only fedora-git.spec)
 | 
			
		||||
          if grep -q "build-aux/fedora-git.spec" changed_files.txt; then
 | 
			
		||||
            echo "fedora=true" >> $GITHUB_OUTPUT
 | 
			
		||||
          else
 | 
			
		||||
            echo "fedora=false" >> $GITHUB_OUTPUT
 | 
			
		||||
          fi
 | 
			
		||||
 | 
			
		||||
          # Check Arch PKGBUILD-git
 | 
			
		||||
          if grep -q "build-aux/PKGBUILD-git" changed_files.txt; then
 | 
			
		||||
            echo "arch=true" >> $GITHUB_OUTPUT
 | 
			
		||||
          else
 | 
			
		||||
            echo "arch=false" >> $GITHUB_OUTPUT
 | 
			
		||||
          fi
 | 
			
		||||
 | 
			
		||||
  build-appimage:
 | 
			
		||||
    name: Build AppImage
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    needs: changes
 | 
			
		||||
    if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
            sudo apt update
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync zstd git
 | 
			
		||||
 | 
			
		||||
      - name: Install tools
 | 
			
		||||
        run: |
 | 
			
		||||
            pip3 install git+https://github.com/Boria138/appimage-builder.git
 | 
			
		||||
            pip3 install uv
 | 
			
		||||
 | 
			
		||||
      - name: Build AppImage
 | 
			
		||||
        run: |
 | 
			
		||||
          cd build-aux
 | 
			
		||||
          appimage-builder
 | 
			
		||||
 | 
			
		||||
      - name: Upload AppImage
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-upload-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: PortProtonQt-AppImage
 | 
			
		||||
          path: build-aux/PortProtonQt*.AppImage
 | 
			
		||||
 | 
			
		||||
  build-fedora:
 | 
			
		||||
    name: Build Fedora RPM
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    needs: changes
 | 
			
		||||
    if: needs.changes.outputs.fedora == 'true' || github.event_name == 'workflow_dispatch'
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        fedora_version: [41, 42, rawhide]
 | 
			
		||||
 | 
			
		||||
    container:
 | 
			
		||||
      image: fedora:${{ matrix.fedora_version }}
 | 
			
		||||
      options: --privileged
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Install build dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
 | 
			
		||||
                         python3-build pyproject-rpm-macros python3-setuptools \
 | 
			
		||||
                         redhat-rpm-config nodejs npm
 | 
			
		||||
 | 
			
		||||
      - name: Setup rpmbuild environment
 | 
			
		||||
        run: |
 | 
			
		||||
          useradd rpmbuild -u 5002 -g users || true
 | 
			
		||||
          mkdir -p /home/rpmbuild/{BUILD,RPMS,SPECS,SRPMS,SOURCES}
 | 
			
		||||
          chown -R rpmbuild:users /home/rpmbuild
 | 
			
		||||
          echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
 | 
			
		||||
 | 
			
		||||
      - name: Checkout repo
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Copy fedora-git.spec
 | 
			
		||||
        run: |
 | 
			
		||||
          cp build-aux/fedora-git.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
 | 
			
		||||
          chown -R rpmbuild:users /home/rpmbuild
 | 
			
		||||
 | 
			
		||||
      - name: Build RPM
 | 
			
		||||
        run: |
 | 
			
		||||
          su rpmbuild -c "rpmbuild -ba /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec"
 | 
			
		||||
 | 
			
		||||
      - name: Upload RPM package
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-upload-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: PortProtonQt-RPM-Fedora-${{ matrix.fedora_version }}
 | 
			
		||||
          path: /home/rpmbuild/RPMS/**/*.rpm
 | 
			
		||||
 | 
			
		||||
  build-arch:
 | 
			
		||||
    name: Build Arch Package
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    needs: changes
 | 
			
		||||
    if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /usr:/usr-host
 | 
			
		||||
        - /opt:/opt-host
 | 
			
		||||
      options: --privileged
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Prepare container
 | 
			
		||||
        run: |
 | 
			
		||||
          pacman -Sy --noconfirm --disable-download-timeout --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
 | 
			
		||||
          pacman-key --init
 | 
			
		||||
          pacman -S --noconfirm archlinux-keyring
 | 
			
		||||
          mkdir -p /__w/portproton-repo
 | 
			
		||||
          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'
 | 
			
		||||
          cat << EOM >> /etc/pacman.conf
 | 
			
		||||
 | 
			
		||||
          [chaotic-aur]
 | 
			
		||||
          Include = /etc/pacman.d/chaotic-mirrorlist
 | 
			
		||||
          EOM
 | 
			
		||||
          pacman -Syy
 | 
			
		||||
          useradd -m user -G wheel && echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
 | 
			
		||||
          echo "PACKAGER=\"Boris Yumankulov <boria138@altlinux.org>\"" >> /etc/makepkg.conf
 | 
			
		||||
          chown user -R /tmp
 | 
			
		||||
          chown user -R ..
 | 
			
		||||
 | 
			
		||||
      - name: Build
 | 
			
		||||
        run: |
 | 
			
		||||
          cd /__w/portproton-repo
 | 
			
		||||
          git clone https://git.linux-gaming.ru/Boria138/PortProtonQt.git
 | 
			
		||||
          cd /__w/portproton-repo/PortProtonQt/build-aux
 | 
			
		||||
          chown user -R ..
 | 
			
		||||
          su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
 | 
			
		||||
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Upload Arch package
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-upload-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: PortProtonQt-Arch
 | 
			
		||||
          path: ${{ env.PKGDEST }}/*
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
name: Code and build check
 | 
			
		||||
name: Code check
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  pull_request:
 | 
			
		||||
@@ -20,12 +20,18 @@ jobs:
 | 
			
		||||
    name: Check code
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Install uv
 | 
			
		||||
        uses: https://github.com/astral-sh/setup-uv@v6
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
 | 
			
		||||
        with:
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
          node-version: 20
 | 
			
		||||
 | 
			
		||||
      - name: Install uv manually
 | 
			
		||||
        run: |
 | 
			
		||||
          curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
			
		||||
          source $HOME/.local/bin/env
 | 
			
		||||
          uv --version
 | 
			
		||||
 | 
			
		||||
      - name: Sync dependencies into venv
 | 
			
		||||
        run: uv sync --all-extras --dev
 | 
			
		||||
@@ -35,20 +41,3 @@ jobs:
 | 
			
		||||
        run: |
 | 
			
		||||
          source .venv/bin/activate
 | 
			
		||||
          pre-commit run --show-diff-on-failure --color=always --all-files
 | 
			
		||||
 | 
			
		||||
  build-uv:
 | 
			
		||||
    name: Build with uv
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Install uv
 | 
			
		||||
        uses: https://github.com/astral-sh/setup-uv@v6
 | 
			
		||||
        with:
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
 | 
			
		||||
      - name: Sync dependencies
 | 
			
		||||
        run: uv sync
 | 
			
		||||
 | 
			
		||||
      - name: Build project
 | 
			
		||||
        run: uv build
 | 
			
		||||
 
 | 
			
		||||
@@ -11,10 +11,10 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Set up Python
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@v5
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version-file: "pyproject.toml"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,11 +8,31 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  renovate:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:41.1.4
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
      - run: renovate
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20
 | 
			
		||||
 | 
			
		||||
      - name: Install uv manually
 | 
			
		||||
        run: |
 | 
			
		||||
          curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
			
		||||
          . $HOME/.local/bin/env
 | 
			
		||||
          uv --version
 | 
			
		||||
 | 
			
		||||
      - name: Download external renovate config
 | 
			
		||||
        run: |
 | 
			
		||||
          mkdir -p /tmp/renovate-config
 | 
			
		||||
          curl -fsSL "https://git.linux-gaming.ru/Linux-Gaming/renovate-config/raw/branch/main/config.js" \
 | 
			
		||||
            -o /tmp/renovate-config/config.js
 | 
			
		||||
 | 
			
		||||
      - name: Run Renovate
 | 
			
		||||
        run: renovate
 | 
			
		||||
        env:
 | 
			
		||||
          RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js"
 | 
			
		||||
          RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
 | 
			
		||||
          LOG_LEVEL: "debug"
 | 
			
		||||
          RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
 | 
			
		||||
          RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
 | 
			
		||||
repos:
 | 
			
		||||
  - repo: https://github.com/pre-commit/pre-commit-hooks
 | 
			
		||||
    rev: v5.0.0
 | 
			
		||||
    rev: v6.0.0
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: trailing-whitespace
 | 
			
		||||
      - id: end-of-file-fixer
 | 
			
		||||
@@ -11,15 +11,14 @@ repos:
 | 
			
		||||
      - id: check-yaml
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/uv-pre-commit
 | 
			
		||||
    rev: 0.6.14
 | 
			
		||||
    rev: 0.9.5
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: uv-lock
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    rev: v0.11.5
 | 
			
		||||
    rev: v0.14.3
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff
 | 
			
		||||
        args: [--fix]
 | 
			
		||||
      - id: ruff-check
 | 
			
		||||
 | 
			
		||||
  - repo: local
 | 
			
		||||
    hooks:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										319
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@@ -3,21 +3,152 @@
 | 
			
		||||
Все заметные изменения в этом проекте фиксируются в этом файле.
 | 
			
		||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
 | 
			
		||||
 | 
			
		||||
## [Unreleased]
 | 
			
		||||
## [0.1.8] - 2025-10-18
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Переводы в переопределениях (за подробностями в документацию)
 | 
			
		||||
- Обложки и описания для всех автоинсталлов
 | 
			
		||||
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры
 | 
			
		||||
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
 | 
			
		||||
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
 | 
			
		||||
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
 | 
			
		||||
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Оптимизированны обложки автоинсталлов
 | 
			
		||||
- Папка custom_data исключена из сборки модуля для уменьшение его размера
 | 
			
		||||
- Бейдж PortProton теперь открывает PortProtonDB
 | 
			
		||||
- При завершении автоустановки приложение больше не перезапускается
 | 
			
		||||
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
 | 
			
		||||
- Обновлены и дополнены скриншоты темы
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
 | 
			
		||||
- Путь к portprotonqt-session-select в оверлее
 | 
			
		||||
- Исправлено наложение карточек при смене фильтра игр
 | 
			
		||||
- Исправлена невозможность запуска приложения без подключёного геймпада
 | 
			
		||||
- Исправлена невозможность установки компонентов Winetricks через геймпад
 | 
			
		||||
- Ресиверы и виртуальные устройства больше не считаются за геймпад
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [0.1.7] - 2025-10-12
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Возможность скроллинга библиотеки мышью или пальцем
 | 
			
		||||
- Импорт и экспорт бекапа префикса
 | 
			
		||||
- Диалог для управление Winetricks
 | 
			
		||||
- Кнопки для удаления префикса, wine или proton
 | 
			
		||||
- Все настройки Wine с оригинального PortProton
 | 
			
		||||
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
 | 
			
		||||
- Вкладка автоустановок
 | 
			
		||||
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
 | 
			
		||||
- В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
 | 
			
		||||
- Исправлено зависание при добавлении или удалении игры в Wayland
 | 
			
		||||
- Исправлено зависание при поиске игр
 | 
			
		||||
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
 | 
			
		||||
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
 | 
			
		||||
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
 | 
			
		||||
- При сохранении настроек теперь не меняется размер окна
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @wmigor (Igor Akulov)
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [0.1.6] - 2025-09-23
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Кэширование шрифтов в load_theme_fonts для предотвращения повторной загрузки
 | 
			
		||||
- Проверка безопасности в theme_manager.py для всех сторонних тем, с проверкой на запрещённые модули и функции (подробности см. в коде theme_manager под полями FORBIDDEN_MODULES и FORBIDDEN_FUNCTIONS)
 | 
			
		||||
- Фильтрация ASRock LED контроллера, чтобы предотвратить его обнаружение как геймпада
 | 
			
		||||
- Подсказки по управлению в интерфейсе
 | 
			
		||||
- Поддержка боковой кнопки мыши, которая теперь работает как кнопка "назад"
 | 
			
		||||
- Аргумент cli --debug-level для указания уровня дебага
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Управления с геймпада теперь перехватывается только если окно в фокусе
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
 | 
			
		||||
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @wmigor (Igor Akulov)
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [0.1.5] - 2025-08-31
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
 | 
			
		||||
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
 | 
			
		||||
- Анимация при закрытии карточки игры (подробности см. в документации).
 | 
			
		||||
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
 | 
			
		||||
- Система быстрого доступа (избранного) в диалоге выбора файлов.
 | 
			
		||||
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
 | 
			
		||||
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
 | 
			
		||||
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
 | 
			
		||||
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
 | 
			
		||||
- Пункт "Выход" в трей.
 | 
			
		||||
- Пункт "Темы" в трей для быстрого переключения тем.
 | 
			
		||||
- Двойной клик по иконке трея для показа/скрытия главного окна.
 | 
			
		||||
- Запуск через трей показывает модальное окно для слежки за процессом запуска
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
 | 
			
		||||
- Контекстное меню при открытии теперь сразу фокусируется на первом элементе.
 | 
			
		||||
- Анимации теперь можно настраивать через темы (подробности см. в документации).
 | 
			
		||||
- Общие JSON-файлы (`steam_apps` и `anticheat_games`) теперь перекачиваются, если они повреждены.
 | 
			
		||||
- Временно удалена светлая тема.
 | 
			
		||||
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
 | 
			
		||||
- Обновлены все зависимости (затрагивает только AppImage).
 | 
			
		||||
- Приложение теперь не закрывается полностью, а сворачивается в трей.
 | 
			
		||||
- Карточки теперь все находятся друг под другом, а не в разнабой
 | 
			
		||||
- Изменено соотношение сторон карточек
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
 | 
			
		||||
- Скриншоты тем больше не теряют качество при масштабе, отличном от 100%.
 | 
			
		||||
- Данные от HLTB теперь не отображаются в карточке, если нет информации о времени прохождения.
 | 
			
		||||
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
 | 
			
		||||
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
 | 
			
		||||
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
 | 
			
		||||
- Заголовок окна диалога выбора файлов теперь можно перевести.
 | 
			
		||||
- Трей теперь можно перевести.
 | 
			
		||||
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
 | 
			
		||||
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Alex Smith
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [0.1.4] - 2025-07-21
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Переводы в переопределениях (подробности см. в документации).
 | 
			
		||||
- Обложки и описания для всех автоинсталлов.
 | 
			
		||||
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры.
 | 
			
		||||
- Интеграция с howlongtobeat.com.
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Оптимизированы обложки автоинсталлов.
 | 
			
		||||
- Папка `custom_data` исключена из сборки модуля для уменьшения его размера.
 | 
			
		||||
- Бейдж PortProton теперь открывает PortProtonDB.
 | 
			
		||||
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в Gamescope-сессии.
 | 
			
		||||
- Удалён аргумент `--session`, так как тестирование Gamescope-сессии завершено.
 | 
			
		||||
- В контекстном меню игр без exe-файла теперь отображается только пункт «Удалить из PortProton».
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Запрос к GitHub API при загрузке legendary теперь учитывает настройки прокси.
 | 
			
		||||
- Путь к `portprotonqt-session-select` в оверлее.
 | 
			
		||||
- Работа `exiftool` в AppImage.
 | 
			
		||||
- Открытие контекстного меню у игр без exe-файла.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
@@ -27,32 +158,32 @@
 | 
			
		||||
## [0.1.3] - 2025-07-05
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Аргумент `--session` для запуска приложения в gamescope (Исключительно в целях тестирования)
 | 
			
		||||
- Начальная поддержка EGS (Без EOS, скачивания игр и запуска игр из сторонних магазинов)
 | 
			
		||||
- Автодополнение bash для комманды portprotonqt
 | 
			
		||||
- Поддержка геймпадов в диалоге выбора игры
 | 
			
		||||
- Быстрый запуск и остановка игры через контекстное меню
 | 
			
		||||
- Иконки в контекстом меню
 | 
			
		||||
- Обложки для части автоинсталлов
 | 
			
		||||
- Аргумент `--session` для запуска приложения в Gamescope (исключительно в целях тестирования).
 | 
			
		||||
- Начальная поддержка EGS (без EOS, скачивания и запуска игр из сторонних магазинов).
 | 
			
		||||
- Автодополнение bash для команды `portprotonqt`.
 | 
			
		||||
- Поддержка геймпадов в диалоге выбора игры.
 | 
			
		||||
- Быстрый запуск и остановка игры через контекстное меню.
 | 
			
		||||
- Иконки в контекстном меню.
 | 
			
		||||
- Обложки для части автоинсталлов.
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Удалены сборки для Fedora 40
 | 
			
		||||
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем.
 | 
			
		||||
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга
 | 
			
		||||
- Все desktop файлы создаются с коментарием "Запустить игру {название} через PortProton"
 | 
			
		||||
- Заполнители в переводах теперь стали более осмысленными
 | 
			
		||||
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope
 | 
			
		||||
- Текст бейджей теперь обрезается через ... если не помещается
 | 
			
		||||
- Удалены сборки для Fedora 40.
 | 
			
		||||
- Параметры анимации GameCard перенесены в `styles.py` с подробной документацией для кастомизации тем.
 | 
			
		||||
- Статусы выделения и наведения на карточки теперь взаимоисключающие.
 | 
			
		||||
- Все desktop-файлы создаются с комментарием «Запустить игру {название} через PortProton».
 | 
			
		||||
- Заполнители в переводах стали более осмысленными.
 | 
			
		||||
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope.
 | 
			
		||||
- Текст бейджей теперь обрезается троеточием, если не помещается.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Дублирование обводки выделения карточек при быстром перемешении мыши
 | 
			
		||||
- Завершение приложения при закритие окна
 | 
			
		||||
- Использование системной палитры в темах
 | 
			
		||||
- Ошибки темы в нативном пакете
 | 
			
		||||
- Ошибки темы в Gamescope
 | 
			
		||||
- Размер иконок для desktop файлов теперь 128x128
 | 
			
		||||
- Пустая область при обновлении сетки игр
 | 
			
		||||
- Запуск игры при открытом оверлее
 | 
			
		||||
- Дублирование обводки карточек при быстром перемещении мыши.
 | 
			
		||||
- Завершение приложения при закрытии окна.
 | 
			
		||||
- Использование системной палитры в темах.
 | 
			
		||||
- Ошибки тем в нативном пакете.
 | 
			
		||||
- Ошибки тем в Gamescope.
 | 
			
		||||
- Размер иконок для desktop-файлов теперь 128x128.
 | 
			
		||||
- Пустая область при обновлении сетки игр.
 | 
			
		||||
- Запуск игры при открытом оверлее.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Dervart
 | 
			
		||||
@@ -63,63 +194,63 @@
 | 
			
		||||
## [0.1.2] - 2025-06-15
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Кнопки сброса настроек и очистки кэша
 | 
			
		||||
- Бейдж PortProton
 | 
			
		||||
- Зависимость от `xdg-utils`
 | 
			
		||||
- Интеграция статуса WeAntiCheatYet в карточку
 | 
			
		||||
- Переключение полноэкршанного режима через F11 или кнопку Select на геймпаде
 | 
			
		||||
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде
 | 
			
		||||
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде
 | 
			
		||||
- Закрытие окна приложения по комбинации клавиш Ctrl+Q
 | 
			
		||||
- Сохранение и восстановление размера окна при перезапуске
 | 
			
		||||
- Переключатель полноэкранного режима приложения
 | 
			
		||||
- Пункт в контекстном меню «Открыть папку игры»
 | 
			
		||||
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam»
 | 
			
		||||
- Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного»
 | 
			
		||||
- Метод сортировки «Сначала избранное»
 | 
			
		||||
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена)
 | 
			
		||||
- Поддержка управления геймпадом в `QMenu` и `QComboBox`
 | 
			
		||||
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме
 | 
			
		||||
- Оверлей на кнопку  Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или переключения между сессиями
 | 
			
		||||
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
 | 
			
		||||
- Пресеты управления для DualShock 4 и DualSense
 | 
			
		||||
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию выключена)
 | 
			
		||||
- Переводы пунктов настроек
 | 
			
		||||
- Кнопки сброса настроек и очистки кэша.
 | 
			
		||||
- Бейдж PortProton.
 | 
			
		||||
- Зависимость от `xdg-utils`.
 | 
			
		||||
- Интеграция статуса WeAntiCheatYet в карточку.
 | 
			
		||||
- Переключение полноэкранного режима через F11 или кнопку Select на геймпаде.
 | 
			
		||||
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде.
 | 
			
		||||
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде.
 | 
			
		||||
- Закрытие приложения комбинацией клавиш Ctrl+Q.
 | 
			
		||||
- Сохранение и восстановление размера окна при перезапуске.
 | 
			
		||||
- Переключатель полноэкранного режима приложения.
 | 
			
		||||
- Пункт в контекстном меню «Открыть папку игры».
 | 
			
		||||
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam».
 | 
			
		||||
- Пункты в контекстном меню «Добавить в избранное» и «Удалить из избранного».
 | 
			
		||||
- Метод сортировки «Сначала избранное».
 | 
			
		||||
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена).
 | 
			
		||||
- Поддержка управления геймпадом в `QMenu` и `QComboBox`.
 | 
			
		||||
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме.
 | 
			
		||||
- Оверлей на кнопку Insert или Xbox/PS-кнопку на геймпаде для закрытия приложения, выключения, перезагрузки, перехода в спящий режим или переключения между сессиями.
 | 
			
		||||
- [Gamescope-сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt).
 | 
			
		||||
- Пресеты управления для DualShock 4 и DualSense.
 | 
			
		||||
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию отключена).
 | 
			
		||||
- Переводы пунктов настроек.
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Обновлены все иконки
 | 
			
		||||
- Переименована функция `_get_steam_home` в `get_steam_home`
 | 
			
		||||
- Переименован `steam_game` в `game_source`
 | 
			
		||||
- Логика контекстного меню вынесена в `ContextMenuManager`
 | 
			
		||||
- Бейдж Steam теперь открывает Steam Community
 | 
			
		||||
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
 | 
			
		||||
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна
 | 
			
		||||
- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке
 | 
			
		||||
- Установлена ширина бейджа в две трети ширины карточки
 | 
			
		||||
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites`
 | 
			
		||||
- Карточки теперь фокусируются в направлении движения стрелок или D-pad:
 | 
			
		||||
- Поддерживается удержание D-pad для непрерывного переключения карточек
 | 
			
		||||
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности
 | 
			
		||||
- D-pad больше не переключает вкладки (только кнопки RB/LB)
 | 
			
		||||
- Кнопка добавления игры больше не фокусируется
 | 
			
		||||
- Диалог добавления игры теперь открывается только в библиотеке
 | 
			
		||||
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt
 | 
			
		||||
- Размер карточек теперь меняется только при отпускании слайдера
 | 
			
		||||
- Слайдер теперь управляется через тригеры на геймпаде
 | 
			
		||||
- Диалог добавления игры теперь открывается на X, а не на Y
 | 
			
		||||
- Обновлены все иконки.
 | 
			
		||||
- Функция `_get_steam_home` переименована в `get_steam_home`.
 | 
			
		||||
- `steam_game` переименован в `game_source`.
 | 
			
		||||
- Логика контекстного меню вынесена в `ContextMenuManager`.
 | 
			
		||||
- Бейдж Steam теперь открывает Steam Community.
 | 
			
		||||
- Лицензия изменена с MIT на GPL-3.0 для совместимости с кодом legendary.
 | 
			
		||||
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна.
 | 
			
		||||
- Бейджи с карточек теперь отображаются и на странице с деталями, а не только в библиотеке.
 | 
			
		||||
- Установлена ширина бейджа в 2/3 ширины карточки.
 | 
			
		||||
- Бейджи источников (`Steam`, `EGS`, `PortProton`) отображаются только при активном фильтре `all` или `favorites`.
 | 
			
		||||
- Карточки теперь фокусируются в направлении движения стрелок или D-pad.
 | 
			
		||||
- Поддерживается удержание D-pad для непрерывного переключения карточек.
 | 
			
		||||
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности.
 | 
			
		||||
- D-pad больше не переключает вкладки (только кнопки RB/LB).
 | 
			
		||||
- Кнопка добавления игры больше не получает фокус.
 | 
			
		||||
- Диалог добавления игры открывается только в библиотеке.
 | 
			
		||||
- Все упоминания PortProtonQT заменены на PortProtonQt.
 | 
			
		||||
- Размер карточек меняется только при отпускании слайдера.
 | 
			
		||||
- Слайдер теперь управляется триггерами на геймпаде.
 | 
			
		||||
- Диалог добавления игры теперь открывается на X, а не на Y.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Возврат к теме «standard» при выборе несуществующей темы
 | 
			
		||||
- Корректное открытие контекстного меню
 | 
			
		||||
- Запуск приложения при отсутствии `exiftool`
 | 
			
		||||
- Предотвращено бесконечное обращение к `get_portproton_location`
 | 
			
		||||
- Обновлены ссылки на документацию в README
 | 
			
		||||
- Устранён traceback при отсутствии обложек (placeholder)
 | 
			
		||||
- Устранены утечки памяти при загрузке обложек
 | 
			
		||||
- Исправлены ошибки при подключении геймпада
 | 
			
		||||
- Предотвращено многократное открытие диалога добавления игры через геймпад
 | 
			
		||||
- Корректная обработка событий геймпада во время игры
 | 
			
		||||
- Убийсво всех процессов "зомби" при закрытии программы
 | 
			
		||||
- Возврат к теме «standard» при выборе несуществующей темы.
 | 
			
		||||
- Корректное открытие контекстного меню.
 | 
			
		||||
- Запуск приложения при отсутствии `exiftool`.
 | 
			
		||||
- Предотвращено бесконечное обращение к `get_portproton_location`.
 | 
			
		||||
- Обновлены ссылки на документацию в README.
 | 
			
		||||
- Исправлено падение при отсутствии обложек (placeholder).
 | 
			
		||||
- Устранены утечки памяти при загрузке обложек.
 | 
			
		||||
- Исправлены ошибки при подключении геймпада.
 | 
			
		||||
- Предотвращено многократное открытие диалога добавления игры через геймпад.
 | 
			
		||||
- Корректная обработка событий геймпада во время игры.
 | 
			
		||||
- Убийство всех процессов-зомби при закрытии программы.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
@@ -130,20 +261,20 @@
 | 
			
		||||
## [0.1.1] – 2025-05-17
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Алфавитная сортировка библиотеки
 | 
			
		||||
- Проверка переводов через yaspeller
 | 
			
		||||
- Сборка Fedora-пакета
 | 
			
		||||
- Сборка AppImage
 | 
			
		||||
- Алфавитная сортировка библиотеки.
 | 
			
		||||
- Проверка переводов через yaspeller.
 | 
			
		||||
- Сборка Fedora-пакета.
 | 
			
		||||
- Сборка AppImage.
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Удалён жёстко заданный размер окна
 | 
			
		||||
- Использован `icoextract` как Python-модуль
 | 
			
		||||
- Удалён жёстко заданный размер окна.
 | 
			
		||||
- Использован `icoextract` как Python-модуль.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Скрытие статус-бара
 | 
			
		||||
- Чтение списка Steam-игр
 | 
			
		||||
- Зависание GUI
 | 
			
		||||
- Сбой при повреждённом Steam
 | 
			
		||||
- Скрытие статус-бара.
 | 
			
		||||
- Чтение списка Steam-игр.
 | 
			
		||||
- Зависание GUI.
 | 
			
		||||
- Сбой при повреждённом Steam.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								README.md
									
									
									
									
									
								
							
							
						
						@@ -1,5 +1,5 @@
 | 
			
		||||
<div align="center">
 | 
			
		||||
  <img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64">
 | 
			
		||||
  <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 и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -51,11 +51,10 @@ pre-commit run --all-files
 | 
			
		||||
 | 
			
		||||
PortProtonQt использует код и зависимости от следующих проектов:
 | 
			
		||||
 | 
			
		||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html).
 | 
			
		||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://opensource.org/licenses/MIT).
 | 
			
		||||
- [PortProton 2.0](https://git.linux-gaming.ru/CastroFidel/PortProton_2.0) — библиотека для взаимодействия с PortProton, лицензия [MIT](https://opensource.org/licenses/MIT).
 | 
			
		||||
 | 
			
		||||
Полный текст лицензий см. в файлах [LICENSE](LICENSE), [LICENSE-icoextract](documentation/licenses/icoextract), [LICENSE-portproton](documentation/licenses/portproton), [LICENSE-legendary](documentation/licenses/legendary).
 | 
			
		||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
 | 
			
		||||
- [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).
 | 
			
		||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
 | 
			
		||||
 | 
			
		||||
> [!WARNING]
 | 
			
		||||
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						@@ -1,6 +1,6 @@
 | 
			
		||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
 | 
			
		||||
- [X] Добавить возможность управления с геймпада
 | 
			
		||||
- [ ] Добавить возможность управления с тачскрина
 | 
			
		||||
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
 | 
			
		||||
- [X] Добавить возможность управления с мыши и клавиатуры
 | 
			
		||||
- [X] Добавить систему тем [Документация](documentation/theme_guide)
 | 
			
		||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
 | 
			
		||||
@@ -11,19 +11,18 @@
 | 
			
		||||
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
 | 
			
		||||
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
 | 
			
		||||
- [X] Получать описания и названия игр из базы данных Steam
 | 
			
		||||
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
 | 
			
		||||
- [X] Получать обложки для игр из CDN Steam
 | 
			
		||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
 | 
			
		||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
 | 
			
		||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
			
		||||
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
			
		||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
 | 
			
		||||
- [X] Избавиться от вызовов yad
 | 
			
		||||
- [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0))
 | 
			
		||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
 | 
			
		||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
 | 
			
		||||
- [X] Добавить экранную клавиатуру в поиск
 | 
			
		||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
 | 
			
		||||
- [X] Добавить индикацию запуска приложения
 | 
			
		||||
- [X] Достигнуть паритета функциональности с Ingame
 | 
			
		||||
- [ ] Достигнуть паритета функциональности с PortProton
 | 
			
		||||
- [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов)
 | 
			
		||||
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
 | 
			
		||||
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
 | 
			
		||||
- [X] Добавить переводы в переопределения
 | 
			
		||||
@@ -42,6 +41,7 @@
 | 
			
		||||
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
 | 
			
		||||
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
 | 
			
		||||
- [X] Добавить поддержку версий Steam для Flatpak и Snap
 | 
			
		||||
- [X] Реализовать добавление игры как сторонней в Steam без перезапуска
 | 
			
		||||
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
 | 
			
		||||
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
 | 
			
		||||
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
 | 
			
		||||
@@ -49,7 +49,7 @@
 | 
			
		||||
- [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`
 | 
			
		||||
@@ -57,13 +57,11 @@
 | 
			
		||||
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
 | 
			
		||||
- [ ] Добавить поддержку GOG (?)
 | 
			
		||||
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
 | 
			
		||||
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?)
 | 
			
		||||
- [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] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
 | 
			
		||||
 
 | 
			
		||||
@@ -1,42 +1,52 @@
 | 
			
		||||
version: 1
 | 
			
		||||
 | 
			
		||||
script:
 | 
			
		||||
  # 1) чистим старый AppDir
 | 
			
		||||
  - rm -rf AppDir || true
 | 
			
		||||
  # 2) создаём структуру каталога
 | 
			
		||||
  - mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
 | 
			
		||||
  # 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
 | 
			
		||||
  - uv venv
 | 
			
		||||
  - uv pip install --no-cache-dir ../
 | 
			
		||||
  # 4) копируем всё из .venv в AppDir
 | 
			
		||||
  - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
 | 
			
		||||
  - cp -r share AppDir/usr
 | 
			
		||||
  # 5) чистим от ненужных модулей и бинарников
 | 
			
		||||
  - cp -r lib AppDir/usr
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*}
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
 | 
			
		||||
  - shopt -s extglob
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*)
 | 
			
		||||
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Network*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
 | 
			
		||||
AppDir:
 | 
			
		||||
  path: ./AppDir
 | 
			
		||||
 | 
			
		||||
  after_bundle:
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/man || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/doc || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/doc-base || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/info || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/help || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/gtk-doc || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/devhelp || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/examples || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/pkgconfig || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/bash-completion || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/pixmaps || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/mime || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/metainfo || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/include || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
 | 
			
		||||
    - find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
 | 
			
		||||
    - "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
 | 
			
		||||
    - find $TARGET_APPDIR -type d -empty -delete || true
 | 
			
		||||
  app_info:
 | 
			
		||||
    id: ru.linux_gaming.PortProtonQt
 | 
			
		||||
    name: PortProtonQt
 | 
			
		||||
    icon: ru.linux_gaming.PortProtonQt
 | 
			
		||||
    version: 0.1.3
 | 
			
		||||
    version: 0.1.8
 | 
			
		||||
    exec: usr/bin/python3
 | 
			
		||||
    exec_args: "-m portprotonqt.app $@"
 | 
			
		||||
 | 
			
		||||
  apt:
 | 
			
		||||
    arch: amd64
 | 
			
		||||
    sources:
 | 
			
		||||
      - sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
 | 
			
		||||
        key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
 | 
			
		||||
 | 
			
		||||
    include:
 | 
			
		||||
      - python3
 | 
			
		||||
      - python3-minimal
 | 
			
		||||
      - python3-pkg-resources
 | 
			
		||||
      - libopengl0
 | 
			
		||||
      - libk5crypto3
 | 
			
		||||
@@ -45,13 +55,26 @@ AppDir:
 | 
			
		||||
      - libxcb-cursor0
 | 
			
		||||
      - libimage-exiftool-perl
 | 
			
		||||
      - xdg-utils
 | 
			
		||||
    exclude: []
 | 
			
		||||
 | 
			
		||||
      - cabextract
 | 
			
		||||
      - curl
 | 
			
		||||
      - 7zip
 | 
			
		||||
      - unzip
 | 
			
		||||
      - unrar
 | 
			
		||||
    exclude:
 | 
			
		||||
      - "*-doc"
 | 
			
		||||
      - "*-man"
 | 
			
		||||
      - manpages
 | 
			
		||||
      - mandb
 | 
			
		||||
      - "*-dev"
 | 
			
		||||
      - "*-static"
 | 
			
		||||
      - "*-dbg"
 | 
			
		||||
      - "*-dbgsym"
 | 
			
		||||
  runtime:
 | 
			
		||||
    env:
 | 
			
		||||
      PYTHONHOME: '${APPDIR}/usr'
 | 
			
		||||
      PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
 | 
			
		||||
 | 
			
		||||
      PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
 | 
			
		||||
AppImage:
 | 
			
		||||
  sign-key: None
 | 
			
		||||
  arch: x86_64
 | 
			
		||||
  comp: zstd
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
pkgname=portprotonqt
 | 
			
		||||
pkgver=0.1.3
 | 
			
		||||
pkgver=0.1.8
 | 
			
		||||
pkgrel=1
 | 
			
		||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
 | 
			
		||||
arch=('any')
 | 
			
		||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
 | 
			
		||||
license=('GPL-3.0')
 | 
			
		||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
 | 
			
		||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
 | 
			
		||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
 | 
			
		||||
sha256sums=('SKIP')
 | 
			
		||||
@@ -20,4 +20,5 @@ package() {
 | 
			
		||||
    cd "$srcdir/PortProtonQt"
 | 
			
		||||
    python -m installer --destdir="$pkgdir" dist/*.whl
 | 
			
		||||
    cp -r build-aux/share "$pkgdir/usr/"
 | 
			
		||||
    cp -r build-aux/lib "$pkgdir/usr/"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ arch=('any')
 | 
			
		||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
 | 
			
		||||
license=('GPL-3.0')
 | 
			
		||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
 | 
			
		||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
 | 
			
		||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
 | 
			
		||||
sha256sums=('SKIP')
 | 
			
		||||
@@ -25,4 +25,5 @@ package() {
 | 
			
		||||
    cd "$srcdir/PortProtonQt"
 | 
			
		||||
    python -m installer --destdir="$pkgdir" dist/*.whl
 | 
			
		||||
    cp -r build-aux/share "$pkgdir/usr/"
 | 
			
		||||
    cp -r build-aux/lib "$pkgdir/usr/"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ BuildRequires:  python3-build
 | 
			
		||||
BuildRequires:  pyproject-rpm-macros
 | 
			
		||||
BuildRequires:  python3dist(setuptools)
 | 
			
		||||
BuildRequires:  git
 | 
			
		||||
BuildRequires:  systemd-rpm-macros
 | 
			
		||||
 | 
			
		||||
%description
 | 
			
		||||
%{summary}
 | 
			
		||||
@@ -33,6 +34,7 @@ Requires:       python3-babel
 | 
			
		||||
Requires:       python3-evdev
 | 
			
		||||
Requires:       python3-icoextract
 | 
			
		||||
Requires:       python3-numpy
 | 
			
		||||
Requires:       python3-websocket-client
 | 
			
		||||
Requires:       python3-orjson
 | 
			
		||||
Requires:       python3-psutil
 | 
			
		||||
Requires:       python3-pyside6
 | 
			
		||||
@@ -45,6 +47,11 @@ Requires:       python3-pillow
 | 
			
		||||
Requires:       perl-Image-ExifTool
 | 
			
		||||
Requires:       xdg-utils
 | 
			
		||||
Requires:       python3-beautifulsoup4
 | 
			
		||||
Requires:       cabextract
 | 
			
		||||
Requires:       gzip
 | 
			
		||||
Requires:       unzip
 | 
			
		||||
Requires:       curl
 | 
			
		||||
Requires:       unrar
 | 
			
		||||
 | 
			
		||||
%description -n python3-%{pypi_name}-git
 | 
			
		||||
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
 | 
			
		||||
@@ -63,11 +70,13 @@ cd %{oname}
 | 
			
		||||
%pyproject_install
 | 
			
		||||
%pyproject_save_files %{pypi_name}
 | 
			
		||||
cp -r build-aux/share %{buildroot}/usr/
 | 
			
		||||
cp -r build-aux/lib %{buildroot}/usr/
 | 
			
		||||
 | 
			
		||||
%files -n python3-%{pypi_name}-git -f %{pyproject_files}
 | 
			
		||||
%{_bindir}/%{pypi_name}
 | 
			
		||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
 | 
			
		||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
 | 
			
		||||
%{_udevrulesdir}/60-portprotonqt.rules
 | 
			
		||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
 | 
			
		||||
%{bash_completions_dir}/portprotonqt
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
%global pypi_name portprotonqt
 | 
			
		||||
%global pypi_version 0.1.3
 | 
			
		||||
%global pypi_version 0.1.8
 | 
			
		||||
%global oname PortProtonQt
 | 
			
		||||
%global _python_no_extras_requires 1
 | 
			
		||||
 | 
			
		||||
@@ -19,6 +19,7 @@ BuildRequires:  python3-build
 | 
			
		||||
BuildRequires:  pyproject-rpm-macros
 | 
			
		||||
BuildRequires:  python3dist(setuptools)
 | 
			
		||||
BuildRequires:  git
 | 
			
		||||
BuildRequires:  systemd-rpm-macros
 | 
			
		||||
 | 
			
		||||
%description
 | 
			
		||||
%{summary}
 | 
			
		||||
@@ -30,6 +31,7 @@ Requires:       python3-babel
 | 
			
		||||
Requires:       python3-evdev
 | 
			
		||||
Requires:       python3-icoextract
 | 
			
		||||
Requires:       python3-numpy
 | 
			
		||||
Requires:       python3-websocket-client
 | 
			
		||||
Requires:       python3-orjson
 | 
			
		||||
Requires:       python3-psutil
 | 
			
		||||
Requires:       python3-pyside6
 | 
			
		||||
@@ -42,6 +44,11 @@ Requires:       python3-pillow
 | 
			
		||||
Requires:       perl-Image-ExifTool
 | 
			
		||||
Requires:       xdg-utils
 | 
			
		||||
Requires:       python3-beautifulsoup4
 | 
			
		||||
Requires:       cabextract
 | 
			
		||||
Requires:       gzip
 | 
			
		||||
Requires:       unzip
 | 
			
		||||
Requires:       curl
 | 
			
		||||
Requires:       unrar
 | 
			
		||||
 | 
			
		||||
%description -n python3-%{pypi_name}
 | 
			
		||||
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
 | 
			
		||||
@@ -62,11 +69,13 @@ cd %{oname}
 | 
			
		||||
%pyproject_install
 | 
			
		||||
%pyproject_save_files %{pypi_name}
 | 
			
		||||
cp -r build-aux/share %{buildroot}/usr/
 | 
			
		||||
cp -r build-aux/lib %{buildroot}/usr/
 | 
			
		||||
 | 
			
		||||
%files -n python3-%{pypi_name} -f %{pyproject_files}
 | 
			
		||||
%{_bindir}/%{pypi_name}
 | 
			
		||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
 | 
			
		||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
 | 
			
		||||
%{_udevrulesdir}/60-portprotonqt.rules
 | 
			
		||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
 | 
			
		||||
%{bash_completions_dir}/portprotonqt
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								build-aux/lib/udev/rules.d/60-portprotonqt.rules
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
			
		||||
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess"
 | 
			
		||||
@@ -1,19 +1,30 @@
 | 
			
		||||
_portprotonqt() {
 | 
			
		||||
    local cur prev
 | 
			
		||||
    _init_completion || return
 | 
			
		||||
_portprotonqt_completions() {
 | 
			
		||||
    local cur prev opts
 | 
			
		||||
    COMPREPLY=()
 | 
			
		||||
    cur="${COMP_WORDS[COMP_CWORD]}"
 | 
			
		||||
    prev="${COMP_WORDS[COMP_CWORD-1]}"
 | 
			
		||||
 | 
			
		||||
    case $prev in
 | 
			
		||||
        --help|-h)
 | 
			
		||||
            return
 | 
			
		||||
    # Available options
 | 
			
		||||
    opts="--fullscreen --debug-level --help -h"
 | 
			
		||||
 | 
			
		||||
    # Debug level choices
 | 
			
		||||
    debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
 | 
			
		||||
 | 
			
		||||
    case "${prev}" in
 | 
			
		||||
        --debug-level)
 | 
			
		||||
            # Complete debug levels
 | 
			
		||||
            COMPREPLY=( $(compgen -W "${debug_levels}" -- ${cur}) )
 | 
			
		||||
            return 0
 | 
			
		||||
            ;;
 | 
			
		||||
        *)
 | 
			
		||||
            ;;
 | 
			
		||||
    esac
 | 
			
		||||
 | 
			
		||||
    if [[ "$cur" == -* ]]; then
 | 
			
		||||
        COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) )
 | 
			
		||||
    # Complete options
 | 
			
		||||
    if [[ ${cur} == -* ]]; then
 | 
			
		||||
        COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
 | 
			
		||||
        return 0
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    return 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
complete -F _portprotonqt portprotonqt
 | 
			
		||||
complete -F _portprotonqt_completions portprotonqt
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +0,0 @@
 | 
			
		||||
module.exports = {
 | 
			
		||||
    "endpoint": "https://git.linux-gaming.ru/api/v1",
 | 
			
		||||
    "gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
 | 
			
		||||
    "platform": "gitea",
 | 
			
		||||
    "onboardingConfigFileName": "renovate.json",
 | 
			
		||||
    "autodiscover": true,
 | 
			
		||||
    "optimizeForDisabled": true,
 | 
			
		||||
};
 | 
			
		||||
@@ -217,7 +217,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "watch_dogs 2",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "zero hour",
 | 
			
		||||
@@ -765,7 +765,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "lost ark",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "archeage unchained",
 | 
			
		||||
@@ -1021,7 +1021,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "farlight 84",
 | 
			
		||||
    "status": "Supported"
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "riders republic",
 | 
			
		||||
@@ -1436,8 +1436,8 @@
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blue protocol",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
    "normalized_name": "blue protocol star resonance",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dark and darker",
 | 
			
		||||
@@ -1777,7 +1777,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "supervive",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "splitgate 2",
 | 
			
		||||
@@ -4426,5 +4426,121 @@
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "carx street",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "warcos 2",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "karos classic",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dead island riptide",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "lineage",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "day of dragons",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "sonic rumble",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "black stigma",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "umamusume pretty derby",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dirt rally",
 | 
			
		||||
    "status": "Supported"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "minifighter",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "hide & hold out h2o",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "battlefield 6",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "ghost of tsushima director's cut",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "sword of justice",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blade & soul neo",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "the finals (cn)",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "tom clancy's rainbow six siege x",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dragonheir silent gods",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "the quinfall",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "redmatch 2",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blade & soul heroes",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blue archive",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "midnight murder club",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dungeon done",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "project wraith",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "solo leveling arise",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "freedom wars",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "open fortress",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "no more room in hell 2",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
							
								
								
									
										46702
									
								
								data/games_appid.json
									
									
									
									
									
								
							
							
						
						@@ -1,12 +1,296 @@
 | 
			
		||||
[
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "return alive",
 | 
			
		||||
    "slug": "return-alive"
 | 
			
		||||
    "normalized_title": "split/second",
 | 
			
		||||
    "slug": "split-second"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "warzone 2100",
 | 
			
		||||
    "slug": "warzone-2100"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "foundation",
 | 
			
		||||
    "slug": "foundation"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "земский собор [демо]",
 | 
			
		||||
    "slug": "zemskij-sobor-demo"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "crusader kings 3",
 | 
			
		||||
    "slug": "crusader-kings-3"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "nadir a grimdark deck builder",
 | 
			
		||||
    "slug": "nadir-a-grimdark-deck-builder"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "oriental empires",
 | 
			
		||||
    "slug": "oriental-empires"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "vampire the masquerade bloodlines 2",
 | 
			
		||||
    "slug": "vampire-the-masquerade-bloodlines-2"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "escape from duckov",
 | 
			
		||||
    "slug": "escape-from-duckov"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "xiii",
 | 
			
		||||
    "slug": "xiii"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "saints row 2",
 | 
			
		||||
    "slug": "saints-row-2"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "frozenheim",
 | 
			
		||||
    "slug": "frozenheim"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "saints row (2022)",
 | 
			
		||||
    "slug": "saints-row-2022"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "iron harvest",
 | 
			
		||||
    "slug": "iron-harvest"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "tom clancy's splinter cell blacklist",
 | 
			
		||||
    "slug": "tom-clancys-splinter-cell-blacklist"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "painkiller overdose",
 | 
			
		||||
    "slug": "painkiller-overdose"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "ancestors legacy",
 | 
			
		||||
    "slug": "ancestors-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "bye sweet carole",
 | 
			
		||||
    "slug": "bye-sweet-carole"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "painkiller black",
 | 
			
		||||
    "slug": "painkiller-black-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "hogwarts legacy",
 | 
			
		||||
    "slug": "hogwarts-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "active matter",
 | 
			
		||||
    "slug": "active-matter"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "tom clancy's splinter cell",
 | 
			
		||||
    "slug": "tom-clancys-splinter-cell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "sniper ghost warrior",
 | 
			
		||||
    "slug": "sniper-ghost-warrior"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "fate undiscovered realms",
 | 
			
		||||
    "slug": "fate-undiscovered-realms"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dying light the beast deluxe",
 | 
			
		||||
    "slug": "dying-light-the-beast-deluxe-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "spellforce platinum",
 | 
			
		||||
    "slug": "spellforce-platinum-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dirt rally 2.0 game of the year",
 | 
			
		||||
    "slug": "dirt-rally-2-0-game-of-the-year-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "deus ex human revolution director’s cut",
 | 
			
		||||
    "slug": "deus-ex-human-revolution-director-s-cut"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "freelancer",
 | 
			
		||||
    "slug": "freelancer"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "everspace",
 | 
			
		||||
    "slug": "everspace"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "blades of time limited",
 | 
			
		||||
    "slug": "blades-of-time-limited-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "chorus",
 | 
			
		||||
    "slug": "chorus"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "tom clancy's splinter cell pandora tomorrow",
 | 
			
		||||
    "slug": "tom-clancys-splinter-cell-pandora-tomorrow"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "the alters",
 | 
			
		||||
    "slug": "the-alters"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "hard reset redux",
 | 
			
		||||
    "slug": "hard-reset-redux"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "far cry 5",
 | 
			
		||||
    "slug": "far-cry-5"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "metal eden",
 | 
			
		||||
    "slug": "metal-eden"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "indiana jones and the great circle",
 | 
			
		||||
    "slug": "indiana-jones-and-the-great-circle"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "old world",
 | 
			
		||||
    "slug": "old-world"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "witchfire",
 | 
			
		||||
    "slug": "witchfire"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "prototype",
 | 
			
		||||
    "slug": "prototype"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "mandragora whispers of the witch tree",
 | 
			
		||||
    "slug": "mandragora-whispers-of-the-witch-tree"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "grand theft auto v (gta 5)",
 | 
			
		||||
    "slug": "grand-theft-auto-v-gta-5"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "lifeless planet premier",
 | 
			
		||||
    "slug": "lifeless-planet-premier-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "warcraft iii the frozen throne",
 | 
			
		||||
    "slug": "warcraft-iii-the-frozen-throne"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "star wars republic commando",
 | 
			
		||||
    "slug": "star-wars-republic-commando"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "hollow knight silksong",
 | 
			
		||||
    "slug": "hollow-knight-silksong"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "arma reforger",
 | 
			
		||||
    "slug": "arma-reforger"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "arma 3",
 | 
			
		||||
    "slug": "arma-3"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "astroneer",
 | 
			
		||||
    "slug": "astroneer"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "anno 2205",
 | 
			
		||||
    "slug": "anno-2205"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "anno 2070",
 | 
			
		||||
    "slug": "anno-2070"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "kompas 3d v23 / компас 3d v23",
 | 
			
		||||
    "slug": "kompas-3d-v23-kompas-3d-v23"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "ultrakill (early access)",
 | 
			
		||||
    "slug": "ultrakill-early-access"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "vintage story",
 | 
			
		||||
    "slug": "vintage-story"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "disco elysium the finul cut",
 | 
			
		||||
    "slug": "disco-elysium-the-finul-cut"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "warcraft iii reign of chaos",
 | 
			
		||||
    "slug": "warcraft-iii-reign-of-chaos"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dying light",
 | 
			
		||||
    "slug": "dying-light"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "лихо одноглазое",
 | 
			
		||||
    "slug": "liho-odnoglazoe"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "indika",
 | 
			
		||||
    "slug": "indika"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "no sleep for kaname date from ai the somnium files",
 | 
			
		||||
    "slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dead island 2",
 | 
			
		||||
    "slug": "dead-island-2"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dead island",
 | 
			
		||||
    "slug": "dead-island-definitive-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "wuchang fallen feathers",
 | 
			
		||||
    "slug": "wuchang-fallen-feathers"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "mindseye",
 | 
			
		||||
    "slug": "mindseye"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "alan wake",
 | 
			
		||||
    "slug": "alan-wake"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a",
 | 
			
		||||
    "slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "fifa 18",
 | 
			
		||||
    "slug": "fifa-18"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "eriksholm the stolen dream",
 | 
			
		||||
    "slug": "eriksholm-the-stolen-dream"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "caravan sandwitch",
 | 
			
		||||
    "slug": "caravan-sandwitch"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "expeditions a mudrunner game",
 | 
			
		||||
    "slug": "expeditions-a-mudrunner-game"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "#drive rally",
 | 
			
		||||
    "slug": "drive-rally"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "return alive",
 | 
			
		||||
    "slug": "return-alive"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "recore",
 | 
			
		||||
    "slug": "recore-definitive-edition"
 | 
			
		||||
@@ -91,10 +375,6 @@
 | 
			
		||||
    "normalized_title": "steins;gate the distant valhalla",
 | 
			
		||||
    "slug": "steins-gate-the-distant-valhalla"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "hogwarts legacy",
 | 
			
		||||
    "slug": "hogwarts-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "osu!",
 | 
			
		||||
    "slug": "osu"
 | 
			
		||||
@@ -107,10 +387,6 @@
 | 
			
		||||
    "normalized_title": "slitterhead",
 | 
			
		||||
    "slug": "slitterhead"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "indiana jones and the great circle",
 | 
			
		||||
    "slug": "indiana-jones-and-the-great-circle"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "crossout",
 | 
			
		||||
    "slug": "crossout"
 | 
			
		||||
@@ -191,10 +467,6 @@
 | 
			
		||||
    "normalized_title": "cardlife creative survival",
 | 
			
		||||
    "slug": "cardlife-creative-survival"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "kompas 3d v23 / компас 3d v23",
 | 
			
		||||
    "slug": "kompas-3d-v23-kompas-3d-v23"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "kompas 3d v24 / компас 3d v24 beta",
 | 
			
		||||
    "slug": "kompas-3d-v24-kompas-3d-v24-beta"
 | 
			
		||||
 
 | 
			
		||||
@@ -17,4 +17,6 @@ Generated-By:
 | 
			
		||||
start.sh
 | 
			
		||||
EGS
 | 
			
		||||
Stop Game
 | 
			
		||||
Fullscreen
 | 
			
		||||
Fulscreen
 | 
			
		||||
\t
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										474
									
								
								dev-scripts/appimage_clean.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						@@ -0,0 +1,474 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
"""
 | 
			
		||||
PySide6 Dependencies Analyzer with ldd support
 | 
			
		||||
Анализирует зависимости PySide6 модулей используя ldd для определения
 | 
			
		||||
реальных зависимостей скомпилированных библиотек.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import ast
 | 
			
		||||
import os
 | 
			
		||||
import sys
 | 
			
		||||
import subprocess
 | 
			
		||||
import re
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import Set, Dict, List
 | 
			
		||||
import argparse
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PySide6DependencyAnalyzer:
 | 
			
		||||
    def __init__(self, project_root: Path = None):
 | 
			
		||||
        # Системные библиотеки, которые нужно всегда оставлять
 | 
			
		||||
        self.system_libs = {
 | 
			
		||||
            'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
 | 
			
		||||
            'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus',
 | 
			
		||||
            'libQt6Svg'
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.critical_modules = {
 | 
			
		||||
            'QtSvg',
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.real_dependencies = {}
 | 
			
		||||
        self.used_modules_code = set()
 | 
			
		||||
        self.used_modules_ldd = set()
 | 
			
		||||
        self.all_required_modules = set()
 | 
			
		||||
        # Определяем корень проекта
 | 
			
		||||
        if project_root is None:
 | 
			
		||||
            # Корень проекта - две директории выше от скрипта
 | 
			
		||||
            self.project_root = Path(__file__).parent.parent
 | 
			
		||||
        else:
 | 
			
		||||
            self.project_root = project_root
 | 
			
		||||
 | 
			
		||||
        self.venv_path = self.project_root / ".venv"
 | 
			
		||||
        self.build_path = self.project_root / "build-aux"
 | 
			
		||||
 | 
			
		||||
    def find_python_files(self, directory: Path) -> List[Path]:
 | 
			
		||||
        """Находит все Python файлы в директории"""
 | 
			
		||||
        python_files = []
 | 
			
		||||
        for root, dirs, files in os.walk(directory):
 | 
			
		||||
            dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git'}]
 | 
			
		||||
 | 
			
		||||
            for file in files:
 | 
			
		||||
                if file.endswith('.py'):
 | 
			
		||||
                    python_files.append(Path(root) / file)
 | 
			
		||||
        return python_files
 | 
			
		||||
 | 
			
		||||
    def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]:
 | 
			
		||||
        """Находит все PySide6 библиотеки (.so файлы)"""
 | 
			
		||||
        libs = {}
 | 
			
		||||
 | 
			
		||||
        # Ищем venv в корне проекта
 | 
			
		||||
        venv_candidates = [
 | 
			
		||||
            self.venv_path,  # .venv
 | 
			
		||||
            self.project_root / "venv",
 | 
			
		||||
            self.project_root / ".virtualenv",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        pyside6_path = None
 | 
			
		||||
 | 
			
		||||
        # Пробуем найти PySide6 в venv
 | 
			
		||||
        for venv in venv_candidates:
 | 
			
		||||
            if venv.exists():
 | 
			
		||||
                # Ищем Python версию
 | 
			
		||||
                lib_path = venv / "lib"
 | 
			
		||||
                if lib_path.exists():
 | 
			
		||||
                    for python_dir in lib_path.iterdir():
 | 
			
		||||
                        if python_dir.name.startswith('python'):
 | 
			
		||||
                            candidate = python_dir / "site-packages" / "PySide6"
 | 
			
		||||
                            if candidate.exists():
 | 
			
		||||
                                pyside6_path = candidate
 | 
			
		||||
                                print(f"Найден PySide6 в: {candidate}")
 | 
			
		||||
                                break
 | 
			
		||||
                    if pyside6_path:
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
        if not pyside6_path:
 | 
			
		||||
            print(f"Предупреждение: PySide6 не найден в venv, проверяем AppDir...")
 | 
			
		||||
            # Если не нашли в venv, пробуем в AppDir
 | 
			
		||||
            if base_path:
 | 
			
		||||
                appdir_candidate = base_path / "AppDir/usr/local/lib"
 | 
			
		||||
                if appdir_candidate.exists():
 | 
			
		||||
                    for python_dir in appdir_candidate.iterdir():
 | 
			
		||||
                        if python_dir.name.startswith('python'):
 | 
			
		||||
                            candidate = python_dir / "dist-packages" / "PySide6"
 | 
			
		||||
                            if candidate.exists():
 | 
			
		||||
                                pyside6_path = candidate
 | 
			
		||||
                                print(f"Найден PySide6 в AppDir: {candidate}")
 | 
			
		||||
                                break
 | 
			
		||||
 | 
			
		||||
        if not pyside6_path:
 | 
			
		||||
            return libs
 | 
			
		||||
 | 
			
		||||
        # Ищем .so файлы модулей
 | 
			
		||||
        for so_file in pyside6_path.glob("Qt*.*.so"):
 | 
			
		||||
            module_name = so_file.stem.split('.')[0]  # QtCore.abi3.so -> QtCore
 | 
			
		||||
            if module_name.startswith('Qt'):
 | 
			
		||||
                libs[module_name] = so_file
 | 
			
		||||
 | 
			
		||||
        # Также ищем в подпапках
 | 
			
		||||
        for subdir in pyside6_path.iterdir():
 | 
			
		||||
            if subdir.is_dir() and subdir.name.startswith('Qt'):
 | 
			
		||||
                for so_file in subdir.glob("*.so*"):
 | 
			
		||||
                    if 'Qt' in so_file.name:
 | 
			
		||||
                        libs[subdir.name] = so_file
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
        return libs
 | 
			
		||||
 | 
			
		||||
    def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]:
 | 
			
		||||
        """Анализирует зависимости библиотеки с помощью ldd"""
 | 
			
		||||
        qt_deps = set()
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            result = subprocess.run(['ldd', str(lib_path)],
 | 
			
		||||
                                  capture_output=True, text=True, check=True)
 | 
			
		||||
 | 
			
		||||
            # Парсим вывод ldd и ищем Qt библиотеки
 | 
			
		||||
            for line in result.stdout.split('\n'):
 | 
			
		||||
                # Ищем строки вида: libQt6Core.so.6 => /path/to/lib
 | 
			
		||||
                match = re.search(r'libQt6(\w+)\.so', line)
 | 
			
		||||
                if match:
 | 
			
		||||
                    qt_module = f"Qt{match.group(1)}"
 | 
			
		||||
                    qt_deps.add(qt_module)
 | 
			
		||||
 | 
			
		||||
        except (subprocess.CalledProcessError, FileNotFoundError) as e:
 | 
			
		||||
            print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}")
 | 
			
		||||
 | 
			
		||||
        return qt_deps
 | 
			
		||||
 | 
			
		||||
    def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]:
 | 
			
		||||
        """Строит граф зависимостей на основе ldd анализа"""
 | 
			
		||||
        dependencies = {}
 | 
			
		||||
 | 
			
		||||
        print("Анализ реальных зависимостей с помощью ldd...")
 | 
			
		||||
        for module, lib_path in pyside_libs.items():
 | 
			
		||||
            print(f"  Анализируется {module}...")
 | 
			
		||||
            deps = self.analyze_ldd_dependencies(lib_path)
 | 
			
		||||
            dependencies[module] = deps
 | 
			
		||||
 | 
			
		||||
            if deps:
 | 
			
		||||
                print(f"    Зависимости: {', '.join(sorted(deps))}")
 | 
			
		||||
 | 
			
		||||
        return dependencies
 | 
			
		||||
 | 
			
		||||
    def analyze_file_imports(self, file_path: Path) -> Set[str]:
 | 
			
		||||
        """Анализирует один Python файл и возвращает используемые PySide6 модули"""
 | 
			
		||||
        modules = set()
 | 
			
		||||
        try:
 | 
			
		||||
            with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
 | 
			
		||||
                content = f.read()
 | 
			
		||||
 | 
			
		||||
            tree = ast.parse(content)
 | 
			
		||||
 | 
			
		||||
            for node in ast.walk(tree):
 | 
			
		||||
                if isinstance(node, ast.Import):
 | 
			
		||||
                    for alias in node.names:
 | 
			
		||||
                        if alias.name.startswith('PySide6.'):
 | 
			
		||||
                            module = alias.name.split('.', 2)[1]
 | 
			
		||||
                            if module.startswith('Qt'):
 | 
			
		||||
                                modules.add(module)
 | 
			
		||||
 | 
			
		||||
                elif isinstance(node, ast.ImportFrom):
 | 
			
		||||
                    if node.module and node.module.startswith('PySide6.'):
 | 
			
		||||
                        module = node.module.split('.', 2)[1]
 | 
			
		||||
                        if module.startswith('Qt'):
 | 
			
		||||
                            modules.add(module)
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print(f"Ошибка при анализе {file_path}: {e}")
 | 
			
		||||
 | 
			
		||||
        return modules
 | 
			
		||||
 | 
			
		||||
    def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]:
 | 
			
		||||
        """Получает все зависимости для набора модулей, используя граф зависимостей из ldd"""
 | 
			
		||||
        all_deps = set(modules)
 | 
			
		||||
 | 
			
		||||
        if not dependency_graph:
 | 
			
		||||
            return all_deps
 | 
			
		||||
 | 
			
		||||
        # Повторяем до тех пор, пока не найдем все транзитивные зависимости
 | 
			
		||||
        changed = True
 | 
			
		||||
        iteration = 0
 | 
			
		||||
        while changed and iteration < 10:  # Защита от бесконечного цикла
 | 
			
		||||
            changed = False
 | 
			
		||||
            current_deps = set(all_deps)
 | 
			
		||||
 | 
			
		||||
            for module in current_deps:
 | 
			
		||||
                if module in dependency_graph:
 | 
			
		||||
                    new_deps = dependency_graph[module] - all_deps
 | 
			
		||||
                    if new_deps:
 | 
			
		||||
                        all_deps.update(new_deps)
 | 
			
		||||
                        changed = True
 | 
			
		||||
 | 
			
		||||
            iteration += 1
 | 
			
		||||
 | 
			
		||||
        return all_deps
 | 
			
		||||
 | 
			
		||||
    def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict:
 | 
			
		||||
        """Анализирует весь проект"""
 | 
			
		||||
        python_files = self.find_python_files(project_path)
 | 
			
		||||
        print(f"Найдено {len(python_files)} Python файлов")
 | 
			
		||||
 | 
			
		||||
        # Анализ статических импортов
 | 
			
		||||
        used_modules_code = set()
 | 
			
		||||
        file_modules = {}
 | 
			
		||||
 | 
			
		||||
        for file_path in python_files:
 | 
			
		||||
            modules = self.analyze_file_imports(file_path)
 | 
			
		||||
            if modules:
 | 
			
		||||
                file_modules[str(file_path.relative_to(project_path))] = list(modules)
 | 
			
		||||
                used_modules_code.update(modules)
 | 
			
		||||
 | 
			
		||||
        print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}")
 | 
			
		||||
 | 
			
		||||
        # Поиск PySide6 библиотек
 | 
			
		||||
        search_base = appdir_path if appdir_path else project_path
 | 
			
		||||
        pyside_libs = self.find_pyside6_libs(search_base)
 | 
			
		||||
 | 
			
		||||
        if not pyside_libs:
 | 
			
		||||
            print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.")
 | 
			
		||||
            return {
 | 
			
		||||
                'error': 'PySide6 библиотеки не найдены',
 | 
			
		||||
                'analysis_method': 'failed',
 | 
			
		||||
                'found_libraries': 0,
 | 
			
		||||
                'directly_used_code': sorted(used_modules_code),
 | 
			
		||||
                'all_required': [],
 | 
			
		||||
                'removable': [],
 | 
			
		||||
                'available_modules': [],
 | 
			
		||||
                'file_usage': file_modules
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        print(f"Найдено {len(pyside_libs)} PySide6 библиотек")
 | 
			
		||||
 | 
			
		||||
        # Анализ реальных зависимостей с ldd
 | 
			
		||||
        real_dependencies = self.build_real_dependency_graph(pyside_libs)
 | 
			
		||||
 | 
			
		||||
        # Определяем модули, которые реально используются через ldd
 | 
			
		||||
        used_modules_ldd = set()
 | 
			
		||||
        for module in used_modules_code:
 | 
			
		||||
            if module in real_dependencies:
 | 
			
		||||
                used_modules_ldd.update(real_dependencies[module])
 | 
			
		||||
                used_modules_ldd.add(module)
 | 
			
		||||
 | 
			
		||||
        print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}")
 | 
			
		||||
 | 
			
		||||
        # Объединяем результаты анализа кода и ldd
 | 
			
		||||
        all_used_modules = used_modules_code | used_modules_ldd
 | 
			
		||||
 | 
			
		||||
        # Получаем все необходимые модули включая зависимости
 | 
			
		||||
        all_required = self.get_all_dependencies(all_used_modules, real_dependencies)
 | 
			
		||||
 | 
			
		||||
        # Все доступные PySide6 модули
 | 
			
		||||
        available_modules = set(pyside_libs.keys())
 | 
			
		||||
 | 
			
		||||
        # Модули, которые можно удалить
 | 
			
		||||
        removable = available_modules - all_required
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            'analysis_method': 'ldd + static analysis',
 | 
			
		||||
            'found_libraries': len(pyside_libs),
 | 
			
		||||
            'directly_used_code': sorted(used_modules_code),
 | 
			
		||||
            'directly_used_ldd': sorted(used_modules_ldd),
 | 
			
		||||
            'all_required': sorted(all_required),
 | 
			
		||||
            'removable': sorted(removable),
 | 
			
		||||
            'available_modules': sorted(available_modules),
 | 
			
		||||
            'file_usage': file_modules,
 | 
			
		||||
            'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()},
 | 
			
		||||
            'library_paths': {k: str(v) for k, v in pyside_libs.items()},
 | 
			
		||||
            'analysis_summary': {
 | 
			
		||||
                'total_modules': len(available_modules),
 | 
			
		||||
                'required_modules': len(all_required),
 | 
			
		||||
                'removable_modules': len(removable),
 | 
			
		||||
                'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%"
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str:
 | 
			
		||||
        """Генерирует обновленный AppImage рецепт с командами очистки"""
 | 
			
		||||
 | 
			
		||||
        # Читаем существующий рецепт
 | 
			
		||||
        try:
 | 
			
		||||
            with open(template_path, 'r', encoding='utf-8') as f:
 | 
			
		||||
                recipe_content = f.read()
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            print(f"Шаблон рецепта не найден: {template_path}")
 | 
			
		||||
            return ""
 | 
			
		||||
 | 
			
		||||
        # Генерируем новые команды очистки
 | 
			
		||||
        cleanup_lines = []
 | 
			
		||||
 | 
			
		||||
        # QML удаляем только если не используется
 | 
			
		||||
        qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'}
 | 
			
		||||
        if qml_modules.issubset(set(removable_modules)):
 | 
			
		||||
            cleanup_lines.append("  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/")
 | 
			
		||||
 | 
			
		||||
        # Инструменты разработки (всегда удаляем)
 | 
			
		||||
        cleanup_lines.append("  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}")
 | 
			
		||||
 | 
			
		||||
        # Модули для удаления
 | 
			
		||||
        if removable_modules:
 | 
			
		||||
            removable_filtered = [m for m in removable_modules if m not in self.critical_modules]
 | 
			
		||||
            if removable_filtered:
 | 
			
		||||
                modules_list = ','.join([f"{mod}*" for mod in sorted(removable_filtered)])
 | 
			
		||||
                cleanup_lines.append(f"  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
 | 
			
		||||
 | 
			
		||||
        # Генерируем команду для удаления нативных библиотек с сохранением нужных
 | 
			
		||||
        required_libs = set()
 | 
			
		||||
        for module in sorted(set(self.all_required_modules)):
 | 
			
		||||
            required_libs.add(f"libQt6{module.replace('Qt', '')}*")
 | 
			
		||||
 | 
			
		||||
        # Добавляем системные библиотеки
 | 
			
		||||
        for lib in self.system_libs:
 | 
			
		||||
            required_libs.add(f"{lib}*")
 | 
			
		||||
 | 
			
		||||
        keep_pattern = '|'.join(sorted(required_libs))
 | 
			
		||||
 | 
			
		||||
        cleanup_lines.extend([
 | 
			
		||||
            "  - shopt -s extglob",
 | 
			
		||||
            f"  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
 | 
			
		||||
        ])
 | 
			
		||||
 | 
			
		||||
        import re
 | 
			
		||||
 | 
			
		||||
        # Ищем весь блок команд очистки PySide6 (от первой rm до AppDir:)
 | 
			
		||||
        # Паттерн: после "  - cp -r lib AppDir/usr\n" идут команды rm, а затем "AppDir:"
 | 
			
		||||
        pattern = r'(  - cp -r lib AppDir/usr\n)((?:  - (?:rm|shopt).*\n)*?)(?=AppDir:)'
 | 
			
		||||
 | 
			
		||||
        match = re.search(pattern, recipe_content)
 | 
			
		||||
 | 
			
		||||
        if not match:
 | 
			
		||||
            print("ПРЕДУПРЕЖДЕНИЕ: Не удалось найти блок очистки в рецепте")
 | 
			
		||||
            print("Добавляем команды очистки перед блоком AppDir:")
 | 
			
		||||
 | 
			
		||||
            # Просто вставим команды перед AppDir:
 | 
			
		||||
            appdir_pos = recipe_content.find('AppDir:')
 | 
			
		||||
            if appdir_pos != -1:
 | 
			
		||||
                new_content = (
 | 
			
		||||
                    recipe_content[:appdir_pos] +
 | 
			
		||||
                    '\n'.join(cleanup_lines) + '\n' +
 | 
			
		||||
                    recipe_content[appdir_pos:]
 | 
			
		||||
                )
 | 
			
		||||
                return new_content
 | 
			
		||||
            else:
 | 
			
		||||
                print("ОШИБКА: Не найден блок AppDir: в рецепте")
 | 
			
		||||
                return ""
 | 
			
		||||
 | 
			
		||||
        # Создаем замену - группа 1 (cp -r lib) + новые команды очистки
 | 
			
		||||
        replacement = r'\1' + '\n'.join(cleanup_lines) + '\n'
 | 
			
		||||
 | 
			
		||||
        updated_recipe = re.sub(pattern, replacement, recipe_content, count=1)
 | 
			
		||||
 | 
			
		||||
        return updated_recipe
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
 | 
			
		||||
    parser.add_argument('project_path', nargs='?', default='.',
 | 
			
		||||
                        help='Путь к проекту для анализа (по умолчанию: текущая директория)')
 | 
			
		||||
    parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
 | 
			
		||||
    parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
 | 
			
		||||
    parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
 | 
			
		||||
    parser.add_argument('--venv', help='Путь к виртуальному окружению (по умолчанию: .venv в корне проекта)')
 | 
			
		||||
 | 
			
		||||
    args = parser.parse_args()
 | 
			
		||||
 | 
			
		||||
    project_path = Path(args.project_path).resolve()
 | 
			
		||||
    if not project_path.exists():
 | 
			
		||||
        print(f"Ошибка: путь {project_path} не существует")
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    appdir_path = Path(args.appdir).resolve() if args.appdir else None
 | 
			
		||||
    if appdir_path and not appdir_path.exists():
 | 
			
		||||
        print(f"Предупреждение: AppDir путь {appdir_path} не существует")
 | 
			
		||||
        appdir_path = None
 | 
			
		||||
 | 
			
		||||
    # Определяем корень проекта
 | 
			
		||||
    # Если запущен из подпапки проекта, ищем корень
 | 
			
		||||
    project_root = project_path
 | 
			
		||||
    if (project_path / ".git").exists() or (project_path / "pyproject.toml").exists():
 | 
			
		||||
        project_root = project_path
 | 
			
		||||
    else:
 | 
			
		||||
        # Пытаемся найти корень проекта
 | 
			
		||||
        current = project_path
 | 
			
		||||
        while current != current.parent:
 | 
			
		||||
            if (current / ".git").exists() or (current / "pyproject.toml").exists():
 | 
			
		||||
                project_root = current
 | 
			
		||||
                break
 | 
			
		||||
            current = current.parent
 | 
			
		||||
    
 | 
			
		||||
    print(f"Корень проекта: {project_root}")
 | 
			
		||||
 | 
			
		||||
    analyzer = PySide6DependencyAnalyzer(project_root=project_root)
 | 
			
		||||
    
 | 
			
		||||
    # Если указан custom venv путь
 | 
			
		||||
    if args.venv:
 | 
			
		||||
        analyzer.venv_path = Path(args.venv).resolve()
 | 
			
		||||
        print(f"Использую указанный venv: {analyzer.venv_path}")
 | 
			
		||||
    
 | 
			
		||||
    results = analyzer.analyze_project(project_path, appdir_path)
 | 
			
		||||
 | 
			
		||||
    # Сохраняем в анализатор для генерации команд
 | 
			
		||||
    analyzer.all_required_modules = set(results.get('all_required', []))
 | 
			
		||||
 | 
			
		||||
    # Выводим результаты
 | 
			
		||||
    print("\n" + "="*60)
 | 
			
		||||
    print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)")
 | 
			
		||||
    print("="*60)
 | 
			
		||||
 | 
			
		||||
    if 'error' in results:
 | 
			
		||||
        print(f"\nОШИБКА: {results['error']}")
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    print(f"\nМетод анализа: {results['analysis_method']}")
 | 
			
		||||
    print(f"Найдено библиотек: {results['found_libraries']}")
 | 
			
		||||
 | 
			
		||||
    if results['directly_used_code']:
 | 
			
		||||
        print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):")
 | 
			
		||||
        for module in results['directly_used_code']:
 | 
			
		||||
            print(f"  • {module}")
 | 
			
		||||
 | 
			
		||||
    if results['directly_used_ldd']:
 | 
			
		||||
        print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):")
 | 
			
		||||
        for module in results['directly_used_ldd']:
 | 
			
		||||
            print(f"  • {module}")
 | 
			
		||||
 | 
			
		||||
    print(f"\nВсе необходимые модули ({len(results['all_required'])}):")
 | 
			
		||||
    for module in results['all_required']:
 | 
			
		||||
        print(f"  • {module}")
 | 
			
		||||
 | 
			
		||||
    print(f"\nМодули, которые можно удалить ({len(results['removable'])}):")
 | 
			
		||||
    for module in results['removable']:
 | 
			
		||||
        print(f"  • {module}")
 | 
			
		||||
 | 
			
		||||
    print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
 | 
			
		||||
 | 
			
		||||
    if args.verbose and results['real_dependencies']:
 | 
			
		||||
        print(f"\nРеальные зависимости (ldd):")
 | 
			
		||||
        for module, deps in results['real_dependencies'].items():
 | 
			
		||||
            if deps:
 | 
			
		||||
                print(f"  {module} → {', '.join(deps)}")
 | 
			
		||||
 | 
			
		||||
    # Обновляем AppImage рецепт
 | 
			
		||||
    recipe_path = analyzer.build_path / "AppImageBuilder.yml"
 | 
			
		||||
    if recipe_path.exists():
 | 
			
		||||
        updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
 | 
			
		||||
        if updated_recipe:
 | 
			
		||||
            with open(recipe_path, 'w', encoding='utf-8') as f:
 | 
			
		||||
                f.write(updated_recipe)
 | 
			
		||||
            print(f"\nAppImage рецепт обновлен: {recipe_path}")
 | 
			
		||||
        else:
 | 
			
		||||
            print(f"\nОШИБКА: не удалось обновить рецепт")
 | 
			
		||||
    else:
 | 
			
		||||
        print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}")
 | 
			
		||||
 | 
			
		||||
    # Сохраняем результаты в JSON
 | 
			
		||||
    if args.output:
 | 
			
		||||
        with open(args.output, 'w', encoding='utf-8') as f:
 | 
			
		||||
            json.dump(results, f, ensure_ascii=False, indent=2)
 | 
			
		||||
        print(f"Результаты сохранены в: {args.output}")
 | 
			
		||||
 | 
			
		||||
    print("\n" + "="*60)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    main()
 | 
			
		||||
@@ -2,6 +2,7 @@
 | 
			
		||||
 | 
			
		||||
import argparse
 | 
			
		||||
import re
 | 
			
		||||
import subprocess
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from datetime import date
 | 
			
		||||
 | 
			
		||||
@@ -134,6 +135,12 @@ def main():
 | 
			
		||||
        print(f"Updated version from {old} to {new} in {len(updated)} files:")
 | 
			
		||||
        for p in sorted(updated):
 | 
			
		||||
            print(f" - {p}")
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            subprocess.run(["uv", "lock"], check=True)
 | 
			
		||||
            print("Regenerated uv.lock")
 | 
			
		||||
        except subprocess.CalledProcessError as e:
 | 
			
		||||
            print(f"Failed to regenerate uv.lock: {e}")
 | 
			
		||||
    else:
 | 
			
		||||
        print(f"No occurrences of version {old} found in specified files.")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,8 +3,9 @@
 | 
			
		||||
import sys
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
import re
 | 
			
		||||
import ast
 | 
			
		||||
 | 
			
		||||
# Запрещенные свойства
 | 
			
		||||
# Запрещенные QSS-свойства
 | 
			
		||||
FORBIDDEN_PROPERTIES = {
 | 
			
		||||
    "box-shadow",
 | 
			
		||||
    "backdrop-filter",
 | 
			
		||||
@@ -12,15 +13,55 @@ FORBIDDEN_PROPERTIES = {
 | 
			
		||||
    "text-shadow",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Запрещенные модули и функции
 | 
			
		||||
FORBIDDEN_MODULES = {
 | 
			
		||||
    "os",
 | 
			
		||||
    "subprocess",
 | 
			
		||||
    "shutil",
 | 
			
		||||
    "sys",
 | 
			
		||||
    "socket",
 | 
			
		||||
    "ctypes",
 | 
			
		||||
    "pathlib",
 | 
			
		||||
    "glob",
 | 
			
		||||
}
 | 
			
		||||
FORBIDDEN_FUNCTIONS = {
 | 
			
		||||
    "exec",
 | 
			
		||||
    "eval",
 | 
			
		||||
    "open",
 | 
			
		||||
    "__import__",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
def check_qss_files():
 | 
			
		||||
    has_errors = False
 | 
			
		||||
    for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
 | 
			
		||||
        with open(qss_file, "r") as f:
 | 
			
		||||
            content = f.read()
 | 
			
		||||
 | 
			
		||||
            # Проверка на запрещённые QSS-свойства
 | 
			
		||||
            for prop in FORBIDDEN_PROPERTIES:
 | 
			
		||||
                if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
 | 
			
		||||
                    print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}")
 | 
			
		||||
                    print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
 | 
			
		||||
                    has_errors = True
 | 
			
		||||
 | 
			
		||||
            # Проверка на опасные импорты и функции
 | 
			
		||||
            try:
 | 
			
		||||
                tree = ast.parse(content)
 | 
			
		||||
                for node in ast.walk(tree):
 | 
			
		||||
                    # Проверка импортов
 | 
			
		||||
                    if isinstance(node, (ast.Import, ast.ImportFrom)):
 | 
			
		||||
                        for name in node.names:
 | 
			
		||||
                            if name.name in FORBIDDEN_MODULES:
 | 
			
		||||
                                print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
 | 
			
		||||
                                has_errors = True
 | 
			
		||||
                    # Проверка вызовов функций
 | 
			
		||||
                    if isinstance(node, ast.Call):
 | 
			
		||||
                        if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
 | 
			
		||||
                            print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
 | 
			
		||||
                            has_errors = True
 | 
			
		||||
            except SyntaxError as e:
 | 
			
		||||
                print(f"ERROR: Syntax error in file {qss_file}: {e}")
 | 
			
		||||
                has_errors = True
 | 
			
		||||
 | 
			
		||||
    return has_errors
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
 
 | 
			
		||||
@@ -3,10 +3,11 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Contents
 | 
			
		||||
- [Overview](#overview)
 | 
			
		||||
- [Adding a New Translation](#adding-a-new-translation)
 | 
			
		||||
- [Updating Existing Translations](#updating-existing-translations)
 | 
			
		||||
- [Compiling Translations](#compiling-translations)
 | 
			
		||||
- [Overview](#-overview)
 | 
			
		||||
- [Adding a New Translation](#-adding-a-new-translation)
 | 
			
		||||
- [Updating Existing Translations](#-updating-existing-translations)
 | 
			
		||||
- [Compiling Translations](#-compiling-translations)
 | 
			
		||||
- [Spell Check](#-spell-check)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -20,9 +21,9 @@ Current translation status:
 | 
			
		||||
 | 
			
		||||
| Locale | Progress | Translated |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 194 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 194 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 of 194 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,10 +3,11 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Содержание
 | 
			
		||||
- [Обзор](#обзор)
 | 
			
		||||
- [Добавление нового перевода](#добавление-нового-перевода)
 | 
			
		||||
- [Обновление существующих переводов](#обновление-существующих-переводов)
 | 
			
		||||
- [Компиляция переводов](#компиляция-переводов)
 | 
			
		||||
- [Обзор](#-обзор)
 | 
			
		||||
- [Добавление нового перевода](#-добавление-нового-перевода)
 | 
			
		||||
- [Обновление существующих переводов](#-обновление-существующих-переводов)
 | 
			
		||||
- [Компиляция переводов](#-компиляция-переводов)
 | 
			
		||||
- [Проверка орфографии](#-проверка-орфографии)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -20,9 +21,9 @@
 | 
			
		||||
 | 
			
		||||
| Локаль | Прогресс | Переведено |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 194 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 194 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 из 194 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,15 +3,10 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Contents
 | 
			
		||||
- [Overview](#overview)
 | 
			
		||||
- [How It Works](#how-it-works)
 | 
			
		||||
  - [Data Priorities](#data-priorities)
 | 
			
		||||
  - [File Structure](#file-structure)
 | 
			
		||||
- [For Users](#for-users)
 | 
			
		||||
  - [Creating User Overrides](#creating-user-overrides)
 | 
			
		||||
  - [Example](#example)
 | 
			
		||||
- [For Developers](#for-developers)
 | 
			
		||||
  - [Adding Built-In Overrides](#adding-built-in-overrides)
 | 
			
		||||
- [Overview](#-overview)
 | 
			
		||||
- [How It Works](#-how-it-works)
 | 
			
		||||
- [For Users](#-for-users)
 | 
			
		||||
- [For Developers](#-for-developers)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,15 +3,10 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Содержание
 | 
			
		||||
- [Обзор](#обзор)
 | 
			
		||||
- [Как это работает](#как-это-работает)
 | 
			
		||||
  - [Приоритеты данных](#приоритеты-данных)
 | 
			
		||||
  - [Структура файлов](#структура-файлов)
 | 
			
		||||
- [Для пользователей](#для-пользователей)
 | 
			
		||||
  - [Создание пользовательских переопределений](#создание-пользовательских-переопределений)
 | 
			
		||||
  - [Пример](#пример)
 | 
			
		||||
- [Для разработчиков](#для-разработчиков)
 | 
			
		||||
  - [Добавление встроенных переопределений](#добавление-встроенных-переопределений)
 | 
			
		||||
- [Обзор](#-обзор)
 | 
			
		||||
- [Как это работает](#-как-это-работает)
 | 
			
		||||
- [Для пользователей](#-для-пользователей)
 | 
			
		||||
- [Для разработчиков](#-для-разработчиков)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,13 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Contents
 | 
			
		||||
- [Overview](#overview)
 | 
			
		||||
- [Creating the Theme Folder](#creating-the-theme-folder)
 | 
			
		||||
- [Style File](#style-file)
 | 
			
		||||
- [Metadata](#metadata)
 | 
			
		||||
- [Screenshots](#screenshots)
 | 
			
		||||
- [Fonts and Icons](#fonts-and-icons)
 | 
			
		||||
- [Overview](#-overview)
 | 
			
		||||
- [Creating the Theme Folder](#-creating-the-theme-folder)
 | 
			
		||||
- [Style File](#-style-file-stylespy)
 | 
			
		||||
- [Animation configuration](#-animation-configuration)
 | 
			
		||||
- [Metadata](#-metadata-metainfoini)
 | 
			
		||||
- [Screenshots](#-screenshots)
 | 
			
		||||
- [Fonts and Icons](#-fonts-and-icons-optional)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +46,163 @@ def custom_button_style(color1, color2):
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 🎥 Animation configuration
 | 
			
		||||
 | 
			
		||||
The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game cards:
 | 
			
		||||
 | 
			
		||||
```python
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Type of animation when entering or exiting the detail page
 | 
			
		||||
    # Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
 | 
			
		||||
    # Determines how the detail page appears and disappears
 | 
			
		||||
    "detail_page_animation_type": "fade",
 | 
			
		||||
 | 
			
		||||
    # Border width of the card in idle state (no hover or focus)
 | 
			
		||||
    # Affects the thickness of the border around the card when it's not selected
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Border width on hover
 | 
			
		||||
    # Increases the border thickness when the cursor is over the card
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    "hover_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Border width on focus (e.g., when selected via keyboard)
 | 
			
		||||
    # Increases the border thickness when the card is focused
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Minimum border width during pulsing animation
 | 
			
		||||
    # Determines the minimum border thickness during the "breathing" animation
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Maximum border width during pulsing animation
 | 
			
		||||
    # Determines the maximum border thickness during pulsing
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Duration of the border thickness animation (e.g., on hover or focus)
 | 
			
		||||
    # Affects the speed of transition from one border width to another
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Duration of one pulsing animation cycle
 | 
			
		||||
    # Determines how fast the border "pulses" between min and max values
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Duration of the gradient rotation animation
 | 
			
		||||
    # Affects how fast the gradient border rotates around the card
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Starting angle of the gradient (in degrees)
 | 
			
		||||
    # Determines the initial rotation point of the gradient at animation start
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Ending angle of the gradient (in degrees)
 | 
			
		||||
    # Determines the final rotation point of the gradient
 | 
			
		||||
    # Value 0 means a full 360° rotation
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # Type of card animation on hover or focus
 | 
			
		||||
    # Possible values: "gradient", "scale"
 | 
			
		||||
    # "gradient" enables a rotating gradient for the border, "scale" enlarges the card
 | 
			
		||||
    "card_animation_type": "gradient",
 | 
			
		||||
 | 
			
		||||
    # Card scale in idle state
 | 
			
		||||
    # Determines the base size of the card (1.0 = 100% of original size)
 | 
			
		||||
    # Value as a fraction (e.g., 1.0 for normal size)
 | 
			
		||||
    "default_scale": 1.0,
 | 
			
		||||
 | 
			
		||||
    # Card scale on hover
 | 
			
		||||
    # Increases the card size on hover
 | 
			
		||||
    # Value as a fraction (e.g., 1.1 = 110% of original size)
 | 
			
		||||
    "hover_scale": 1.1,
 | 
			
		||||
 | 
			
		||||
    # Card scale on focus (e.g., when selected via keyboard)
 | 
			
		||||
    # Increases the card size on focus
 | 
			
		||||
    # Value as a fraction (e.g., 1.05 = 105% of original size)
 | 
			
		||||
    "focus_scale": 1.05,
 | 
			
		||||
 | 
			
		||||
    # Duration of scale animation
 | 
			
		||||
    # Affects how fast the card changes size on hover or focus
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "scale_anim_duration": 200,
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for border thickness increase animation (on hover/focus)
 | 
			
		||||
    # Affects the "feel" of the animation (e.g., smooth acceleration or deceleration)
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad")
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for border thickness decrease animation (on hover/focus exit)
 | 
			
		||||
    # Affects the "feel" of returning to the default border width
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for scale increase animation (on hover/focus)
 | 
			
		||||
    # Affects the "feel" of the scaling animation (e.g., with a "bounce" effect)
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type
 | 
			
		||||
    "scale_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for scale decrease animation (on hover/focus exit)
 | 
			
		||||
    # Affects the "feel" of returning to the original scale
 | 
			
		||||
    "scale_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Gradient colors for animated border
 | 
			
		||||
    # List of dictionaries, each specifying position (0.0–1.0) and color in hex format
 | 
			
		||||
    # Affects the appearance of the border on hover or focus if card_animation_type="gradient"
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Starting color (cyan)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Color at 33% (orange)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # Color at 66% (purple)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # Ending color (back to cyan)
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Duration of fade animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page appearance with fade animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Duration of slide animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page sliding animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Duration of bounce animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page "bounce" animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "detail_page_bounce_duration": 400,
 | 
			
		||||
 | 
			
		||||
    # Duration of fade animation when exiting the detail page
 | 
			
		||||
    # Affects the speed of page disappearance with fade animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "detail_page_fade_duration_exit": 350,
 | 
			
		||||
 | 
			
		||||
    # Duration of slide animation when exiting the detail page
 | 
			
		||||
    # Affects the speed of page sliding animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "detail_page_slide_duration_exit": 500,
 | 
			
		||||
 | 
			
		||||
    # Duration of bounce animation when exiting the detail page
 | 
			
		||||
    # Affects the speed of page "compression" animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "detail_page_bounce_duration_exit": 400,
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for animations when entering the detail page
 | 
			
		||||
    # Applied to slide and bounce animations; affects the "feel" of movement
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for animations when exiting the detail page
 | 
			
		||||
    # Applied to slide and bounce animations; affects the "feel" of movement
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📝 Metadata (`metainfo.ini`)
 | 
			
		||||
 | 
			
		||||
```ini
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,13 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Содержание
 | 
			
		||||
- [Обзор](#обзор)
 | 
			
		||||
- [Создание папки темы](#создание-папки-темы)
 | 
			
		||||
- [Файл стилей](#файл-стилей)
 | 
			
		||||
- [Метаинформация](#метаинформация)
 | 
			
		||||
- [Скриншоты](#скриншоты)
 | 
			
		||||
- [Шрифты и иконки](#шрифты-и-иконки)
 | 
			
		||||
- [Обзор](#-обзор)
 | 
			
		||||
- [Создание папки темы](#-создание-папки-темы)
 | 
			
		||||
- [Файл стилей](#-файл-стилей-stylespy)
 | 
			
		||||
- [Конфигурация анимации](#-конфигурация-анимации)
 | 
			
		||||
- [Метаинформация](#-метаинформация-metainfoini)
 | 
			
		||||
- [Скриншоты](#-скриншоты)
 | 
			
		||||
- [Шрифты и иконки](#-шрифты-и-иконки-опционально)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +46,163 @@ def custom_button_style(color1, color2):
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 🎥 Конфигурация анимации
 | 
			
		||||
 | 
			
		||||
Словарь `GAME_CARD_ANIMATION` управляет всеми параметрами анимации для карточек игр:
 | 
			
		||||
 | 
			
		||||
```python
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Тип анимации при входе и выходе на детальную страницу
 | 
			
		||||
    # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
 | 
			
		||||
    # Определяет, как детальная страница появляется и исчезает
 | 
			
		||||
    "detail_page_animation_type": "fade",
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
 | 
			
		||||
    # Влияет на толщину рамки вокруг карточки, когда она не выделена
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при наведении курсора
 | 
			
		||||
    # Увеличивает толщину рамки, когда курсор находится над карточкой
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    "hover_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при фокусе (например, при выборе с клавиатуры)
 | 
			
		||||
    # Увеличивает толщину рамки, когда карточка в фокусе
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Минимальная ширина обводки во время пульсирующей анимации
 | 
			
		||||
    # Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Максимальная ширина обводки во время пульсирующей анимации
 | 
			
		||||
    # Определяет максимальную толщину рамки при пульсации
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
 | 
			
		||||
    # Влияет на скорость перехода от одной ширины обводки к другой
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Длительность одного цикла пульсирующей анимации
 | 
			
		||||
    # Определяет, как быстро рамка "пульсирует" между min и max значениями
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации вращения градиента
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Начальный угол градиента (в градусах)
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Конечный угол градиента (в градусах)
 | 
			
		||||
    # Определяет конечную точку вращения градиента
 | 
			
		||||
    # Значение 0 означает полный поворот на 360 градусов
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # Тип анимации для карточки при наведении или фокусе
 | 
			
		||||
    # Возможные значения: "gradient", "scale"
 | 
			
		||||
    # "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
 | 
			
		||||
    "card_animation_type": "gradient",
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки в состоянии покоя
 | 
			
		||||
    # Определяет базовый размер карточки (1.0 = 100% от исходного размера)
 | 
			
		||||
    # Значение в долях (например, 1.0 для нормального размера)
 | 
			
		||||
    "default_scale": 1.0,
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки при наведении курсора
 | 
			
		||||
    # Увеличивает размер карточки при наведении
 | 
			
		||||
    # Значение в долях (например, 1.1 = 110% от исходного размера)
 | 
			
		||||
    "hover_scale": 1.1,
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки при фокусе (например, при выборе с клавиатуры)
 | 
			
		||||
    # Увеличивает размер карточки при фокусе
 | 
			
		||||
    # Значение в долях (например, 1.05 = 105% от исходного размера)
 | 
			
		||||
    "focus_scale": 1.05,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации масштабирования
 | 
			
		||||
    # Влияет на скорость изменения размера карточки при наведении или фокусе
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "scale_anim_duration": 200,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
 | 
			
		||||
    # Влияет на "чувство" анимации (например, плавное ускорение или замедление)
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
 | 
			
		||||
    # Влияет на "чувство" возврата к исходной ширине обводки
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
 | 
			
		||||
    # Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    "scale_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
 | 
			
		||||
    # Влияет на "чувство" возврата к исходному масштабу
 | 
			
		||||
    "scale_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Цвета градиента для анимированной обводки
 | 
			
		||||
    # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex
 | 
			
		||||
    # Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Начальный цвет (циан)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # Конечный цвет (возвращение к циану)
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость появления страницы при fade-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость скольжения страницы при slide-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость "прыжка" страницы при bounce-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_bounce_duration": 400,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость исчезновения страницы при fade-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_fade_duration_exit": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость скольжения страницы при slide-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_slide_duration_exit": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость "сжатия" страницы при bounce-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_bounce_duration_exit": 400,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при входе на детальную страницу
 | 
			
		||||
    # Применяется к slide и bounce анимациям, влияет на "чувство" движения
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при выходе из детальной страницы
 | 
			
		||||
    # Применяется к slide и bounce анимациям, влияет на "чувство" движения
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📝 Метаинформация (`metainfo.ini`)
 | 
			
		||||
 | 
			
		||||
```ini
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										387
									
								
								portprotonqt/animations.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,387 @@
 | 
			
		||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
 | 
			
		||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
 | 
			
		||||
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
class SafeOpacityEffect(QGraphicsOpacityEffect):
 | 
			
		||||
    def __init__(self, parent=None, disable_at_full=True):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.disable_at_full = disable_at_full
 | 
			
		||||
 | 
			
		||||
    def setOpacity(self, opacity: float):
 | 
			
		||||
        opacity = max(0.0, min(1.0, opacity))
 | 
			
		||||
        super().setOpacity(opacity)
 | 
			
		||||
        if opacity < 1.0:
 | 
			
		||||
            self.setEnabled(True)
 | 
			
		||||
        elif self.disable_at_full:
 | 
			
		||||
            self.setEnabled(False)
 | 
			
		||||
 | 
			
		||||
class GameCardAnimations:
 | 
			
		||||
    def __init__(self, game_card, theme=None):
 | 
			
		||||
        self.game_card = game_card
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.thickness_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self.gradient_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self.scale_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self.pulse_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self._isPulseAnimationConnected = False
 | 
			
		||||
 | 
			
		||||
    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"])
 | 
			
		||||
 | 
			
		||||
        animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
        if animation_type == "gradient":
 | 
			
		||||
            self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
            self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
        elif animation_type == "scale":
 | 
			
		||||
            self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
 | 
			
		||||
            self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
 | 
			
		||||
 | 
			
		||||
    def start_pulse_animation(self):
 | 
			
		||||
        """Start pulse animation for border width when hovered or focused."""
 | 
			
		||||
        if not (self.game_card._hovered or self.game_card._focused):
 | 
			
		||||
            return
 | 
			
		||||
        if self.pulse_anim:
 | 
			
		||||
            self.pulse_anim.stop()
 | 
			
		||||
        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.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.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 self.thickness_anim:
 | 
			
		||||
            self.setup_animations()
 | 
			
		||||
 | 
			
		||||
        animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
 | 
			
		||||
        if self.thickness_anim:
 | 
			
		||||
            self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
                self.thickness_anim.finished.disconnect(self.start_pulse_animation)
 | 
			
		||||
                self._isPulseAnimationConnected = False
 | 
			
		||||
            self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
 | 
			
		||||
            self.thickness_anim.setStartValue(self.game_card._borderWidth)
 | 
			
		||||
            self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
 | 
			
		||||
            self.thickness_anim.finished.connect(self.start_pulse_animation)
 | 
			
		||||
            self._isPulseAnimationConnected = True
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
        if animation_type == "gradient":
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
            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.setLoopCount(-1)
 | 
			
		||||
            self.gradient_anim.start()
 | 
			
		||||
        elif animation_type == "scale":
 | 
			
		||||
            if self.scale_anim:
 | 
			
		||||
                self.scale_anim.stop()
 | 
			
		||||
            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.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
 | 
			
		||||
            self.scale_anim.setStartValue(self.game_card._scale)
 | 
			
		||||
            self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_scale"])
 | 
			
		||||
            self.scale_anim.start()
 | 
			
		||||
 | 
			
		||||
    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")
 | 
			
		||||
            if animation_type == "gradient":
 | 
			
		||||
                if self.gradient_anim:
 | 
			
		||||
                    self.gradient_anim.stop()
 | 
			
		||||
                    self.gradient_anim = None
 | 
			
		||||
            elif animation_type == "scale":
 | 
			
		||||
                if self.scale_anim:
 | 
			
		||||
                    self.scale_anim.stop()
 | 
			
		||||
                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.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
 | 
			
		||||
                self.scale_anim.setStartValue(self.game_card._scale)
 | 
			
		||||
                self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
 | 
			
		||||
                self.scale_anim.start()
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
                if self._isPulseAnimationConnected:
 | 
			
		||||
                    self.thickness_anim.finished.disconnect(self.start_pulse_animation)
 | 
			
		||||
                    self._isPulseAnimationConnected = False
 | 
			
		||||
                self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
 | 
			
		||||
                self.thickness_anim.setStartValue(self.game_card._borderWidth)
 | 
			
		||||
                self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
 | 
			
		||||
                self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_focus_in_event(self):
 | 
			
		||||
        """Handle focus in event animations."""
 | 
			
		||||
        if not self.game_card._hovered:
 | 
			
		||||
            self.game_card._focused = True
 | 
			
		||||
            self.game_card.focusChanged.emit(self.game_card.name, True)
 | 
			
		||||
 | 
			
		||||
            if not self.thickness_anim:
 | 
			
		||||
                self.setup_animations()
 | 
			
		||||
 | 
			
		||||
            animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
                if self._isPulseAnimationConnected:
 | 
			
		||||
                    self.thickness_anim.finished.disconnect(self.start_pulse_animation)
 | 
			
		||||
                    self._isPulseAnimationConnected = False
 | 
			
		||||
                self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
 | 
			
		||||
                self.thickness_anim.setStartValue(self.game_card._borderWidth)
 | 
			
		||||
                self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
 | 
			
		||||
                self.thickness_anim.finished.connect(self.start_pulse_animation)
 | 
			
		||||
                self._isPulseAnimationConnected = True
 | 
			
		||||
                self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
            if animation_type == "gradient":
 | 
			
		||||
                if self.gradient_anim:
 | 
			
		||||
                    self.gradient_anim.stop()
 | 
			
		||||
                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.setLoopCount(-1)
 | 
			
		||||
                self.gradient_anim.start()
 | 
			
		||||
            elif animation_type == "scale":
 | 
			
		||||
                if self.scale_anim:
 | 
			
		||||
                    self.scale_anim.stop()
 | 
			
		||||
                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.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
 | 
			
		||||
                self.scale_anim.setStartValue(self.game_card._scale)
 | 
			
		||||
                self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_scale"])
 | 
			
		||||
                self.scale_anim.start()
 | 
			
		||||
 | 
			
		||||
    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")
 | 
			
		||||
            if animation_type == "gradient":
 | 
			
		||||
                if self.gradient_anim:
 | 
			
		||||
                    self.gradient_anim.stop()
 | 
			
		||||
                    self.gradient_anim = None
 | 
			
		||||
            elif animation_type == "scale":
 | 
			
		||||
                if self.scale_anim:
 | 
			
		||||
                    self.scale_anim.stop()
 | 
			
		||||
                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.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
 | 
			
		||||
                self.scale_anim.setStartValue(self.game_card._scale)
 | 
			
		||||
                self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
 | 
			
		||||
                self.scale_anim.start()
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
                if self._isPulseAnimationConnected:
 | 
			
		||||
                    self.thickness_anim.finished.disconnect(self.start_pulse_animation)
 | 
			
		||||
                    self._isPulseAnimationConnected = False
 | 
			
		||||
                self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
 | 
			
		||||
                self.thickness_anim.setStartValue(self.game_card._borderWidth)
 | 
			
		||||
                self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
 | 
			
		||||
                self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
    def paint_border(self, painter: QPainter):
 | 
			
		||||
        if not painter.isActive():
 | 
			
		||||
            logger.debug("Painter is not active; skipping border paint")
 | 
			
		||||
            return
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
        pen = QPen()
 | 
			
		||||
        pen.setWidth(self.game_card._borderWidth)
 | 
			
		||||
        animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
        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"]:
 | 
			
		||||
                gradient.setColorAt(stop["position"], QColor(stop["color"]))
 | 
			
		||||
            pen.setBrush(QBrush(gradient))
 | 
			
		||||
        else:
 | 
			
		||||
            pen.setColor(QColor(0, 0, 0, 0))
 | 
			
		||||
        painter.setPen(pen)
 | 
			
		||||
        radius = 18 * self.game_card._scale
 | 
			
		||||
        bw = round(self.game_card._borderWidth / 2)
 | 
			
		||||
        rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
 | 
			
		||||
        if rect.isEmpty():
 | 
			
		||||
            return
 | 
			
		||||
        painter.drawRoundedRect(rect, radius, radius)
 | 
			
		||||
 | 
			
		||||
class DetailPageAnimations:
 | 
			
		||||
    def __init__(self, main_window, theme=None):
 | 
			
		||||
        self.main_window = main_window
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
 | 
			
		||||
 | 
			
		||||
    def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
 | 
			
		||||
        """Animate the detail page based on theme settings."""
 | 
			
		||||
        animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
 | 
			
		||||
        duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
 | 
			
		||||
 | 
			
		||||
        if animation_type == "fade":
 | 
			
		||||
            original_effect = detail_page.graphicsEffect()
 | 
			
		||||
            opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
 | 
			
		||||
            opacity_effect.setOpacity(0.0)
 | 
			
		||||
            detail_page.setGraphicsEffect(opacity_effect)
 | 
			
		||||
            animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
 | 
			
		||||
            animation.setDuration(duration)
 | 
			
		||||
            animation.setStartValue(0.0)
 | 
			
		||||
            animation.setEndValue(0.999)
 | 
			
		||||
            animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
            self.animations[detail_page] = animation
 | 
			
		||||
            def restore_effect():
 | 
			
		||||
                try:
 | 
			
		||||
                    detail_page.setGraphicsEffect(original_effect) # type: ignore
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    logger.warning("Original effect already deleted")
 | 
			
		||||
            animation.finished.connect(restore_effect)
 | 
			
		||||
            animation.finished.connect(load_image_and_restore_effect)
 | 
			
		||||
            animation.finished.connect(opacity_effect.deleteLater)
 | 
			
		||||
        elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
 | 
			
		||||
            duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
 | 
			
		||||
            easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
 | 
			
		||||
            start_pos = {
 | 
			
		||||
                "slide_left": QPoint(self.main_window.width(), 0),
 | 
			
		||||
                "slide_right": QPoint(-self.main_window.width(), 0),
 | 
			
		||||
                "slide_up": QPoint(0, self.main_window.height()),
 | 
			
		||||
                "slide_down": QPoint(0, -self.main_window.height())
 | 
			
		||||
            }[animation_type]
 | 
			
		||||
            detail_page.move(start_pos)
 | 
			
		||||
            animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
 | 
			
		||||
            animation.setDuration(duration)
 | 
			
		||||
            animation.setStartValue(start_pos)
 | 
			
		||||
            animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
 | 
			
		||||
            animation.setEasingCurve(easing_curve)
 | 
			
		||||
            animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
            self.animations[detail_page] = animation
 | 
			
		||||
            animation.finished.connect(cleanup_animation)
 | 
			
		||||
            animation.finished.connect(load_image_and_restore_effect)
 | 
			
		||||
        elif animation_type == "bounce":
 | 
			
		||||
            duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
 | 
			
		||||
            easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
 | 
			
		||||
            detail_page.setWindowOpacity(0.0)
 | 
			
		||||
            opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
 | 
			
		||||
            opacity_anim.setDuration(duration)
 | 
			
		||||
            opacity_anim.setStartValue(0.0)
 | 
			
		||||
            opacity_anim.setEndValue(1.0)
 | 
			
		||||
            initial_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
 | 
			
		||||
                                detail_page.width() // 2, detail_page.height() // 2)
 | 
			
		||||
            final_rect = detail_page.geometry()
 | 
			
		||||
            geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
 | 
			
		||||
            geometry_anim.setDuration(duration)
 | 
			
		||||
            geometry_anim.setStartValue(initial_rect)
 | 
			
		||||
            geometry_anim.setEndValue(final_rect)
 | 
			
		||||
            geometry_anim.setEasingCurve(easing_curve)
 | 
			
		||||
            group_anim = QParallelAnimationGroup()
 | 
			
		||||
            group_anim.addAnimation(opacity_anim)
 | 
			
		||||
            group_anim.addAnimation(geometry_anim)
 | 
			
		||||
            group_anim.finished.connect(load_image_and_restore_effect)
 | 
			
		||||
            group_anim.finished.connect(cleanup_animation)
 | 
			
		||||
            group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
            self.animations[detail_page] = group_anim
 | 
			
		||||
 | 
			
		||||
    def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
 | 
			
		||||
        """Animate the detail page exit based on theme settings."""
 | 
			
		||||
        try:
 | 
			
		||||
            animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
 | 
			
		||||
 | 
			
		||||
            # Safely stop and remove any existing animation
 | 
			
		||||
            if detail_page in self.animations:
 | 
			
		||||
                try:
 | 
			
		||||
                    animation = self.animations[detail_page]
 | 
			
		||||
                    if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
 | 
			
		||||
                        animation.stop()
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    logger.warning("Animation already deleted for page")
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(f"Error stopping existing animation: {e}", exc_info=True)
 | 
			
		||||
                finally:
 | 
			
		||||
                    self.animations.pop(detail_page, None)
 | 
			
		||||
 | 
			
		||||
            # Define animation based on type
 | 
			
		||||
            if animation_type == "fade":
 | 
			
		||||
                duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
 | 
			
		||||
                original_effect = detail_page.graphicsEffect()
 | 
			
		||||
                opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
 | 
			
		||||
                opacity_effect.setOpacity(0.999)
 | 
			
		||||
                detail_page.setGraphicsEffect(opacity_effect)
 | 
			
		||||
                animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
 | 
			
		||||
                animation.setDuration(duration)
 | 
			
		||||
                animation.setStartValue(0.999)
 | 
			
		||||
                animation.setEndValue(0.0)
 | 
			
		||||
                animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
                self.animations[detail_page] = animation
 | 
			
		||||
                def restore_and_cleanup():
 | 
			
		||||
                    try:
 | 
			
		||||
                        detail_page.setGraphicsEffect(original_effect) # type: ignore
 | 
			
		||||
                    except RuntimeError:
 | 
			
		||||
                        logger.debug("Original effect already deleted")
 | 
			
		||||
                    cleanup_callback()
 | 
			
		||||
                animation.finished.connect(restore_and_cleanup)
 | 
			
		||||
                animation.finished.connect(opacity_effect.deleteLater)
 | 
			
		||||
            elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
 | 
			
		||||
                duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
 | 
			
		||||
                easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
 | 
			
		||||
                end_pos = {
 | 
			
		||||
                    "slide_left": QPoint(-self.main_window.width(), 0),
 | 
			
		||||
                    "slide_right": QPoint(self.main_window.width(), 0),
 | 
			
		||||
                    "slide_up": QPoint(0, self.main_window.height()),
 | 
			
		||||
                    "slide_down": QPoint(0, -self.main_window.height())
 | 
			
		||||
                }[animation_type]
 | 
			
		||||
                animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
 | 
			
		||||
                animation.setDuration(duration)
 | 
			
		||||
                animation.setStartValue(detail_page.pos())
 | 
			
		||||
                animation.setEndValue(end_pos)
 | 
			
		||||
                animation.setEasingCurve(easing_curve)
 | 
			
		||||
                animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
                self.animations[detail_page] = animation
 | 
			
		||||
                animation.finished.connect(cleanup_callback)
 | 
			
		||||
            elif animation_type == "bounce":
 | 
			
		||||
                duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
 | 
			
		||||
                easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
 | 
			
		||||
                opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
 | 
			
		||||
                opacity_anim.setDuration(duration)
 | 
			
		||||
                opacity_anim.setStartValue(1.0)
 | 
			
		||||
                opacity_anim.setEndValue(0.0)
 | 
			
		||||
                final_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
 | 
			
		||||
                                  detail_page.width() // 2, detail_page.height() // 2)
 | 
			
		||||
                geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
 | 
			
		||||
                geometry_anim.setDuration(duration)
 | 
			
		||||
                geometry_anim.setStartValue(detail_page.geometry())
 | 
			
		||||
                geometry_anim.setEndValue(final_rect)
 | 
			
		||||
                geometry_anim.setEasingCurve(easing_curve)
 | 
			
		||||
                group_anim = QParallelAnimationGroup()
 | 
			
		||||
                group_anim.addAnimation(opacity_anim)
 | 
			
		||||
                group_anim.addAnimation(geometry_anim)
 | 
			
		||||
                group_anim.finished.connect(cleanup_callback)
 | 
			
		||||
                group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
                self.animations[detail_page] = group_anim
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
 | 
			
		||||
            self.animations.pop(detail_page, None)
 | 
			
		||||
            cleanup_callback()
 | 
			
		||||
@@ -1,86 +1,163 @@
 | 
			
		||||
import sys
 | 
			
		||||
import os
 | 
			
		||||
import subprocess
 | 
			
		||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
 | 
			
		||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
 | 
			
		||||
from PySide6.QtWidgets import QApplication
 | 
			
		||||
from PySide6.QtGui import QIcon
 | 
			
		||||
from portprotonqt.main_window import MainWindow
 | 
			
		||||
from portprotonqt.tray import SystemTray
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.cli import parse_args
 | 
			
		||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
from portprotonqt.main_window import MainWindow
 | 
			
		||||
from portprotonqt.config_utils import (
 | 
			
		||||
    save_fullscreen_config,
 | 
			
		||||
    read_fullscreen_config,
 | 
			
		||||
    get_portproton_start_command
 | 
			
		||||
)
 | 
			
		||||
from portprotonqt.logger import get_logger, setup_logger
 | 
			
		||||
from portprotonqt.cli import parse_args
 | 
			
		||||
 | 
			
		||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
 | 
			
		||||
__app_name__ = "PortProtonQt"
 | 
			
		||||
__app_version__ = "0.1.3"
 | 
			
		||||
__app_version__ = "0.1.8"
 | 
			
		||||
 | 
			
		||||
def get_version():
 | 
			
		||||
    try:
 | 
			
		||||
        commit = subprocess.check_output(
 | 
			
		||||
            ["git", "rev-parse", "--short", "HEAD"],
 | 
			
		||||
            stderr=subprocess.DEVNULL,
 | 
			
		||||
        ).decode("utf-8").strip()
 | 
			
		||||
        return f"{__app_version__} ({commit})"
 | 
			
		||||
    except (subprocess.CalledProcessError, FileNotFoundError, OSError):
 | 
			
		||||
        return __app_version__
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    os.environ["PW_CLI"] = "1"
 | 
			
		||||
    os.environ["PROCESS_LOG"] = "1"
 | 
			
		||||
    os.environ["START_FROM_STEAM"] = "1"
 | 
			
		||||
 | 
			
		||||
    start_sh = get_portproton_start_command()
 | 
			
		||||
 | 
			
		||||
    if start_sh is None:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    subprocess.run(start_sh + ["cli", "--initial"])
 | 
			
		||||
 | 
			
		||||
    app = QApplication(sys.argv)
 | 
			
		||||
    app.setWindowIcon(QIcon.fromTheme(__app_id__))
 | 
			
		||||
    app.setDesktopFileName(__app_id__)
 | 
			
		||||
    app.setApplicationName(__app_name__)
 | 
			
		||||
    app.setApplicationVersion(__app_version__)
 | 
			
		||||
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
    setup_logger(args.debug_level)
 | 
			
		||||
    logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
    # --- Single-instance logic ---
 | 
			
		||||
    server_name = __app_id__
 | 
			
		||||
    socket = QLocalSocket()
 | 
			
		||||
    socket.connectToServer(server_name)
 | 
			
		||||
 | 
			
		||||
    if socket.waitForConnected(200):
 | 
			
		||||
        # Второй экземпляр — передаём команду первому
 | 
			
		||||
        fullscreen = args.fullscreen or read_fullscreen_config()
 | 
			
		||||
        msg = b"show:fullscreen" if fullscreen else b"show"
 | 
			
		||||
        socket.write(msg)
 | 
			
		||||
        socket.flush()
 | 
			
		||||
        socket.waitForBytesWritten(500)
 | 
			
		||||
        socket.disconnectFromServer()
 | 
			
		||||
        logger.info("Restored existing instance from tray")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Если старый сокет остался — удалить
 | 
			
		||||
    QLocalServer.removeServer(server_name)
 | 
			
		||||
 | 
			
		||||
    local_server = QLocalServer()
 | 
			
		||||
    if not local_server.listen(server_name):
 | 
			
		||||
        logger.warning(f"Failed to start local server: {local_server.errorString()}")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # --- Qt translations ---
 | 
			
		||||
    system_locale = QLocale.system()
 | 
			
		||||
    qt_translator = QTranslator()
 | 
			
		||||
    translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
 | 
			
		||||
    if qt_translator.load(system_locale, "qtbase", "_", translations_path):
 | 
			
		||||
        app.installTranslator(qt_translator)
 | 
			
		||||
    else:
 | 
			
		||||
        logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
 | 
			
		||||
        logger.warning(
 | 
			
		||||
            f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
    # --- Main Window ---
 | 
			
		||||
    version = get_version()
 | 
			
		||||
    window = MainWindow(app_name=__app_name__, version=version)
 | 
			
		||||
 | 
			
		||||
    window = MainWindow()
 | 
			
		||||
    # --- Handle incoming connections ---
 | 
			
		||||
    def handle_new_connection():
 | 
			
		||||
        conn = local_server.nextPendingConnection()
 | 
			
		||||
        if not conn:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
    if args.session:
 | 
			
		||||
        gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2")
 | 
			
		||||
        cmd = f"{gamescope_cmd} -- portprotonqt"
 | 
			
		||||
        logger.info(f"Executing: {cmd}")
 | 
			
		||||
        subprocess.Popen(cmd, shell=True)
 | 
			
		||||
        sys.exit(0)
 | 
			
		||||
        if conn.waitForReadyRead(1000):
 | 
			
		||||
            data = conn.readAll().data()
 | 
			
		||||
            msg = bytes(data).decode("utf-8", errors="ignore")
 | 
			
		||||
            logger.info(f"IPC message received: {msg}")
 | 
			
		||||
 | 
			
		||||
    if args.fullscreen:
 | 
			
		||||
        logger.info("Launching in fullscreen mode due to --fullscreen flag")
 | 
			
		||||
            def restore_window():
 | 
			
		||||
                try:
 | 
			
		||||
                    if msg.startswith("show"):
 | 
			
		||||
                        if hasattr(window, "restore_from_tray"):
 | 
			
		||||
                            window.restore_from_tray()  # type: ignore[attr-defined]
 | 
			
		||||
                        else:
 | 
			
		||||
                            window.showNormal()
 | 
			
		||||
                            window.raise_()
 | 
			
		||||
                            window.activateWindow()
 | 
			
		||||
                            window.setWindowState(
 | 
			
		||||
                                window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
 | 
			
		||||
                            )
 | 
			
		||||
 | 
			
		||||
                        if ":fullscreen" in msg:
 | 
			
		||||
                            logger.info("Switching to fullscreen via IPC")
 | 
			
		||||
                            save_fullscreen_config(True)
 | 
			
		||||
                            window.showFullScreen()
 | 
			
		||||
                        else:
 | 
			
		||||
                            logger.info("Switching to normal window via IPC")
 | 
			
		||||
                            save_fullscreen_config(False)
 | 
			
		||||
                            window.showNormal()
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.warning(f"Failed to restore window: {e}")
 | 
			
		||||
 | 
			
		||||
            # Выполняем в основном потоке
 | 
			
		||||
            QTimer.singleShot(0, restore_window)
 | 
			
		||||
 | 
			
		||||
        conn.disconnectFromServer()
 | 
			
		||||
 | 
			
		||||
    local_server.newConnection.connect(handle_new_connection)
 | 
			
		||||
 | 
			
		||||
    # --- Initial fullscreen state ---
 | 
			
		||||
    launch_fullscreen = args.fullscreen or read_fullscreen_config()
 | 
			
		||||
    if launch_fullscreen:
 | 
			
		||||
        logger.info(
 | 
			
		||||
            f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
 | 
			
		||||
        )
 | 
			
		||||
        save_fullscreen_config(True)
 | 
			
		||||
        window.showFullScreen()
 | 
			
		||||
    else:
 | 
			
		||||
        logger.info("Launching in normal mode")
 | 
			
		||||
        save_fullscreen_config(False)
 | 
			
		||||
        window.showNormal()
 | 
			
		||||
 | 
			
		||||
    current_theme_name = read_theme_from_config()
 | 
			
		||||
    tray = SystemTray(app, current_theme_name)
 | 
			
		||||
    tray.show_action.triggered.connect(window.show)
 | 
			
		||||
    tray.hide_action.triggered.connect(window.hide)
 | 
			
		||||
 | 
			
		||||
    def recreate_tray():
 | 
			
		||||
        nonlocal tray
 | 
			
		||||
        if tray:
 | 
			
		||||
            logger.debug("Recreating system tray")
 | 
			
		||||
            tray.cleanup()
 | 
			
		||||
            tray = None
 | 
			
		||||
        current_theme = read_theme_from_config()
 | 
			
		||||
        tray = SystemTray(app, current_theme)
 | 
			
		||||
        # Ensure window is not None before connecting signals
 | 
			
		||||
        if window:
 | 
			
		||||
            tray.show_action.triggered.connect(window.show)
 | 
			
		||||
            tray.hide_action.triggered.connect(window.hide)
 | 
			
		||||
 | 
			
		||||
    # --- Cleanup ---
 | 
			
		||||
    def cleanup_on_exit():
 | 
			
		||||
        nonlocal tray, window
 | 
			
		||||
        app.aboutToQuit.disconnect()
 | 
			
		||||
        if tray:
 | 
			
		||||
            tray.cleanup()
 | 
			
		||||
            tray = None
 | 
			
		||||
        if window:
 | 
			
		||||
            window.close()
 | 
			
		||||
        app.quit()
 | 
			
		||||
        try:
 | 
			
		||||
            local_server.close()
 | 
			
		||||
            QLocalServer.removeServer(server_name)
 | 
			
		||||
            if window:
 | 
			
		||||
                window.close()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.warning(f"Cleanup error: {e}")
 | 
			
		||||
 | 
			
		||||
    window.settings_saved.connect(recreate_tray)
 | 
			
		||||
    app.aboutToQuit.connect(cleanup_on_exit)
 | 
			
		||||
 | 
			
		||||
    window.show()
 | 
			
		||||
 | 
			
		||||
    sys.exit(app.exec())
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    main()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,20 @@
 | 
			
		||||
import argparse
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
def parse_args():
 | 
			
		||||
    """
 | 
			
		||||
    Парсит аргументы командной строки.
 | 
			
		||||
    Parses command-line arguments.
 | 
			
		||||
    """
 | 
			
		||||
    parser = argparse.ArgumentParser(description="PortProtonQt CLI")
 | 
			
		||||
    parser.add_argument(
 | 
			
		||||
        "--fullscreen",
 | 
			
		||||
        action="store_true",
 | 
			
		||||
        help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
 | 
			
		||||
        help="Launch the application in fullscreen mode and save this setting"
 | 
			
		||||
    )
 | 
			
		||||
    parser.add_argument(
 | 
			
		||||
        "--session",
 | 
			
		||||
        action="store_true",
 | 
			
		||||
        help="Запустить приложение с использованием gamescope"
 | 
			
		||||
        "--debug-level",
 | 
			
		||||
        choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
 | 
			
		||||
        default='NOTSET',
 | 
			
		||||
        help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)"
 | 
			
		||||
    )
 | 
			
		||||
    return parser.parse_args()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,15 @@
 | 
			
		||||
import os
 | 
			
		||||
import configparser
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
_portproton_location = None
 | 
			
		||||
_portproton_start_sh = None
 | 
			
		||||
 | 
			
		||||
# Пути к конфигурационным файлам
 | 
			
		||||
# Paths to configuration files
 | 
			
		||||
CONFIG_FILE = os.path.join(
 | 
			
		||||
    os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
 | 
			
		||||
    "PortProtonQt.conf"
 | 
			
		||||
@@ -18,17 +20,32 @@ PORTPROTON_CONFIG_FILE = os.path.join(
 | 
			
		||||
    "PortProton.conf"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
# Пути к папкам с темами
 | 
			
		||||
# Paths to theme directories
 | 
			
		||||
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
THEMES_DIRS = [
 | 
			
		||||
    os.path.join(xdg_data_home, "PortProtonQt", "themes"),
 | 
			
		||||
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
 | 
			
		||||
    """Safely reads a configuration file and returns a ConfigParser object or None if reading fails."""
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if not os.path.exists(config_file):
 | 
			
		||||
        logger.debug(f"Configuration file {config_file} not found")
 | 
			
		||||
        return None
 | 
			
		||||
    try:
 | 
			
		||||
        cp.read(config_file, encoding="utf-8")
 | 
			
		||||
        return cp
 | 
			
		||||
    except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
        logger.warning(f"Invalid configuration file format: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.warning(f"Failed to read configuration file: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
def read_config():
 | 
			
		||||
    """
 | 
			
		||||
    Читает конфигурационный файл и возвращает словарь параметров.
 | 
			
		||||
    Пример строки в конфиге (без секций):
 | 
			
		||||
    """Reads the configuration file and returns a dictionary of parameters.
 | 
			
		||||
    Example line in config (no sections):
 | 
			
		||||
      detail_level = detailed
 | 
			
		||||
    """
 | 
			
		||||
    config_dict = {}
 | 
			
		||||
@@ -44,29 +61,17 @@ def read_config():
 | 
			
		||||
    return config_dict
 | 
			
		||||
 | 
			
		||||
def read_theme_from_config():
 | 
			
		||||
    """Reads the theme from the [Appearance] section of the configuration file.
 | 
			
		||||
    Returns 'standart' if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    Читает из конфигурационного файла тему из секции [Appearance].
 | 
			
		||||
    Если параметр не задан, возвращает "standart".
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
            return "standart"
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None:
 | 
			
		||||
        return "standart"
 | 
			
		||||
    return cp.get("Appearance", "theme", fallback="standart")
 | 
			
		||||
 | 
			
		||||
def save_theme_to_config(theme_name):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
    """Saves the selected theme name to the [Appearance] section of the configuration file."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Appearance" not in cp:
 | 
			
		||||
        cp["Appearance"] = {}
 | 
			
		||||
    cp["Appearance"]["theme"] = theme_name
 | 
			
		||||
@@ -74,34 +79,18 @@ def save_theme_to_config(theme_name):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_time_config():
 | 
			
		||||
    """Reads time settings from the [Time] section of the configuration file.
 | 
			
		||||
    If the section or parameter is missing, saves and returns 'detailed' as default.
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройки времени из секции [Time] конфигурационного файла.
 | 
			
		||||
    Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
            save_time_config("detailed")
 | 
			
		||||
            return "detailed"
 | 
			
		||||
        if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
 | 
			
		||||
            save_time_config("detailed")
 | 
			
		||||
            return "detailed"
 | 
			
		||||
        return cp.get("Time", "detail_level", fallback="detailed").lower()
 | 
			
		||||
    return "detailed"
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
 | 
			
		||||
        save_time_config("detailed")
 | 
			
		||||
        return "detailed"
 | 
			
		||||
    return cp.get("Time", "detail_level", fallback="detailed").lower()
 | 
			
		||||
 | 
			
		||||
def save_time_config(detail_level):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку уровня детализации времени в секции [Time].
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
    """Saves the time detail level to the [Time] section of the configuration file."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Time" not in cp:
 | 
			
		||||
        cp["Time"] = {}
 | 
			
		||||
    cp["Time"]["detail_level"] = detail_level
 | 
			
		||||
@@ -109,48 +98,69 @@ def save_time_config(detail_level):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_file_content(file_path):
 | 
			
		||||
    """
 | 
			
		||||
    Читает содержимое файла и возвращает его как строку.
 | 
			
		||||
    """
 | 
			
		||||
    """Reads the content of a file and returns it as a string."""
 | 
			
		||||
    with open(file_path, encoding="utf-8") as f:
 | 
			
		||||
        return f.read().strip()
 | 
			
		||||
 | 
			
		||||
def get_portproton_location():
 | 
			
		||||
    """
 | 
			
		||||
    Возвращает путь к директории PortProton.
 | 
			
		||||
    Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
 | 
			
		||||
    наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
 | 
			
		||||
    используется директория по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    """Возвращает путь к PortProton каталогу (строку) или None."""
 | 
			
		||||
    global _portproton_location
 | 
			
		||||
 | 
			
		||||
    if _portproton_location is not None:
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    # Попытка чтения пути из конфигурационного файла
 | 
			
		||||
    location = None
 | 
			
		||||
 | 
			
		||||
    if os.path.isfile(PORTPROTON_CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
 | 
			
		||||
            if location and os.path.isdir(location):
 | 
			
		||||
                _portproton_location = location
 | 
			
		||||
                logger.info(f"Путь PortProton из конфигурации: {location}")
 | 
			
		||||
                logger.info(f"PortProton path from configuration: {location}")
 | 
			
		||||
                return _portproton_location
 | 
			
		||||
            logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
 | 
			
		||||
            logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
 | 
			
		||||
        except (OSError, PermissionError) as e:
 | 
			
		||||
            logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
 | 
			
		||||
            logger.warning(f"Failed to read PortProton configuration file: {e}")
 | 
			
		||||
 | 
			
		||||
    default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
			
		||||
    if os.path.isdir(default_dir):
 | 
			
		||||
        _portproton_location = default_dir
 | 
			
		||||
        logger.info(f"Используется директория flatpak PortProton: {default_dir}")
 | 
			
		||||
    default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
			
		||||
    if os.path.isdir(default_flatpak_dir):
 | 
			
		||||
        _portproton_location = default_flatpak_dir
 | 
			
		||||
        logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    logger.warning("Конфигурация и директория flatpak PortProton не найдены")
 | 
			
		||||
    logger.warning("PortProton configuration and Flatpak directory not found")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def get_portproton_start_command():
 | 
			
		||||
    """Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
 | 
			
		||||
    portproton_path = get_portproton_location()
 | 
			
		||||
    if not portproton_path:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        result = subprocess.run(
 | 
			
		||||
            ["flatpak", "list"],
 | 
			
		||||
            capture_output=True,
 | 
			
		||||
            text=True,
 | 
			
		||||
            check=False
 | 
			
		||||
        )
 | 
			
		||||
        if "ru.linux_gaming.PortProton" in result.stdout:
 | 
			
		||||
            logger.info("Detected Flatpak installation")
 | 
			
		||||
            return ["flatpak", "run", "ru.linux_gaming.PortProton"]
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
 | 
			
		||||
    if os.path.exists(start_sh_path):
 | 
			
		||||
        return [start_sh_path]
 | 
			
		||||
 | 
			
		||||
    logger.warning("Neither flatpak nor start.sh found for PortProton")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_desktop_entry(file_path):
 | 
			
		||||
    """
 | 
			
		||||
    Читает и парсит .desktop файл с помощью configparser.
 | 
			
		||||
    Если секция [Desktop Entry] отсутствует, возвращается None.
 | 
			
		||||
    """Reads and parses a .desktop file using configparser.
 | 
			
		||||
    Returns None if the [Desktop Entry] section is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser(interpolation=None)
 | 
			
		||||
    cp.read(file_path, encoding="utf-8")
 | 
			
		||||
@@ -159,9 +169,8 @@ def parse_desktop_entry(file_path):
 | 
			
		||||
    return cp["Desktop Entry"]
 | 
			
		||||
 | 
			
		||||
def load_theme_metainfo(theme_name):
 | 
			
		||||
    """
 | 
			
		||||
    Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
 | 
			
		||||
    Ожидаемые поля: author, author_link, description, name.
 | 
			
		||||
    """Loads theme metadata from metainfo.ini in the theme's root directory.
 | 
			
		||||
    Expected fields: author, author_link, description, name.
 | 
			
		||||
    """
 | 
			
		||||
    meta = {}
 | 
			
		||||
    for themes_dir in THEMES_DIRS:
 | 
			
		||||
@@ -179,69 +188,57 @@ def load_theme_metainfo(theme_name):
 | 
			
		||||
    return meta
 | 
			
		||||
 | 
			
		||||
def read_card_size():
 | 
			
		||||
    """Reads the card size (width) from the [Cards] section.
 | 
			
		||||
    Returns 250 if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    Читает размер карточек (ширину) из секции [Cards],
 | 
			
		||||
    Если параметр не задан, возвращает 250.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
            save_card_size(250)
 | 
			
		||||
            return 250
 | 
			
		||||
        if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
 | 
			
		||||
            save_card_size(250)
 | 
			
		||||
            return 250
 | 
			
		||||
        return cp.getint("Cards", "card_width", fallback=250)
 | 
			
		||||
    return 250
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
 | 
			
		||||
        save_card_size(250)
 | 
			
		||||
        return 250
 | 
			
		||||
    return cp.getint("Cards", "card_width", fallback=250)
 | 
			
		||||
 | 
			
		||||
def save_card_size(card_width):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет размер карточек (ширину) в секцию [Cards].
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
    """Saves the card size (width) to the [Cards] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Cards" not in cp:
 | 
			
		||||
        cp["Cards"] = {}
 | 
			
		||||
    cp["Cards"]["card_width"] = str(card_width)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_auto_card_size():
 | 
			
		||||
    """Reads the card size (width) for Auto Install from the [Cards] section.
 | 
			
		||||
    Returns 250 if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
 | 
			
		||||
        save_auto_card_size(250)
 | 
			
		||||
        return 250
 | 
			
		||||
    return cp.getint("Cards", "auto_card_width", fallback=250)
 | 
			
		||||
 | 
			
		||||
def save_auto_card_size(card_width):
 | 
			
		||||
    """Saves the card size (width) for Auto Install to the [Cards] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Cards" not in cp:
 | 
			
		||||
        cp["Cards"] = {}
 | 
			
		||||
    cp["Cards"]["auto_card_width"] = str(card_width)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def read_sort_method():
 | 
			
		||||
    """Reads the sort method from the [Games] section.
 | 
			
		||||
    Returns 'last_launch' if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    Читает метод сортировки из секции [Games].
 | 
			
		||||
    Если параметр не задан, возвращает last_launch.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
            save_sort_method("last_launch")
 | 
			
		||||
            return "last_launch"
 | 
			
		||||
        if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
 | 
			
		||||
            save_sort_method("last_launch")
 | 
			
		||||
            return "last_launch"
 | 
			
		||||
        return cp.get("Games", "sort_method", fallback="last_launch").lower()
 | 
			
		||||
    return "last_launch"
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
 | 
			
		||||
        save_sort_method("last_launch")
 | 
			
		||||
        return "last_launch"
 | 
			
		||||
    return cp.get("Games", "sort_method", fallback="last_launch").lower()
 | 
			
		||||
 | 
			
		||||
def save_sort_method(sort_method):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет метод сортировки в секцию [Games].
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
    """Saves the sort method to the [Games] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Games" not in cp:
 | 
			
		||||
        cp["Games"] = {}
 | 
			
		||||
    cp["Games"]["sort_method"] = sort_method
 | 
			
		||||
@@ -249,34 +246,18 @@ def save_sort_method(sort_method):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_display_filter():
 | 
			
		||||
    """Reads the display_filter parameter from the [Games] section.
 | 
			
		||||
    Returns 'all' if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    Читает параметр display_filter из секции [Games].
 | 
			
		||||
    Если параметр отсутствует, сохраняет и возвращает значение "all".
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфига: %s", e)
 | 
			
		||||
            save_display_filter("all")
 | 
			
		||||
            return "all"
 | 
			
		||||
        if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
 | 
			
		||||
            save_display_filter("all")
 | 
			
		||||
            return "all"
 | 
			
		||||
        return cp.get("Games", "display_filter", fallback="all").lower()
 | 
			
		||||
    return "all"
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
 | 
			
		||||
        save_display_filter("all")
 | 
			
		||||
        return "all"
 | 
			
		||||
    return cp.get("Games", "display_filter", fallback="all").lower()
 | 
			
		||||
 | 
			
		||||
def save_display_filter(filter_value):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет параметр display_filter в секцию [Games] конфигурационного файла.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфига: %s", e)
 | 
			
		||||
    """Saves the display_filter parameter to the [Games] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Games" not in cp:
 | 
			
		||||
        cp["Games"] = {}
 | 
			
		||||
    cp["Games"]["display_filter"] = filter_value
 | 
			
		||||
@@ -284,37 +265,23 @@ def save_display_filter(filter_value):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_favorites():
 | 
			
		||||
    """Reads the list of favorite games from the [Favorites] section.
 | 
			
		||||
    The list is stored as a quoted string with comma-separated names.
 | 
			
		||||
    Returns an empty list if the section or parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    Читает список избранных игр из секции [Favorites] конфигурационного файла.
 | 
			
		||||
    Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми.
 | 
			
		||||
    Если секция или параметр отсутствуют, возвращает пустой список.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфига: %s", e)
 | 
			
		||||
            return []
 | 
			
		||||
        if cp.has_section("Favorites") and cp.has_option("Favorites", "games"):
 | 
			
		||||
            favs = cp.get("Favorites", "games", fallback="").strip()
 | 
			
		||||
            # Если строка начинается и заканчивается кавычками, удаляем их
 | 
			
		||||
            if favs.startswith('"') and favs.endswith('"'):
 | 
			
		||||
                favs = favs[1:-1]
 | 
			
		||||
            return [s.strip() for s in favs.split(",") if s.strip()]
 | 
			
		||||
    return []
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Favorites") or not cp.has_option("Favorites", "games"):
 | 
			
		||||
        return []
 | 
			
		||||
    favs = cp.get("Favorites", "games", fallback="").strip()
 | 
			
		||||
    if favs.startswith('"') and favs.endswith('"'):
 | 
			
		||||
        favs = favs[1:-1]
 | 
			
		||||
    return [s.strip() for s in favs.split(",") if s.strip()]
 | 
			
		||||
 | 
			
		||||
def save_favorites(favorites):
 | 
			
		||||
    """Saves the list of favorite games to the [Favorites] section.
 | 
			
		||||
    The list is stored as a quoted string with comma-separated names.
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла.
 | 
			
		||||
    Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфига: %s", e)
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Favorites" not in cp:
 | 
			
		||||
        cp["Favorites"] = {}
 | 
			
		||||
    fav_str = ", ".join(favorites)
 | 
			
		||||
@@ -323,76 +290,66 @@ def save_favorites(favorites):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_rumble_config():
 | 
			
		||||
    """Reads the gamepad rumble setting from the [Gamepad] section.
 | 
			
		||||
    Returns False if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройку виброотдачи геймпада из секции [Gamepad].
 | 
			
		||||
    Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
            save_rumble_config(False)
 | 
			
		||||
            return False
 | 
			
		||||
        if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
 | 
			
		||||
            save_rumble_config(False)
 | 
			
		||||
            return False
 | 
			
		||||
        return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
 | 
			
		||||
    return False
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
 | 
			
		||||
        save_rumble_config(False)
 | 
			
		||||
        return False
 | 
			
		||||
    return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
 | 
			
		||||
 | 
			
		||||
def save_rumble_config(rumble_enabled):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку виброотдачи геймпада в секцию [Gamepad].
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
    """Saves the gamepad rumble setting to the [Gamepad] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Gamepad" not in cp:
 | 
			
		||||
        cp["Gamepad"] = {}
 | 
			
		||||
    cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_gamepad_type():
 | 
			
		||||
    """Reads the gamepad type from the [Gamepad] section.
 | 
			
		||||
    Returns 'xbox' if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"):
 | 
			
		||||
        save_gamepad_type("xbox")
 | 
			
		||||
        return "xbox"
 | 
			
		||||
    return cp.get("Gamepad", "type", fallback="xbox").lower()
 | 
			
		||||
 | 
			
		||||
def save_gamepad_type(gpad_type):
 | 
			
		||||
    """Saves the gamepad type to the [Gamepad] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Gamepad" not in cp:
 | 
			
		||||
        cp["Gamepad"] = {}
 | 
			
		||||
    cp["Gamepad"]["type"] = gpad_type
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def ensure_default_proxy_config():
 | 
			
		||||
    """Ensures the [Proxy] section exists in the configuration file.
 | 
			
		||||
    Creates it with empty values if missing.
 | 
			
		||||
    """
 | 
			
		||||
    Проверяет наличие секции [Proxy] в конфигурационном файле.
 | 
			
		||||
    Если секция отсутствует, создаёт её с пустыми значениями.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
            return
 | 
			
		||||
        if not cp.has_section("Proxy"):
 | 
			
		||||
            cp.add_section("Proxy")
 | 
			
		||||
            cp["Proxy"]["proxy_url"] = ""
 | 
			
		||||
            cp["Proxy"]["proxy_user"] = ""
 | 
			
		||||
            cp["Proxy"]["proxy_password"] = ""
 | 
			
		||||
            with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
                cp.write(configfile)
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Proxy" not in cp:
 | 
			
		||||
        cp.add_section("Proxy")
 | 
			
		||||
        cp["Proxy"]["proxy_url"] = ""
 | 
			
		||||
        cp["Proxy"]["proxy_user"] = ""
 | 
			
		||||
        cp["Proxy"]["proxy_password"] = ""
 | 
			
		||||
        with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
            cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_proxy_config():
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройки прокси из секции [Proxy] конфигурационного файла.
 | 
			
		||||
    Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
 | 
			
		||||
    """Reads proxy settings from the [Proxy] section.
 | 
			
		||||
    Returns an empty dict if proxy_url is not set or empty.
 | 
			
		||||
    """
 | 
			
		||||
    ensure_default_proxy_config()
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    try:
 | 
			
		||||
        cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None:
 | 
			
		||||
        return {}
 | 
			
		||||
 | 
			
		||||
    proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
 | 
			
		||||
    if proxy_url:
 | 
			
		||||
        # Если указаны логин и пароль, добавляем их к URL
 | 
			
		||||
        proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
 | 
			
		||||
        proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
 | 
			
		||||
        if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
 | 
			
		||||
@@ -402,16 +359,10 @@ def read_proxy_config():
 | 
			
		||||
    return {}
 | 
			
		||||
 | 
			
		||||
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
 | 
			
		||||
    """Saves proxy settings to the [Proxy] section.
 | 
			
		||||
    Creates the section if it does not exist.
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла.
 | 
			
		||||
    Если секция отсутствует, создаёт её.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Proxy" not in cp:
 | 
			
		||||
        cp["Proxy"] = {}
 | 
			
		||||
    cp["Proxy"]["proxy_url"] = proxy_url
 | 
			
		||||
@@ -421,34 +372,18 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_fullscreen_config():
 | 
			
		||||
    """Reads the fullscreen mode setting from the [Display] section.
 | 
			
		||||
    Returns False if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройку полноэкранного режима приложения из секции [Display].
 | 
			
		||||
    Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
            save_fullscreen_config(False)
 | 
			
		||||
            return False
 | 
			
		||||
        if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
 | 
			
		||||
            save_fullscreen_config(False)
 | 
			
		||||
            return False
 | 
			
		||||
        return cp.getboolean("Display", "fullscreen", fallback=False)
 | 
			
		||||
    return False
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
 | 
			
		||||
        save_fullscreen_config(False)
 | 
			
		||||
        return False
 | 
			
		||||
    return cp.getboolean("Display", "fullscreen", fallback=False)
 | 
			
		||||
 | 
			
		||||
def save_fullscreen_config(fullscreen):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку полноэкранного режима приложения в секцию [Display].
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
    """Saves the fullscreen mode setting to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["fullscreen"] = str(fullscreen)
 | 
			
		||||
@@ -456,33 +391,19 @@ def save_fullscreen_config(fullscreen):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_window_geometry() -> tuple[int, int]:
 | 
			
		||||
    """Reads the window width and height from the [MainWindow] section.
 | 
			
		||||
    Returns (0, 0) if the parameters are missing.
 | 
			
		||||
    """
 | 
			
		||||
    Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
 | 
			
		||||
    Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0).
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
            return (0, 0)
 | 
			
		||||
        if cp.has_section("MainWindow"):
 | 
			
		||||
            width = cp.getint("MainWindow", "width", fallback=0)
 | 
			
		||||
            height = cp.getint("MainWindow", "height", fallback=0)
 | 
			
		||||
            return (width, height)
 | 
			
		||||
    return (0, 0)
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("MainWindow"):
 | 
			
		||||
        return (0, 0)
 | 
			
		||||
    width = cp.getint("MainWindow", "width", fallback=0)
 | 
			
		||||
    height = cp.getint("MainWindow", "height", fallback=0)
 | 
			
		||||
    return (width, height)
 | 
			
		||||
 | 
			
		||||
def save_window_geometry(width: int, height: int):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка в конфигурационном файле: %s", e)
 | 
			
		||||
    """Saves the window width and height to the [MainWindow] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "MainWindow" not in cp:
 | 
			
		||||
        cp["MainWindow"] = {}
 | 
			
		||||
    cp["MainWindow"]["width"] = str(width)
 | 
			
		||||
@@ -491,61 +412,86 @@ def save_window_geometry(width: int, height: int):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def reset_config():
 | 
			
		||||
    """
 | 
			
		||||
    Сбрасывает конфигурационный файл, удаляя его.
 | 
			
		||||
    После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
 | 
			
		||||
    """Resets the configuration file by deleting it.
 | 
			
		||||
    Subsequent reads will use default values.
 | 
			
		||||
    """
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            os.remove(CONFIG_FILE)
 | 
			
		||||
            logger.info("Конфигурационный файл %s удалён", CONFIG_FILE)
 | 
			
		||||
            logger.info("Configuration file %s deleted", CONFIG_FILE)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка при удалении конфигурационного файла: %s", e)
 | 
			
		||||
            logger.warning(f"Failed to delete configuration file: {e}")
 | 
			
		||||
 | 
			
		||||
def clear_cache():
 | 
			
		||||
    """
 | 
			
		||||
    Очищает кэш PortProtonQt, удаляя папку кэша.
 | 
			
		||||
    """
 | 
			
		||||
    """Clears the PortProtonQt cache by deleting the cache directory."""
 | 
			
		||||
    xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
 | 
			
		||||
    cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
 | 
			
		||||
    if os.path.exists(cache_dir):
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.rmtree(cache_dir)
 | 
			
		||||
            logger.info("Кэш PortProtonQt удалён: %s", cache_dir)
 | 
			
		||||
            logger.info("PortProtonQt cache deleted: %s", cache_dir)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка при удалении кэша: %s", e)
 | 
			
		||||
            logger.warning(f"Failed to delete cache: {e}")
 | 
			
		||||
 | 
			
		||||
def read_auto_fullscreen_gamepad():
 | 
			
		||||
    """Reads the auto-fullscreen setting for gamepad from the [Display] section.
 | 
			
		||||
    Returns False if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [Display].
 | 
			
		||||
    Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
            save_auto_fullscreen_gamepad(False)
 | 
			
		||||
            return False
 | 
			
		||||
        if not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
 | 
			
		||||
            save_auto_fullscreen_gamepad(False)
 | 
			
		||||
            return False
 | 
			
		||||
        return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
 | 
			
		||||
    return False
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
 | 
			
		||||
        save_auto_fullscreen_gamepad(False)
 | 
			
		||||
        return False
 | 
			
		||||
    return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
 | 
			
		||||
 | 
			
		||||
def save_auto_fullscreen_gamepad(auto_fullscreen):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [Display].
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
    """Saves the auto-fullscreen setting for gamepad to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_favorite_folders():
 | 
			
		||||
    """Reads the list of favorite folders from the [FavoritesFolders] section.
 | 
			
		||||
    The list is stored as a quoted string with comma-separated paths.
 | 
			
		||||
    Returns an empty list if the section or parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("FavoritesFolders") or not cp.has_option("FavoritesFolders", "folders"):
 | 
			
		||||
        return []
 | 
			
		||||
    favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
 | 
			
		||||
    if favs.startswith('"') and favs.endswith('"'):
 | 
			
		||||
        favs = favs[1:-1]
 | 
			
		||||
    return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
 | 
			
		||||
 | 
			
		||||
def save_favorite_folders(folders):
 | 
			
		||||
    """Saves the list of favorite folders to the [FavoritesFolders] section.
 | 
			
		||||
    The list is stored as a quoted string with comma-separated paths.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "FavoritesFolders" not in cp:
 | 
			
		||||
        cp["FavoritesFolders"] = {}
 | 
			
		||||
    fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
 | 
			
		||||
    cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_minimize_to_tray():
 | 
			
		||||
    """Reads the minimize-to-tray setting from the [Display] section.
 | 
			
		||||
    Returns True if the parameter is missing (default: minimize to tray).
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
 | 
			
		||||
        save_minimize_to_tray(True)
 | 
			
		||||
        return True
 | 
			
		||||
    return cp.getboolean("Display", "minimize_to_tray", fallback=True)
 | 
			
		||||
 | 
			
		||||
def save_minimize_to_tray(minimize_to_tray):
 | 
			
		||||
    """Saves the minimize-to-tray setting to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,6 @@ import glob
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
import threading
 | 
			
		||||
import logging
 | 
			
		||||
import orjson
 | 
			
		||||
import psutil
 | 
			
		||||
import signal
 | 
			
		||||
@@ -12,13 +11,14 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
 | 
			
		||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
 | 
			
		||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command
 | 
			
		||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
 | 
			
		||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
class ContextMenuSignals(QObject):
 | 
			
		||||
    """Signals for thread-safe UI updates from worker threads."""
 | 
			
		||||
@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
 | 
			
		||||
class ContextMenuManager:
 | 
			
		||||
    """Manages context menu actions for game management in PortProtonQt."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
 | 
			
		||||
    def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
 | 
			
		||||
        """
 | 
			
		||||
        Initialize the ContextMenuManager.
 | 
			
		||||
 | 
			
		||||
@@ -45,7 +45,8 @@ class ContextMenuManager:
 | 
			
		||||
        self.theme = theme
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.load_games = load_games_callback
 | 
			
		||||
        self.update_game_grid = update_game_grid_callback
 | 
			
		||||
        self.game_library_manager = game_library_manager
 | 
			
		||||
        self.update_game_grid = game_library_manager.update_game_grid
 | 
			
		||||
        self.legendary_path = os.path.join(
 | 
			
		||||
            os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
 | 
			
		||||
            "PortProtonQt", "legendary_cache", "legendary"
 | 
			
		||||
@@ -62,7 +63,7 @@ class ContextMenuManager:
 | 
			
		||||
                self.parent.statusBar().showMessage,
 | 
			
		||||
                Qt.ConnectionType.QueuedConnection
 | 
			
		||||
            )
 | 
			
		||||
            logger.debug("Connected show_status_message signal to statusBar")
 | 
			
		||||
            logger.debug("Connected show_status_message signal to status bar")
 | 
			
		||||
        self.signals.show_warning_dialog.connect(
 | 
			
		||||
            self._show_warning_dialog,
 | 
			
		||||
            Qt.ConnectionType.QueuedConnection
 | 
			
		||||
@@ -74,28 +75,28 @@ class ContextMenuManager:
 | 
			
		||||
 | 
			
		||||
    def _show_warning_dialog(self, title: str, message: str):
 | 
			
		||||
        """Show a warning dialog in the main thread."""
 | 
			
		||||
        logger.debug("Showing warning dialog: %s - %s", title, message)
 | 
			
		||||
        logger.debug("Displaying warning dialog: %s - %s", title, message)
 | 
			
		||||
        QMessageBox.warning(self.parent, title, message)
 | 
			
		||||
 | 
			
		||||
    def _show_info_dialog(self, title: str, message: str):
 | 
			
		||||
        """Show an info dialog in the main thread."""
 | 
			
		||||
        logger.debug("Showing info dialog: %s - %s", title, message)
 | 
			
		||||
        logger.debug("Displaying info dialog: %s - %s", title, message)
 | 
			
		||||
        QMessageBox.information(self.parent, title, message)
 | 
			
		||||
 | 
			
		||||
    def _show_status_message(self, message: str, timeout: int = 3000):
 | 
			
		||||
        """Show a status message on the status bar if available."""
 | 
			
		||||
        if self.parent.statusBar():
 | 
			
		||||
            self.parent.statusBar().showMessage(message, timeout)
 | 
			
		||||
            logger.debug("Direct status message: %s", message)
 | 
			
		||||
            logger.debug("Displayed status message: %s", message)
 | 
			
		||||
        else:
 | 
			
		||||
            logger.warning("Status bar not available for message: %s", message)
 | 
			
		||||
            logger.warning("Status bar unavailable for message: %s", message)
 | 
			
		||||
 | 
			
		||||
    def _check_portproton(self):
 | 
			
		||||
        """Check if PortProton is available."""
 | 
			
		||||
        if self.portproton_location is None:
 | 
			
		||||
            self.signals.show_warning_dialog.emit(
 | 
			
		||||
                _("Error"),
 | 
			
		||||
                _("PortProton is not found")
 | 
			
		||||
                _("PortProton directory not found")
 | 
			
		||||
            )
 | 
			
		||||
            return False
 | 
			
		||||
        return True
 | 
			
		||||
@@ -119,7 +120,7 @@ class ContextMenuManager:
 | 
			
		||||
                installed_games = orjson.loads(f.read())
 | 
			
		||||
            return app_name in installed_games
 | 
			
		||||
        except (OSError, orjson.JSONDecodeError) as e:
 | 
			
		||||
            logger.error("Failed to read installed.json: %s", e)
 | 
			
		||||
            logger.error("Error reading installed.json: %s", e)
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def _is_game_running(self, game_card) -> bool:
 | 
			
		||||
@@ -148,10 +149,85 @@ class ContextMenuManager:
 | 
			
		||||
                return False
 | 
			
		||||
            current_exe = os.path.basename(exe_path)
 | 
			
		||||
 | 
			
		||||
        # Check if the current_exe matches the target_exe in MainWindow
 | 
			
		||||
        if hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe:
 | 
			
		||||
            return True
 | 
			
		||||
        return False
 | 
			
		||||
        return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
 | 
			
		||||
 | 
			
		||||
    def show_folder_context_menu(self, file_explorer, pos):
 | 
			
		||||
        """Shows the context menu for a folder in FileExplorer."""
 | 
			
		||||
        try:
 | 
			
		||||
            item = file_explorer.file_list.itemAt(pos)
 | 
			
		||||
            if not item:
 | 
			
		||||
                logger.debug("No folder selected at position %s", pos)
 | 
			
		||||
                return
 | 
			
		||||
            selected = item.text()
 | 
			
		||||
            if not selected.endswith("/"):
 | 
			
		||||
                logger.debug("Selected item is not a folder: %s", selected)
 | 
			
		||||
                return  # Only for folders
 | 
			
		||||
            full_path = os.path.normpath(os.path.join(file_explorer.current_path, selected.rstrip("/")))
 | 
			
		||||
            if not os.path.isdir(full_path):
 | 
			
		||||
                logger.debug("Path is not a directory: %s", full_path)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            menu = QMenu(file_explorer)
 | 
			
		||||
            menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
 | 
			
		||||
            menu.setParent(file_explorer, Qt.WindowType.Popup)  # Set transientParent for Wayland
 | 
			
		||||
 | 
			
		||||
            favorite_folders = read_favorite_folders()
 | 
			
		||||
            is_favorite = full_path in favorite_folders
 | 
			
		||||
            action_text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
 | 
			
		||||
            favorite_action = menu.addAction(self._get_safe_icon("star" if is_favorite else "star_full"), action_text)
 | 
			
		||||
            favorite_action.triggered.connect(lambda: self.toggle_favorite_folder(file_explorer, full_path, not is_favorite))
 | 
			
		||||
 | 
			
		||||
            # Disconnect file_list signals to prevent navigation during menu interaction
 | 
			
		||||
            try:
 | 
			
		||||
                file_explorer.file_list.itemClicked.disconnect(file_explorer.handle_item_click)
 | 
			
		||||
                file_explorer.file_list.itemDoubleClicked.disconnect(file_explorer.handle_item_double_click)
 | 
			
		||||
            except TypeError:
 | 
			
		||||
                pass  # Signals may not be connected
 | 
			
		||||
 | 
			
		||||
            # Reconnect signals after menu closes
 | 
			
		||||
            def reconnect_signals():
 | 
			
		||||
                try:
 | 
			
		||||
                    file_explorer.file_list.itemClicked.connect(file_explorer.handle_item_click)
 | 
			
		||||
                    file_explorer.file_list.itemDoubleClicked.connect(file_explorer.handle_item_double_click)
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error("Error reconnecting file list signals: %s", e)
 | 
			
		||||
 | 
			
		||||
            menu.aboutToHide.connect(reconnect_signals)
 | 
			
		||||
 | 
			
		||||
            # Set focus to the first menu item
 | 
			
		||||
            actions = menu.actions()
 | 
			
		||||
            if actions:
 | 
			
		||||
                menu.setActiveAction(actions[0])
 | 
			
		||||
 | 
			
		||||
            # Map local position to global for menu display
 | 
			
		||||
            global_pos = file_explorer.file_list.mapToGlobal(pos)
 | 
			
		||||
            menu.exec(global_pos)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error displaying folder context menu: %s", e)
 | 
			
		||||
 | 
			
		||||
    def toggle_favorite_folder(self, file_explorer, folder_path, add):
 | 
			
		||||
        """Adds or removes a folder from favorites."""
 | 
			
		||||
        favorite_folders = read_favorite_folders()
 | 
			
		||||
        if add:
 | 
			
		||||
            if folder_path not in favorite_folders:
 | 
			
		||||
                favorite_folders.append(folder_path)
 | 
			
		||||
                save_favorite_folders(favorite_folders)
 | 
			
		||||
                logger.info("Added folder to favorites: %s", folder_path)
 | 
			
		||||
        else:
 | 
			
		||||
            if folder_path in favorite_folders:
 | 
			
		||||
                favorite_folders.remove(folder_path)
 | 
			
		||||
                save_favorite_folders(favorite_folders)
 | 
			
		||||
                logger.info("Removed folder from favorites: %s", folder_path)
 | 
			
		||||
        file_explorer.update_drives_list()
 | 
			
		||||
 | 
			
		||||
    def _get_safe_icon(self, icon_name: str) -> QIcon:
 | 
			
		||||
        """Returns a QIcon, ensuring it is valid."""
 | 
			
		||||
        icon = self.theme_manager.get_icon(icon_name)
 | 
			
		||||
        if isinstance(icon, QIcon):
 | 
			
		||||
            return icon
 | 
			
		||||
        elif isinstance(icon, str) and os.path.exists(icon):
 | 
			
		||||
            return QIcon(icon)
 | 
			
		||||
        return QIcon()
 | 
			
		||||
 | 
			
		||||
    def show_context_menu(self, game_card, pos: QPoint):
 | 
			
		||||
        """
 | 
			
		||||
@@ -161,23 +237,25 @@ class ContextMenuManager:
 | 
			
		||||
            game_card: The GameCard instance requesting the context menu.
 | 
			
		||||
            pos: The position (in widget coordinates) where the menu should appear.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        def get_safe_icon(icon_name: str) -> QIcon:
 | 
			
		||||
            icon = self.theme_manager.get_icon(icon_name)
 | 
			
		||||
            if isinstance(icon, QIcon):
 | 
			
		||||
                return icon
 | 
			
		||||
            elif isinstance(icon, str) and os.path.exists(icon):
 | 
			
		||||
                return QIcon(icon)
 | 
			
		||||
            return QIcon()
 | 
			
		||||
 | 
			
		||||
        menu = QMenu(self.parent)
 | 
			
		||||
        menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
 | 
			
		||||
 | 
			
		||||
        # Check if the game is running
 | 
			
		||||
        # For non-Steam and non-Epic games, check if exe exists
 | 
			
		||||
        if game_card.game_source not in ("steam", "epic"):
 | 
			
		||||
            exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
 | 
			
		||||
            exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
 | 
			
		||||
            if not exe_path:
 | 
			
		||||
                # Show only "Delete from PortProton" if no valid exe
 | 
			
		||||
                delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
                delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
 | 
			
		||||
                menu.exec(game_card.mapToGlobal(pos))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
        # Normal menu for games with valid exe or from Steam/Epic
 | 
			
		||||
        is_running = self._is_game_running(game_card)
 | 
			
		||||
        action_text = _("Stop Game") if is_running else _("Launch Game")
 | 
			
		||||
        action_icon = "stop" if is_running else "play"
 | 
			
		||||
        launch_action = menu.addAction(get_safe_icon(action_icon), action_text)
 | 
			
		||||
        launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
 | 
			
		||||
        launch_action.triggered.connect(
 | 
			
		||||
            lambda: self._launch_game(game_card)
 | 
			
		||||
        )
 | 
			
		||||
@@ -186,11 +264,11 @@ class ContextMenuManager:
 | 
			
		||||
        is_favorite = game_card.name in favorites
 | 
			
		||||
        icon_name = "star_full" if is_favorite else "star"
 | 
			
		||||
        text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
 | 
			
		||||
        favorite_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
        favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
        favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
 | 
			
		||||
 | 
			
		||||
        if game_card.game_source == "epic":
 | 
			
		||||
            import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary"))
 | 
			
		||||
            import_action = menu.addAction(self._get_safe_icon("epic_games"), _("Import to Legendary"))
 | 
			
		||||
            import_action.triggered.connect(
 | 
			
		||||
                lambda: self.import_to_legendary(game_card.name, game_card.appid)
 | 
			
		||||
            )
 | 
			
		||||
@@ -198,13 +276,13 @@ class ContextMenuManager:
 | 
			
		||||
                is_in_steam = is_game_in_steam(game_card.name)
 | 
			
		||||
                icon_name = "delete" if is_in_steam else "steam"
 | 
			
		||||
                text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
 | 
			
		||||
                steam_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
                steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
                steam_action.triggered.connect(
 | 
			
		||||
                    lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
 | 
			
		||||
                    if is_in_steam
 | 
			
		||||
                    else self.add_egs_to_steam(game_card.name, game_card.appid)
 | 
			
		||||
                )
 | 
			
		||||
                open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
                open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
                open_folder_action.triggered.connect(
 | 
			
		||||
                    lambda: self.open_egs_game_folder(game_card.appid)
 | 
			
		||||
                )
 | 
			
		||||
@@ -212,7 +290,7 @@ class ContextMenuManager:
 | 
			
		||||
                desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
 | 
			
		||||
                icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
 | 
			
		||||
                text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
 | 
			
		||||
                desktop_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
                desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
                desktop_action.triggered.connect(
 | 
			
		||||
                    lambda: self.remove_egs_from_desktop(game_card.name)
 | 
			
		||||
                    if os.path.exists(desktop_path)
 | 
			
		||||
@@ -221,7 +299,7 @@ class ContextMenuManager:
 | 
			
		||||
                applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
 | 
			
		||||
                menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
 | 
			
		||||
                menu_action = menu.addAction(
 | 
			
		||||
                    get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
 | 
			
		||||
                    self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
 | 
			
		||||
                    _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
 | 
			
		||||
                )
 | 
			
		||||
                menu_action.triggered.connect(
 | 
			
		||||
@@ -235,19 +313,19 @@ class ContextMenuManager:
 | 
			
		||||
            desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
 | 
			
		||||
            icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
 | 
			
		||||
            text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
 | 
			
		||||
            desktop_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
            desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            desktop_action.triggered.connect(
 | 
			
		||||
                lambda: self.remove_from_desktop(game_card.name)
 | 
			
		||||
                if os.path.exists(desktop_path)
 | 
			
		||||
                else self.add_to_desktop(game_card.name, game_card.exec_line)
 | 
			
		||||
            )
 | 
			
		||||
            edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut"))
 | 
			
		||||
            edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
 | 
			
		||||
            edit_action.triggered.connect(
 | 
			
		||||
                lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
 | 
			
		||||
            )
 | 
			
		||||
            delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
            delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
            delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
 | 
			
		||||
            open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
            open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
            open_folder_action.triggered.connect(
 | 
			
		||||
                lambda: self.open_game_folder(game_card.name, game_card.exec_line)
 | 
			
		||||
            )
 | 
			
		||||
@@ -255,7 +333,7 @@ class ContextMenuManager:
 | 
			
		||||
            menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
 | 
			
		||||
            icon_name = "delete" if os.path.exists(menu_path) else "menu"
 | 
			
		||||
            text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
 | 
			
		||||
            menu_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
            menu_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            menu_action.triggered.connect(
 | 
			
		||||
                lambda: self.remove_from_menu(game_card.name)
 | 
			
		||||
                if os.path.exists(menu_path)
 | 
			
		||||
@@ -264,7 +342,7 @@ class ContextMenuManager:
 | 
			
		||||
            is_in_steam = is_game_in_steam(game_card.name)
 | 
			
		||||
            icon_name = "delete" if is_in_steam else "steam"
 | 
			
		||||
            text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
 | 
			
		||||
            steam_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
            steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            steam_action.triggered.connect(
 | 
			
		||||
                lambda: (
 | 
			
		||||
                    self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
 | 
			
		||||
@@ -273,7 +351,12 @@ class ContextMenuManager:
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        menu.exec(game_card.mapToGlobal(pos))
 | 
			
		||||
        # Set focus to the first menu item
 | 
			
		||||
        actions = menu.actions()
 | 
			
		||||
        if actions:
 | 
			
		||||
            menu.setActiveAction(actions[0])
 | 
			
		||||
 | 
			
		||||
            menu.exec(game_card.mapToGlobal(pos))
 | 
			
		||||
 | 
			
		||||
    def _launch_game(self, game_card):
 | 
			
		||||
        """
 | 
			
		||||
@@ -323,16 +406,7 @@ class ContextMenuManager:
 | 
			
		||||
                )
 | 
			
		||||
                return
 | 
			
		||||
            # Construct EGS launch command
 | 
			
		||||
            wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
			
		||||
            start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
			
		||||
            if self.portproton_location and ".var" not in self.portproton_location:
 | 
			
		||||
                wrapper = start_sh_path
 | 
			
		||||
                if not os.path.exists(start_sh_path):
 | 
			
		||||
                    self.signals.show_warning_dialog.emit(
 | 
			
		||||
                        _("Error"),
 | 
			
		||||
                        _("start.sh not found at {path}").format(path=start_sh_path)
 | 
			
		||||
                    )
 | 
			
		||||
                    return
 | 
			
		||||
            wrapper = get_portproton_start_command()
 | 
			
		||||
            exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
 | 
			
		||||
        else:
 | 
			
		||||
            exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
 | 
			
		||||
@@ -410,7 +484,7 @@ class ContextMenuManager:
 | 
			
		||||
            )
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Используем FileExplorer с directory_only=True
 | 
			
		||||
        # Use FileExplorer with directory_only=True
 | 
			
		||||
        file_explorer = FileExplorer(
 | 
			
		||||
            parent=self.parent,
 | 
			
		||||
            theme=self.theme,
 | 
			
		||||
@@ -440,10 +514,10 @@ class ContextMenuManager:
 | 
			
		||||
            self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
 | 
			
		||||
            threading.Thread(target=run_import, daemon=True).start()
 | 
			
		||||
 | 
			
		||||
        # Подключаем сигнал выбора файла/папки
 | 
			
		||||
        # Connect the file selection signal
 | 
			
		||||
        file_explorer.file_signal.file_selected.connect(on_folder_selected)
 | 
			
		||||
 | 
			
		||||
        # Центрируем FileExplorer относительно родительского виджета
 | 
			
		||||
        # Center FileExplorer relative to the parent widget
 | 
			
		||||
        parent_widget = self.parent
 | 
			
		||||
        if parent_widget:
 | 
			
		||||
            parent_geometry = parent_widget.geometry()
 | 
			
		||||
@@ -525,10 +599,10 @@ class ContextMenuManager:
 | 
			
		||||
        exe_path = get_egs_executable(app_name, self.legendary_config_path)
 | 
			
		||||
        if exe_path and os.path.exists(exe_path):
 | 
			
		||||
            if not generate_thumbnail(exe_path, icon_path, size=128):
 | 
			
		||||
                logger.error(f"Failed to generate thumbnail from exe: {exe_path}")
 | 
			
		||||
                logger.error("Failed to generate thumbnail for EGS game: %s", exe_path)
 | 
			
		||||
                icon_path = ""
 | 
			
		||||
        else:
 | 
			
		||||
            logger.error(f"No executable found for EGS game: {app_name}")
 | 
			
		||||
            logger.error("No executable found for EGS game: %s", app_name)
 | 
			
		||||
            icon_path = ""
 | 
			
		||||
 | 
			
		||||
        egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
 | 
			
		||||
@@ -668,7 +742,7 @@ Icon={icon_path}
 | 
			
		||||
                    if not exec_line:
 | 
			
		||||
                        self.signals.show_warning_dialog.emit(
 | 
			
		||||
                            _("Error"),
 | 
			
		||||
                            _("No executable command in .desktop file for '{game_name}'").format(game_name=game_name)
 | 
			
		||||
                            _("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name)
 | 
			
		||||
                        )
 | 
			
		||||
                        return None
 | 
			
		||||
                else:
 | 
			
		||||
@@ -680,7 +754,7 @@ Icon={icon_path}
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                self.signals.show_warning_dialog.emit(
 | 
			
		||||
                    _("Error"),
 | 
			
		||||
                    _("Failed to read .desktop file: {error}").format(error=str(e))
 | 
			
		||||
                    _("Error reading .desktop file: {error}").format(error=str(e))
 | 
			
		||||
                )
 | 
			
		||||
                return None
 | 
			
		||||
        else:
 | 
			
		||||
@@ -697,15 +771,12 @@ Icon={icon_path}
 | 
			
		||||
            return None
 | 
			
		||||
        return exec_line
 | 
			
		||||
 | 
			
		||||
    def _parse_exe_path(self, exec_line, game_name):
 | 
			
		||||
    def _parse_exe_path(self, exec_line: str, game_name: str) -> str | None:
 | 
			
		||||
        """Parse the executable path from exec_line."""
 | 
			
		||||
        try:
 | 
			
		||||
            entry_exec_split = shlex.split(exec_line)
 | 
			
		||||
            if not entry_exec_split:
 | 
			
		||||
                self.signals.show_warning_dialog.emit(
 | 
			
		||||
                    _("Error"),
 | 
			
		||||
                    _("Invalid executable command: {exec_line}").format(exec_line=exec_line)
 | 
			
		||||
                )
 | 
			
		||||
                logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line)
 | 
			
		||||
                return None
 | 
			
		||||
            if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
 | 
			
		||||
                exe_path = entry_exec_split[2]
 | 
			
		||||
@@ -714,17 +785,11 @@ Icon={icon_path}
 | 
			
		||||
            else:
 | 
			
		||||
                exe_path = entry_exec_split[-1]
 | 
			
		||||
            if not exe_path or not os.path.exists(exe_path):
 | 
			
		||||
                self.signals.show_warning_dialog.emit(
 | 
			
		||||
                    _("Error"),
 | 
			
		||||
                    _("Executable not found: {path}").format(path=exe_path or "None")
 | 
			
		||||
                )
 | 
			
		||||
                logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None")
 | 
			
		||||
                return None
 | 
			
		||||
            return exe_path
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            self.signals.show_warning_dialog.emit(
 | 
			
		||||
                _("Error"),
 | 
			
		||||
                _("Failed to parse executable: {error}").format(error=str(e))
 | 
			
		||||
            )
 | 
			
		||||
            logger.debug("Error parsing executable for game '%s': %s", game_name, e)
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
 | 
			
		||||
@@ -786,9 +851,16 @@ Icon={icon_path}
 | 
			
		||||
                        _("Failed to delete custom data: {error}").format(error=str(e))
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
        # Перезагрузка списка игр и обновление сетки
 | 
			
		||||
        self.load_games()
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
        self.update_game_grid = self.game_library_manager.remove_game_incremental
 | 
			
		||||
        self.game_library_manager.remove_game_incremental(game_name, exec_line)
 | 
			
		||||
 | 
			
		||||
    def add_game_incremental(self, game_data: tuple):
 | 
			
		||||
        """Add game after .desktop creation."""
 | 
			
		||||
        if not self._check_portproton():
 | 
			
		||||
            return
 | 
			
		||||
        # Assume game_data is built from new .desktop (name, desc, cover, etc.)
 | 
			
		||||
        self.game_library_manager.add_game_incremental(game_data)
 | 
			
		||||
        self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0]))
 | 
			
		||||
 | 
			
		||||
    def add_to_menu(self, game_name, exec_line):
 | 
			
		||||
        """Copy the .desktop file to ~/.local/share/applications."""
 | 
			
		||||
@@ -863,7 +935,7 @@ Icon={icon_path}
 | 
			
		||||
        icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
 | 
			
		||||
        if not os.path.exists(icon_path):
 | 
			
		||||
            if not generate_thumbnail(exe_path, icon_path, size=128):
 | 
			
		||||
                logger.error(f"Failed to generate thumbnail for {exe_path}")
 | 
			
		||||
                logger.error("Failed to generate thumbnail for game: %s", exe_path)
 | 
			
		||||
 | 
			
		||||
        desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
 | 
			
		||||
        os.makedirs(desktop_dir, exist_ok=True)
 | 
			
		||||
@@ -999,7 +1071,7 @@ Icon={icon_path}
 | 
			
		||||
        exe_path = self._parse_exe_path(exec_line, game_name)
 | 
			
		||||
        if not exe_path:
 | 
			
		||||
            return
 | 
			
		||||
        logger.debug("Adding '%s' to Steam", game_name)
 | 
			
		||||
        logger.debug("Adding game '%s' to Steam", game_name)
 | 
			
		||||
        try:
 | 
			
		||||
            success, message = add_to_steam(game_name, exec_line, cover_path)
 | 
			
		||||
            self.signals.show_info_dialog.emit(
 | 
			
		||||
@@ -1042,7 +1114,7 @@ Icon={icon_path}
 | 
			
		||||
            exe_path = self._parse_exe_path(exec_line, game_name)
 | 
			
		||||
            if not exe_path:
 | 
			
		||||
                return
 | 
			
		||||
            logger.debug("Removing non-EGS game '%s' from Steam", game_name)
 | 
			
		||||
            logger.debug("Removing game '%s' from Steam", game_name)
 | 
			
		||||
            try:
 | 
			
		||||
                success, message = remove_from_steam(game_name, exec_line)
 | 
			
		||||
                self.signals.show_info_dialog.emit(
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB  | 
| 
		 Before Width: | Height: | Size: 447 KiB  | 
@@ -1,3 +0,0 @@
 | 
			
		||||
name=Pulse Online
 | 
			
		||||
description_ru=Многопользовательская онлайн-игра в жанре MMORPG, действие которой происходит в научно-фантастическом мире с уникальной боевой системой и глубоким крафтом. Игроки могут исследовать обширные локации, выполнять квесты, сражаться с противниками и взаимодействовать с другими участниками игры.
 | 
			
		||||
description_en=A multiplayer online game in the MMORPG genre set in a sci-fi world with a unique combat system and deep crafting mechanics. Players can explore vast locations, complete quests, battle enemies, and interact with other participants in the game.
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB  | 
| 
		 Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB  | 
| 
		 Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB  | 
| 
		 Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB  | 
| 
		 Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB  | 
| 
		 Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB  | 
@@ -5,30 +5,63 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter
 | 
			
		||||
 | 
			
		||||
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
    """
 | 
			
		||||
    Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
 | 
			
		||||
    nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
 | 
			
		||||
    rect_width: доступная ширина контейнера.
 | 
			
		||||
    spacing: отступ между элементами.
 | 
			
		||||
    max_scale: максимальный коэффициент масштабирования (например, 1.2).
 | 
			
		||||
    Computes the layout of elements considering spacing and potential scaling of cards.
 | 
			
		||||
    nat_sizes: Array (N, 2) with natural sizes of elements (width, height).
 | 
			
		||||
    rect_width: Available container width.
 | 
			
		||||
    spacing: Spacing between elements (horizontal and vertical).
 | 
			
		||||
    max_scale: Maximum scaling factor (e.g., 1.0).
 | 
			
		||||
 | 
			
		||||
    Возвращает:
 | 
			
		||||
      result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
 | 
			
		||||
      total_height: итоговая высота всех рядов.
 | 
			
		||||
    Returns:
 | 
			
		||||
      result: Array (N, 4), where each row contains [x, y, new_width, new_height].
 | 
			
		||||
      total_height: Total height of all rows.
 | 
			
		||||
    """
 | 
			
		||||
    N = nat_sizes.shape[0]
 | 
			
		||||
    result = np.zeros((N, 4), dtype=np.int32)
 | 
			
		||||
    y = 0
 | 
			
		||||
    i = 0
 | 
			
		||||
    min_margin = 20  # Minimum margin on edges
 | 
			
		||||
 | 
			
		||||
    # Determine the maximum number of items per row and overall scale
 | 
			
		||||
    max_items_per_row = 0
 | 
			
		||||
    global_scale = 1.0
 | 
			
		||||
    max_row_x_start = min_margin  # Starting x position of the widest row
 | 
			
		||||
    temp_i = 0
 | 
			
		||||
 | 
			
		||||
    # First pass: Find the maximum number of items in a row
 | 
			
		||||
    while temp_i < N:
 | 
			
		||||
        sum_width = 0
 | 
			
		||||
        count = 0
 | 
			
		||||
        temp_j = temp_i
 | 
			
		||||
        while temp_j < N:
 | 
			
		||||
            w = nat_sizes[temp_j, 0]
 | 
			
		||||
            if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
 | 
			
		||||
                break
 | 
			
		||||
            sum_width += w
 | 
			
		||||
            count += 1
 | 
			
		||||
            temp_j += 1
 | 
			
		||||
 | 
			
		||||
        if count > max_items_per_row:
 | 
			
		||||
            max_items_per_row = count
 | 
			
		||||
            # Calculate scale for the most populated row
 | 
			
		||||
            available_width = rect_width - spacing * (count - 1) - 2 * min_margin
 | 
			
		||||
            desired_scale = available_width / sum_width if sum_width > 0 else 1.0
 | 
			
		||||
            global_scale = desired_scale if desired_scale < max_scale else max_scale
 | 
			
		||||
            # Store starting x position for the widest row
 | 
			
		||||
            scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
 | 
			
		||||
            max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
 | 
			
		||||
        temp_i = temp_j
 | 
			
		||||
 | 
			
		||||
    # Second pass: Place elements
 | 
			
		||||
    while i < N:
 | 
			
		||||
        sum_width = 0
 | 
			
		||||
        row_max_height = 0
 | 
			
		||||
        count = 0
 | 
			
		||||
        j = i
 | 
			
		||||
        # Подбираем количество элементов для текущего ряда
 | 
			
		||||
 | 
			
		||||
        # Determine the number of items for the current row
 | 
			
		||||
        while j < N:
 | 
			
		||||
            w = nat_sizes[j, 0]
 | 
			
		||||
            # Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
 | 
			
		||||
            if count > 0 and (sum_width + spacing + w) > rect_width:
 | 
			
		||||
            if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
 | 
			
		||||
                break
 | 
			
		||||
            sum_width += w
 | 
			
		||||
            count += 1
 | 
			
		||||
@@ -36,13 +69,19 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
            if h > row_max_height:
 | 
			
		||||
                row_max_height = h
 | 
			
		||||
            j += 1
 | 
			
		||||
        # Доступная ширина ряда с учетом обязательных отступов между элементами
 | 
			
		||||
        available_width = rect_width - spacing * (count - 1)
 | 
			
		||||
        desired_scale = available_width / sum_width if sum_width > 0 else 1.0
 | 
			
		||||
        # Разрешаем увеличение карточек, но не более max_scale
 | 
			
		||||
        scale = desired_scale if desired_scale < max_scale else max_scale
 | 
			
		||||
        # Выравниваем по левому краю (offset = 0)
 | 
			
		||||
        x = 0
 | 
			
		||||
 | 
			
		||||
        # Use global scale for all rows
 | 
			
		||||
        scale = global_scale
 | 
			
		||||
        scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
 | 
			
		||||
 | 
			
		||||
        # Determine starting x coordinate
 | 
			
		||||
        if count == max_items_per_row:
 | 
			
		||||
            # Center the full row
 | 
			
		||||
            x = max(min_margin, (rect_width - scaled_row_width) // 2)
 | 
			
		||||
        else:
 | 
			
		||||
            # Align incomplete row to the left, matching the widest row's start
 | 
			
		||||
            x = max_row_x_start
 | 
			
		||||
 | 
			
		||||
        for k in range(i, j):
 | 
			
		||||
            new_w = int(nat_sizes[k, 0] * scale)
 | 
			
		||||
            new_h = int(nat_sizes[k, 1] * scale)
 | 
			
		||||
@@ -51,6 +90,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
            result[k, 2] = new_w
 | 
			
		||||
            result[k, 3] = new_h
 | 
			
		||||
            x += new_w + spacing
 | 
			
		||||
 | 
			
		||||
        y += int(row_max_height * scale) + spacing
 | 
			
		||||
        i = j
 | 
			
		||||
    return result, y
 | 
			
		||||
@@ -59,18 +99,17 @@ class FlowLayout(QLayout):
 | 
			
		||||
    def __init__(self, parent=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.itemList = []
 | 
			
		||||
        # Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
 | 
			
		||||
        self.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        self._spacing = 3  # отступ между карточками
 | 
			
		||||
        self._max_scale = 1.2  # максимальное увеличение карточек (например, на 20%)
 | 
			
		||||
        self.setContentsMargins(20, 20, 20, 20)  # Margins around the layout
 | 
			
		||||
        self._spacing = 20  # Spacing for animation and overlap prevention
 | 
			
		||||
        self._max_scale = 1.0  # Scaling disabled in layout
 | 
			
		||||
 | 
			
		||||
    def addItem(self, item: QLayoutItem) -> None:
 | 
			
		||||
            self.itemList.append(item)
 | 
			
		||||
        self.itemList.append(item)
 | 
			
		||||
 | 
			
		||||
    def takeAt(self, index: int) -> QLayoutItem:
 | 
			
		||||
            if 0 <= index < len(self.itemList):
 | 
			
		||||
                return self.itemList.pop(index)
 | 
			
		||||
            raise IndexError("Index out of range")
 | 
			
		||||
        if 0 <= index < len(self.itemList):
 | 
			
		||||
            return self.itemList.pop(index)
 | 
			
		||||
        raise IndexError("Index out of range")
 | 
			
		||||
 | 
			
		||||
    def count(self) -> int:
 | 
			
		||||
        return len(self.itemList)
 | 
			
		||||
@@ -87,7 +126,21 @@ class FlowLayout(QLayout):
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def heightForWidth(self, width):
 | 
			
		||||
        return self.doLayout(QRect(0, 0, width, 0), True)
 | 
			
		||||
        # Аналогично фильтруем видимые для тестового расчёта высоты
 | 
			
		||||
        visible_items = []
 | 
			
		||||
        nat_sizes = np.empty((0, 2), dtype=np.int32)
 | 
			
		||||
        for item in self.itemList:
 | 
			
		||||
            if item.widget() and item.widget().isVisible():
 | 
			
		||||
                visible_items.append(item)
 | 
			
		||||
                s = item.sizeHint()
 | 
			
		||||
                new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
 | 
			
		||||
                nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
 | 
			
		||||
 | 
			
		||||
        if len(visible_items) == 0:
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        _, total_height = compute_layout(nat_sizes, width, self._spacing, self._max_scale)
 | 
			
		||||
        return total_height
 | 
			
		||||
 | 
			
		||||
    def setGeometry(self, rect):
 | 
			
		||||
        super().setGeometry(rect)
 | 
			
		||||
@@ -102,32 +155,50 @@ class FlowLayout(QLayout):
 | 
			
		||||
            size = size.expandedTo(item.minimumSize())
 | 
			
		||||
        margins = self.contentsMargins()
 | 
			
		||||
        size += QSize(margins.left() + margins.right(),
 | 
			
		||||
                             margins.top() + margins.bottom())
 | 
			
		||||
                      margins.top() + margins.bottom())
 | 
			
		||||
        return size
 | 
			
		||||
 | 
			
		||||
    def doLayout(self, rect, testOnly):
 | 
			
		||||
        N = len(self.itemList)
 | 
			
		||||
        if N == 0:
 | 
			
		||||
        N_total = len(self.itemList)
 | 
			
		||||
        if N_total == 0:
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        # Собираем натуральные размеры всех элементов в массив NumPy
 | 
			
		||||
        nat_sizes = np.empty((N, 2), dtype=np.int32)
 | 
			
		||||
        # Фильтруем только видимые элементы
 | 
			
		||||
        visible_items = []
 | 
			
		||||
        visible_indices = []  # Индексы в оригинальном itemList для установки геометрии
 | 
			
		||||
        nat_sizes = np.empty((0, 2), dtype=np.int32)
 | 
			
		||||
        for i, item in enumerate(self.itemList):
 | 
			
		||||
            s = item.sizeHint()
 | 
			
		||||
            nat_sizes[i, 0] = s.width()
 | 
			
		||||
            nat_sizes[i, 1] = s.height()
 | 
			
		||||
            if item.widget() and item.widget().isVisible():
 | 
			
		||||
                visible_items.append(item)
 | 
			
		||||
                visible_indices.append(i)
 | 
			
		||||
                s = item.sizeHint()
 | 
			
		||||
                new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
 | 
			
		||||
                nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
 | 
			
		||||
 | 
			
		||||
        N = len(visible_items)
 | 
			
		||||
        if N == 0:
 | 
			
		||||
            # Если все скрыты, устанавливаем нулевые геометрии для всех
 | 
			
		||||
            if not testOnly:
 | 
			
		||||
                for item in self.itemList:
 | 
			
		||||
                    item.setGeometry(QRect())
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        # Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
 | 
			
		||||
        geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
 | 
			
		||||
 | 
			
		||||
        if not testOnly:
 | 
			
		||||
            for i, item in enumerate(self.itemList):
 | 
			
		||||
                x = geom_array[i, 0] + rect.x()
 | 
			
		||||
                y = geom_array[i, 1] + rect.y()
 | 
			
		||||
                w = geom_array[i, 2]
 | 
			
		||||
                h = geom_array[i, 3]
 | 
			
		||||
            # Устанавливаем геометрии только для видимых
 | 
			
		||||
            for idx, (_vis_idx, item) in enumerate(zip(visible_indices, visible_items, strict=True)):
 | 
			
		||||
                x = geom_array[idx, 0] + rect.x()
 | 
			
		||||
                y = geom_array[idx, 1] + rect.y()
 | 
			
		||||
                w = geom_array[idx, 2]
 | 
			
		||||
                h = geom_array[idx, 3]
 | 
			
		||||
                item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
 | 
			
		||||
 | 
			
		||||
            # Для невидимых — нулевая геометрия
 | 
			
		||||
            for i in range(N_total):
 | 
			
		||||
                if i not in visible_indices:
 | 
			
		||||
                    self.itemList[i].setGeometry(QRect())
 | 
			
		||||
 | 
			
		||||
        return total_height
 | 
			
		||||
 | 
			
		||||
class ClickableLabel(QLabel):
 | 
			
		||||
@@ -152,7 +223,7 @@ class ClickableLabel(QLabel):
 | 
			
		||||
        self._icon_size = icon_size
 | 
			
		||||
        self._icon_space = icon_space
 | 
			
		||||
        self._font_scale_factor = font_scale_factor
 | 
			
		||||
        self._card_width = 250  # Значение по умолчанию
 | 
			
		||||
        self._card_width = 250
 | 
			
		||||
        if change_cursor:
 | 
			
		||||
            self.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.updateFontSize()
 | 
			
		||||
@@ -170,28 +241,23 @@ class ClickableLabel(QLabel):
 | 
			
		||||
        self.update()
 | 
			
		||||
 | 
			
		||||
    def setCardWidth(self, card_width: int):
 | 
			
		||||
        """Обновляет ширину карточки и пересчитывает размер шрифта."""
 | 
			
		||||
        self._card_width = card_width
 | 
			
		||||
        self.updateFontSize()
 | 
			
		||||
 | 
			
		||||
    def updateFontSize(self):
 | 
			
		||||
        """Обновляет размер шрифта на основе card_width и font_scale_factor."""
 | 
			
		||||
        font = self.font()
 | 
			
		||||
        font_size = int(self._card_width * self._font_scale_factor)
 | 
			
		||||
        font.setPointSize(max(8, font_size))  # Минимальный размер шрифта 8
 | 
			
		||||
        font.setPointSize(max(8, font_size))
 | 
			
		||||
        self.setFont(font)
 | 
			
		||||
        self.update()
 | 
			
		||||
 | 
			
		||||
    def paintEvent(self, event):
 | 
			
		||||
        painter = QPainter(self)
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
 | 
			
		||||
        rect = self.contentsRect()
 | 
			
		||||
        alignment = self.alignment()
 | 
			
		||||
 | 
			
		||||
        icon_size = self._icon_size
 | 
			
		||||
        spacing = self._icon_space
 | 
			
		||||
 | 
			
		||||
        text = self.text()
 | 
			
		||||
 | 
			
		||||
        if self._icon:
 | 
			
		||||
@@ -200,17 +266,11 @@ class ClickableLabel(QLabel):
 | 
			
		||||
            pixmap = None
 | 
			
		||||
 | 
			
		||||
        fm = QFontMetrics(self.font())
 | 
			
		||||
 | 
			
		||||
        # Считаем, сколько места остаётся под текст
 | 
			
		||||
        available_width = rect.width()
 | 
			
		||||
        if pixmap:
 | 
			
		||||
            available_width -= (icon_size + spacing)
 | 
			
		||||
        # Отступы по 2px с каждой стороны
 | 
			
		||||
        available_width = max(0, available_width - 4)
 | 
			
		||||
 | 
			
		||||
        # Получаем «обрезанный» текст с многоточием
 | 
			
		||||
        display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
 | 
			
		||||
 | 
			
		||||
        text_width = fm.horizontalAdvance(display_text)
 | 
			
		||||
        text_height = fm.height()
 | 
			
		||||
        total_width = text_width + (icon_size + spacing if pixmap else 0)
 | 
			
		||||
@@ -280,8 +340,6 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
 | 
			
		||||
        self.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.setFlat(True)
 | 
			
		||||
 | 
			
		||||
        # Изначально выставляем минимальную ширину
 | 
			
		||||
        self.setMinimumWidth(50)
 | 
			
		||||
        self.adjustFontSize()
 | 
			
		||||
 | 
			
		||||
@@ -312,7 +370,6 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        if not self._update_size:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Определяем доступную ширину внутри кнопки
 | 
			
		||||
        available_width = self.width()
 | 
			
		||||
        if self._icon:
 | 
			
		||||
            available_width -= self._icon_size
 | 
			
		||||
@@ -323,7 +380,6 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        font = QFont(self._original_font)
 | 
			
		||||
        text = self._original_text
 | 
			
		||||
 | 
			
		||||
        # Подбираем максимально возможный размер шрифта, при котором текст укладывается
 | 
			
		||||
        chosen_size = self._max_font_size
 | 
			
		||||
        for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
 | 
			
		||||
            font.setPointSize(font_size)
 | 
			
		||||
@@ -336,14 +392,12 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        font.setPointSize(chosen_size)
 | 
			
		||||
        self.setFont(font)
 | 
			
		||||
 | 
			
		||||
        # После выбора шрифта вычисляем требуемую ширину для полного отображения текста
 | 
			
		||||
        fm = QFontMetrics(font)
 | 
			
		||||
        text_width = fm.horizontalAdvance(text)
 | 
			
		||||
        required_width = text_width + margins.left() + margins.right() + self._padding * 2
 | 
			
		||||
        if self._icon:
 | 
			
		||||
            required_width += self._icon_size
 | 
			
		||||
 | 
			
		||||
        # Если текущая ширина меньше требуемой, обновляем минимальную ширину
 | 
			
		||||
        if self.width() < required_width:
 | 
			
		||||
            self.setMinimumWidth(required_width)
 | 
			
		||||
 | 
			
		||||
@@ -353,7 +407,6 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        if not self._update_size:
 | 
			
		||||
            return super().sizeHint()
 | 
			
		||||
        else:
 | 
			
		||||
            # Вычисляем оптимальный размер кнопки на основе текста и отступов
 | 
			
		||||
            font = self.font()
 | 
			
		||||
            fm = QFontMetrics(font)
 | 
			
		||||
            text_width = fm.horizontalAdvance(self._original_text)
 | 
			
		||||
@@ -364,7 +417,6 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
            height = fm.height() + margins.top() + margins.bottom() + self._padding
 | 
			
		||||
            return QSize(width, height)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NavLabel(QLabel):
 | 
			
		||||
    clicked = Signal()
 | 
			
		||||
 | 
			
		||||
@@ -376,7 +428,6 @@ class NavLabel(QLabel):
 | 
			
		||||
        self._isChecked = False
 | 
			
		||||
        self.setProperty("checked", self._isChecked)
 | 
			
		||||
        self.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        # Explicitly enable focus
 | 
			
		||||
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
 | 
			
		||||
    def setCheckable(self, checkable):
 | 
			
		||||
@@ -395,7 +446,6 @@ class NavLabel(QLabel):
 | 
			
		||||
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
        if event.button() == Qt.MouseButton.LeftButton:
 | 
			
		||||
            # Ensure widget can take focus on click
 | 
			
		||||
            self.setFocus(Qt.FocusReason.MouseFocusReason)
 | 
			
		||||
            if self._checkable:
 | 
			
		||||
                self.setChecked(not self._isChecked)
 | 
			
		||||
 
 | 
			
		||||
@@ -144,14 +144,21 @@ class Downloader(QObject):
 | 
			
		||||
                logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
 | 
			
		||||
                return None
 | 
			
		||||
            if url in self._cache:
 | 
			
		||||
                return self._cache[url]
 | 
			
		||||
                cached_path = self._cache[url]
 | 
			
		||||
                if os.path.exists(cached_path):
 | 
			
		||||
                    if os.path.abspath(cached_path) == os.path.abspath(local_path):
 | 
			
		||||
                        return cached_path
 | 
			
		||||
                else:
 | 
			
		||||
                    del self._cache[url]
 | 
			
		||||
        url_lock = self._get_url_lock(url)
 | 
			
		||||
        with url_lock:
 | 
			
		||||
            with self._global_lock:
 | 
			
		||||
                if url in self._last_error:
 | 
			
		||||
                    return None
 | 
			
		||||
                if url in self._cache:
 | 
			
		||||
                    return self._cache[url]
 | 
			
		||||
                    cached_path = self._cache[url]
 | 
			
		||||
                    if os.path.exists(cached_path) and os.path.abspath(cached_path) == os.path.abspath(local_path):
 | 
			
		||||
                        return cached_path
 | 
			
		||||
            result = download_with_cache(url, local_path, timeout, self)
 | 
			
		||||
            with self._global_lock:
 | 
			
		||||
                if result:
 | 
			
		||||
 
 | 
			
		||||
@@ -13,16 +13,17 @@ from portprotonqt.localization import get_egs_language, _
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async
 | 
			
		||||
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
 | 
			
		||||
from portprotonqt.steam_api import (
 | 
			
		||||
    get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
 | 
			
		||||
    search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail
 | 
			
		||||
    search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
 | 
			
		||||
)
 | 
			
		||||
import vdf
 | 
			
		||||
import shutil
 | 
			
		||||
import zlib
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from PySide6.QtGui import QPixmap
 | 
			
		||||
import base64
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
downloader = Downloader()
 | 
			
		||||
@@ -66,7 +67,8 @@ def get_cache_dir() -> Path:
 | 
			
		||||
 | 
			
		||||
def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None:
 | 
			
		||||
    """
 | 
			
		||||
    Removes an EGS game from Steam by modifying shortcuts.vdf and deleting the launch script.
 | 
			
		||||
    Removes an EGS game from Steam using CEF API or by modifying shortcuts.vdf and deleting the launch script.
 | 
			
		||||
    Also deletes associated cover files in the Steam grid directory.
 | 
			
		||||
    Calls the callback with (success, message).
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
@@ -74,6 +76,7 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
 | 
			
		||||
        portproton_dir: Path to the PortProton directory.
 | 
			
		||||
        callback: Callback function to handle the result (success, message).
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    if not portproton_dir:
 | 
			
		||||
        logger.error("PortProton directory not found")
 | 
			
		||||
        callback((False, "PortProton directory not found"))
 | 
			
		||||
@@ -101,51 +104,89 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
 | 
			
		||||
    unsigned_id = convert_steam_id(user_id)
 | 
			
		||||
    user_dir = os.path.join(userdata_dir, str(unsigned_id))
 | 
			
		||||
    steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    grid_dir = os.path.join(user_dir, "config", "grid")
 | 
			
		||||
 | 
			
		||||
    if not os.path.exists(steam_shortcuts_path):
 | 
			
		||||
        logger.error("Steam shortcuts file not found")
 | 
			
		||||
        callback((False, "Steam shortcuts file not found"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Find appid for the shortcut
 | 
			
		||||
    try:
 | 
			
		||||
        with open(steam_shortcuts_path, 'rb') as f:
 | 
			
		||||
            shortcuts_data = vdf.binary_load(f)
 | 
			
		||||
        shortcuts = shortcuts_data.get("shortcuts", {})
 | 
			
		||||
        appid = None
 | 
			
		||||
        for _key, entry in shortcuts.items():
 | 
			
		||||
            if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
 | 
			
		||||
                appid = convert_steam_id(int(entry.get("appid")))
 | 
			
		||||
                logger.info(f"Found matching shortcut for '{game_name}' with AppID {appid}")
 | 
			
		||||
                break
 | 
			
		||||
        if not appid:
 | 
			
		||||
            logger.info(f"Game '{game_name}' not found in Steam shortcuts")
 | 
			
		||||
            callback((False, f"Game '{game_name}' not found in Steam"))
 | 
			
		||||
            return
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to load shortcuts.vdf: {e}")
 | 
			
		||||
        callback((False, f"Failed to load shortcuts.vdf: {e}"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Try CEF API first
 | 
			
		||||
    logger.info(f"Attempting to remove EGS game '{game_name}' via Steam CEF API with AppID {appid}")
 | 
			
		||||
    api_response = call_steam_api("removeShortcut", appid)
 | 
			
		||||
    if api_response is not None:  # API responded, even if empty
 | 
			
		||||
        logger.info(f"Shortcut for AppID {appid} successfully removed via CEF API")
 | 
			
		||||
 | 
			
		||||
        # Delete cover files
 | 
			
		||||
        cover_files = [
 | 
			
		||||
            os.path.join(grid_dir, f"{appid}.jpg"),
 | 
			
		||||
            os.path.join(grid_dir, f"{appid}p.jpg"),
 | 
			
		||||
            os.path.join(grid_dir, f"{appid}_hero.jpg"),
 | 
			
		||||
            os.path.join(grid_dir, f"{appid}_logo.png")
 | 
			
		||||
        ]
 | 
			
		||||
        for cover_file in cover_files:
 | 
			
		||||
            if os.path.exists(cover_file):
 | 
			
		||||
                try:
 | 
			
		||||
                    os.remove(cover_file)
 | 
			
		||||
                    logger.info(f"Deleted cover file: {cover_file}")
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(f"Failed to delete cover file {cover_file}: {e}")
 | 
			
		||||
 | 
			
		||||
        # Delete launch script
 | 
			
		||||
        if os.path.exists(script_path):
 | 
			
		||||
            try:
 | 
			
		||||
                os.remove(script_path)
 | 
			
		||||
                logger.info(f"Removed EGS script: {script_path}")
 | 
			
		||||
            except OSError as e:
 | 
			
		||||
                logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
 | 
			
		||||
 | 
			
		||||
        callback((True, f"Game '{game_name}' was removed from Steam. Please restart Steam for changes to take effect."))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Fallback to VDF modification
 | 
			
		||||
    logger.warning("CEF API failed for EGS game removal; falling back to VDF modification")
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    try:
 | 
			
		||||
        shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
        logger.info("Created backup of shortcuts.vdf at %s", backup_path)
 | 
			
		||||
        logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
        callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        with open(steam_shortcuts_path, 'rb') as f:
 | 
			
		||||
            shortcuts_data = vdf.binary_load(f)
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to load shortcuts.vdf: {e}")
 | 
			
		||||
        callback((False, f"Failed to load shortcuts.vdf: {e}"))
 | 
			
		||||
        return
 | 
			
		||||
        new_shortcuts = {}
 | 
			
		||||
        index = 0
 | 
			
		||||
        for _key, entry in shortcuts.items():
 | 
			
		||||
            if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
 | 
			
		||||
                logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
 | 
			
		||||
                continue
 | 
			
		||||
            new_shortcuts[str(index)] = entry
 | 
			
		||||
            index += 1
 | 
			
		||||
 | 
			
		||||
    shortcuts = shortcuts_data.get("shortcuts", {})
 | 
			
		||||
    modified = False
 | 
			
		||||
    new_shortcuts = {}
 | 
			
		||||
    index = 0
 | 
			
		||||
 | 
			
		||||
    for _key, entry in shortcuts.items():
 | 
			
		||||
        if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
 | 
			
		||||
            modified = True
 | 
			
		||||
            logger.info("Removing EGS game '%s' from Steam shortcuts", game_name)
 | 
			
		||||
            continue
 | 
			
		||||
        new_shortcuts[str(index)] = entry
 | 
			
		||||
        index += 1
 | 
			
		||||
 | 
			
		||||
    if not modified:
 | 
			
		||||
        logger.error("Game '%s' not found in Steam shortcuts", game_name)
 | 
			
		||||
        callback((False, f"Game '{game_name}' not found in Steam shortcuts"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
            vdf.binary_dump({"shortcuts": new_shortcuts}, f)
 | 
			
		||||
        logger.info("Updated shortcuts.vdf, removed '%s'", game_name)
 | 
			
		||||
        logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
        if os.path.exists(backup_path):
 | 
			
		||||
@@ -157,10 +198,26 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
 | 
			
		||||
        callback((False, f"Failed to update shortcuts.vdf: {e}"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Delete cover files
 | 
			
		||||
    cover_files = [
 | 
			
		||||
        os.path.join(grid_dir, f"{appid}.jpg"),
 | 
			
		||||
        os.path.join(grid_dir, f"{appid}p.jpg"),
 | 
			
		||||
        os.path.join(grid_dir, f"{appid}_hero.jpg"),
 | 
			
		||||
        os.path.join(grid_dir, f"{appid}_logo.png")
 | 
			
		||||
    ]
 | 
			
		||||
    for cover_file in cover_files:
 | 
			
		||||
        if os.path.exists(cover_file):
 | 
			
		||||
            try:
 | 
			
		||||
                os.remove(cover_file)
 | 
			
		||||
                logger.info(f"Deleted cover file: {cover_file}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to delete cover file {cover_file}: {e}")
 | 
			
		||||
 | 
			
		||||
    # Delete launch script
 | 
			
		||||
    if os.path.exists(script_path):
 | 
			
		||||
        try:
 | 
			
		||||
            os.remove(script_path)
 | 
			
		||||
            logger.info("Removed EGS script: %s", script_path)
 | 
			
		||||
            logger.info(f"Removed EGS script: {script_path}")
 | 
			
		||||
        except OSError as e:
 | 
			
		||||
            logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
 | 
			
		||||
 | 
			
		||||
@@ -168,11 +225,17 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
 | 
			
		||||
 | 
			
		||||
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag.
 | 
			
		||||
    Asynchronously adds an EGS game to Steam via CEF API or shortcuts.vdf with PortProton tag.
 | 
			
		||||
    Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
 | 
			
		||||
    Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
 | 
			
		||||
    Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
 | 
			
		||||
    Calls the callback with (success, message).
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        app_name: The Legendary app_name (unique identifier for the game).
 | 
			
		||||
        game_title: The display name of the game.
 | 
			
		||||
        legendary_path: Path to the Legendary CLI executable.
 | 
			
		||||
        callback: Callback function to handle the result (success, message).
 | 
			
		||||
    """
 | 
			
		||||
    if not app_name or not app_name.strip() or not game_title or not game_title.strip():
 | 
			
		||||
        logger.error("Invalid app_name or game_title: empty or whitespace")
 | 
			
		||||
@@ -191,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Determine wrapper
 | 
			
		||||
    wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
			
		||||
    start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
 | 
			
		||||
    if portproton_dir is not None and ".var" not in portproton_dir:
 | 
			
		||||
        wrapper = start_sh_path
 | 
			
		||||
        if not os.path.exists(start_sh_path):
 | 
			
		||||
            logger.error(f"start.sh not found at {start_sh_path}")
 | 
			
		||||
            callback((False, f"start.sh not found at {start_sh_path}"))
 | 
			
		||||
            return
 | 
			
		||||
    wrapper = get_portproton_start_command()
 | 
			
		||||
 | 
			
		||||
    # Create launch script
 | 
			
		||||
    steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
 | 
			
		||||
@@ -267,47 +323,47 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
    grid_dir = user_dir / "config" / "grid"
 | 
			
		||||
    os.makedirs(grid_dir, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
    # Backup shortcuts.vdf
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
            logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
            callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
 | 
			
		||||
            return
 | 
			
		||||
    # Try CEF API first
 | 
			
		||||
    logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API")
 | 
			
		||||
    api_response = call_steam_api(
 | 
			
		||||
        "createShortcut",
 | 
			
		||||
        game_title,
 | 
			
		||||
        script_path,
 | 
			
		||||
        str(Path(script_path).parent),
 | 
			
		||||
        icon_path,
 | 
			
		||||
        ""
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Generate unique appid
 | 
			
		||||
    unique_string = f"{script_path}{game_title}"
 | 
			
		||||
    baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
    appid = baseid | 0x80000000
 | 
			
		||||
    if appid > 0x7FFFFFFF:
 | 
			
		||||
        aidvdf = appid - 0x100000000
 | 
			
		||||
    appid = None
 | 
			
		||||
    was_api_used = False
 | 
			
		||||
 | 
			
		||||
    if api_response and isinstance(api_response, dict) and 'id' in api_response:
 | 
			
		||||
        appid = api_response['id']
 | 
			
		||||
        was_api_used = True
 | 
			
		||||
        logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}")
 | 
			
		||||
    else:
 | 
			
		||||
        aidvdf = appid
 | 
			
		||||
        logger.warning("CEF API failed for EGS game addition; falling back to VDF modification")
 | 
			
		||||
        # Backup shortcuts.vdf
 | 
			
		||||
        backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
        if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
            try:
 | 
			
		||||
                shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
                logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
                callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
    downloaded_count = 0
 | 
			
		||||
    total_covers = 4
 | 
			
		||||
    download_lock = threading.Lock()
 | 
			
		||||
        # Generate unique appid
 | 
			
		||||
        unique_string = f"{script_path}{game_title}"
 | 
			
		||||
        baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
        appid = baseid | 0x80000000
 | 
			
		||||
        if appid > 0x7FFFFFFF:
 | 
			
		||||
            aidvdf = appid - 0x100000000
 | 
			
		||||
        else:
 | 
			
		||||
            aidvdf = appid
 | 
			
		||||
 | 
			
		||||
    def on_cover_download(cover_file: str, cover_type: str):
 | 
			
		||||
        nonlocal downloaded_count
 | 
			
		||||
        try:
 | 
			
		||||
            if cover_file and os.path.exists(cover_file):
 | 
			
		||||
                logger.info(f"Downloaded cover {cover_type} to {cover_file}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
 | 
			
		||||
        with download_lock:
 | 
			
		||||
            downloaded_count += 1
 | 
			
		||||
            if downloaded_count == total_covers:
 | 
			
		||||
                finalize_shortcut()
 | 
			
		||||
 | 
			
		||||
    def finalize_shortcut():
 | 
			
		||||
        tags_dict = {'0': 'PortProton'}
 | 
			
		||||
        # Create shortcut entry
 | 
			
		||||
        shortcut = {
 | 
			
		||||
            "appid": aidvdf,
 | 
			
		||||
            "AppName": game_title,
 | 
			
		||||
@@ -322,7 +378,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
            "Devkit": 0,
 | 
			
		||||
            "DevkitGameID": "",
 | 
			
		||||
            "LastPlayTime": 0,
 | 
			
		||||
            "tags": tags_dict
 | 
			
		||||
            "tags": {'0': 'PortProton'}
 | 
			
		||||
        }
 | 
			
		||||
        logger.info(f"Shortcut entry for EGS game: {shortcut}")
 | 
			
		||||
 | 
			
		||||
@@ -353,6 +409,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
 | 
			
		||||
            with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
                vdf.binary_dump({"shortcuts": shortcuts}, f)
 | 
			
		||||
            logger.info(f"EGS game '{game_title}' added to Steam via VDF")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
            if os.path.exists(backup_path):
 | 
			
		||||
@@ -364,8 +421,42 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
            callback((False, f"Failed to update shortcuts.vdf: {e}"))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        logger.info(f"EGS game '{game_title}' added to Steam")
 | 
			
		||||
        callback((True, f"Game '{game_title}' added to Steam with covers"))
 | 
			
		||||
    if not appid:
 | 
			
		||||
        callback((False, "Failed to create shortcut via any method"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
    downloaded_count = 0
 | 
			
		||||
    total_covers = 4
 | 
			
		||||
    download_lock = threading.Lock()
 | 
			
		||||
 | 
			
		||||
    def on_cover_download(cover_file: str | None, cover_type: str, index: int):
 | 
			
		||||
        nonlocal downloaded_count
 | 
			
		||||
        try:
 | 
			
		||||
            if cover_file is None or not os.path.exists(cover_file):
 | 
			
		||||
                logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
 | 
			
		||||
                with download_lock:
 | 
			
		||||
                    downloaded_count += 1
 | 
			
		||||
                    if downloaded_count == total_covers:
 | 
			
		||||
                        callback((True, f"Game '{game_title}' added to Steam with covers"))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            logger.info(f"Downloaded cover {cover_type} to {cover_file}")
 | 
			
		||||
            if was_api_used:
 | 
			
		||||
                try:
 | 
			
		||||
                    with open(cover_file, 'rb') as f:
 | 
			
		||||
                        img_b64 = base64.b64encode(f.read()).decode('utf-8')
 | 
			
		||||
                    logger.info(f"Applying cover type '{cover_type}' via API for AppID {appid}")
 | 
			
		||||
                    ext = Path(cover_type).suffix.lstrip('.')
 | 
			
		||||
                    call_steam_api("setGrid", appid, index, ext, img_b64)
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(f"Error applying cover '{cover_type}' via API: {e}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
 | 
			
		||||
        with download_lock:
 | 
			
		||||
            downloaded_count += 1
 | 
			
		||||
            if downloaded_count == total_covers:
 | 
			
		||||
                callback((True, f"Game '{game_title}' added to Steam with covers"))
 | 
			
		||||
 | 
			
		||||
    def on_steam_apps(steam_data: tuple[list, dict]):
 | 
			
		||||
        nonlocal steam_appid
 | 
			
		||||
@@ -375,24 +466,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
 | 
			
		||||
        if not steam_appid:
 | 
			
		||||
            logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
 | 
			
		||||
            finalize_shortcut()
 | 
			
		||||
            callback((True, f"Game '{game_title}' added to Steam"))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        cover_types = [
 | 
			
		||||
            (".jpg", "header.jpg"),
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg"),
 | 
			
		||||
            ("_hero.jpg", "library_hero.jpg"),
 | 
			
		||||
            ("_logo.png", "logo.png")
 | 
			
		||||
            (".jpg", "header.jpg", 0),
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg", 1),
 | 
			
		||||
            ("_hero.jpg", "library_hero.jpg", 2),
 | 
			
		||||
            ("_logo.png", "logo.png", 3)
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for suffix, cover_type in cover_types:
 | 
			
		||||
        for suffix, cover_type, index in cover_types:
 | 
			
		||||
            cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
 | 
			
		||||
            cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
 | 
			
		||||
            downloader.download_async(
 | 
			
		||||
                cover_url,
 | 
			
		||||
                cover_file,
 | 
			
		||||
                timeout=5,
 | 
			
		||||
                callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
 | 
			
		||||
                callback=lambda result, ctype=cover_type, idx=index: on_cover_download(result, ctype, idx)
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    get_steam_apps_and_index_async(on_steam_apps)
 | 
			
		||||
@@ -747,6 +838,11 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
 | 
			
		||||
    games: list[tuple] = []
 | 
			
		||||
    cache_dir.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
    user_json_path = cache_dir / "user.json"
 | 
			
		||||
    if not user_json_path.exists():
 | 
			
		||||
        callback(games)
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    def process_games(installed_games: list | None):
 | 
			
		||||
        if installed_games is None:
 | 
			
		||||
            logger.info("No installed Epic Games Store games found")
 | 
			
		||||
@@ -855,12 +951,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
 | 
			
		||||
                                        app_name,
 | 
			
		||||
                                        f"legendary:launch:{app_name}",
 | 
			
		||||
                                        "",
 | 
			
		||||
                                        last_launch,  # Время последнего запуска
 | 
			
		||||
                                        formatted_playtime,  # Форматированное время игры
 | 
			
		||||
                                        protondb_tier,  # ProtonDB tier
 | 
			
		||||
                                        last_launch,
 | 
			
		||||
                                        formatted_playtime,
 | 
			
		||||
                                        protondb_tier,
 | 
			
		||||
                                        status or "",
 | 
			
		||||
                                        last_launch_timestamp,  # Временная метка последнего запуска
 | 
			
		||||
                                        playtime_seconds,  # Время игры в секундах
 | 
			
		||||
                                        last_launch_timestamp,
 | 
			
		||||
                                        playtime_seconds,
 | 
			
		||||
                                        "epic"
 | 
			
		||||
                                    )
 | 
			
		||||
                                    pending_images -= 1
 | 
			
		||||
@@ -880,7 +976,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
 | 
			
		||||
                    get_protondb_tier_async(steam_appid, on_protondb_tier)
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.debug(f"No Steam app found for EGS game {title}")
 | 
			
		||||
                    on_protondb_tier("")  # Proceed with empty ProtonDB tier
 | 
			
		||||
                    on_protondb_tier("")
 | 
			
		||||
 | 
			
		||||
            get_steam_apps_and_index_async(on_steam_apps)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,38 +1,37 @@
 | 
			
		||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices
 | 
			
		||||
from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl
 | 
			
		||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
 | 
			
		||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
 | 
			
		||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
 | 
			
		||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.custom_widgets import ClickableLabel
 | 
			
		||||
from portprotonqt.portproton_api import PortProtonAPI
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
import weakref
 | 
			
		||||
from portprotonqt.animations import GameCardAnimations
 | 
			
		||||
from typing import cast
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameCard(QFrame):
 | 
			
		||||
    borderWidthChanged = Signal()
 | 
			
		||||
    gradientAngleChanged = Signal()
 | 
			
		||||
    # Signals for context menu actions
 | 
			
		||||
    editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path
 | 
			
		||||
    deleteGameRequested = Signal(str, str)        # name, exec_line
 | 
			
		||||
    addToMenuRequested = Signal(str, str)         # name, exec_line
 | 
			
		||||
    removeFromMenuRequested = Signal(str)         # name
 | 
			
		||||
    addToDesktopRequested = Signal(str, str)      # name, exec_line
 | 
			
		||||
    removeFromDesktopRequested = Signal(str)      # name
 | 
			
		||||
    addToSteamRequested = Signal(str, str, str)   # name, exec_line, cover_path
 | 
			
		||||
    removeFromSteamRequested = Signal(str, str)   # name, exec_line
 | 
			
		||||
    openGameFolderRequested = Signal(str, str)    # name, exec_line
 | 
			
		||||
    scaleChanged = Signal()
 | 
			
		||||
    editShortcutRequested = Signal(str, str, str)
 | 
			
		||||
    deleteGameRequested = Signal(str, str)
 | 
			
		||||
    addToMenuRequested = Signal(str, str)
 | 
			
		||||
    removeFromMenuRequested = Signal(str)
 | 
			
		||||
    addToDesktopRequested = Signal(str, str)
 | 
			
		||||
    removeFromDesktopRequested = Signal(str)
 | 
			
		||||
    addToSteamRequested = Signal(str, str, str)
 | 
			
		||||
    removeFromSteamRequested = Signal(str, str)
 | 
			
		||||
    openGameFolderRequested = Signal(str, str)
 | 
			
		||||
    hoverChanged = Signal(str, bool)
 | 
			
		||||
    focusChanged = Signal(str, bool)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
 | 
			
		||||
                last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
 | 
			
		||||
                select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
 | 
			
		||||
                 last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
 | 
			
		||||
                 select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.name = name
 | 
			
		||||
        self.description = description
 | 
			
		||||
@@ -47,14 +46,16 @@ class GameCard(QFrame):
 | 
			
		||||
        self.game_source = game_source
 | 
			
		||||
        self.last_launch_ts = last_launch_ts
 | 
			
		||||
        self.playtime_seconds = playtime_seconds
 | 
			
		||||
        self.card_width = card_width
 | 
			
		||||
        self.base_card_width = card_width
 | 
			
		||||
        self.base_pixmap = None
 | 
			
		||||
        self.base_font_size = None
 | 
			
		||||
 | 
			
		||||
        self.select_callback = select_callback
 | 
			
		||||
        self.context_menu_manager = context_menu_manager
 | 
			
		||||
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
 | 
			
		||||
        self.customContextMenuRequested.connect(self._show_context_menu)
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
 | 
			
		||||
        self.display_filter = read_display_filter()
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
@@ -65,80 +66,46 @@ class GameCard(QFrame):
 | 
			
		||||
        self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
        self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
 | 
			
		||||
        # Дополнительное пространство для анимации
 | 
			
		||||
        extra_margin = 20
 | 
			
		||||
        self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
 | 
			
		||||
        self.base_extra_margin = 20
 | 
			
		||||
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
 | 
			
		||||
 | 
			
		||||
        # Параметры анимации обводки
 | 
			
		||||
        self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
 | 
			
		||||
        self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
 | 
			
		||||
        self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
 | 
			
		||||
        self._hovered = False
 | 
			
		||||
        self._focused = False
 | 
			
		||||
 | 
			
		||||
        # Анимации
 | 
			
		||||
        self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
 | 
			
		||||
        self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
 | 
			
		||||
        self.gradient_anim = None
 | 
			
		||||
        self.pulse_anim = None
 | 
			
		||||
        self.animations = GameCardAnimations(self, self.theme)
 | 
			
		||||
        self.animations.setup_animations()
 | 
			
		||||
 | 
			
		||||
        # Флаг для отслеживания подключения слота startPulseAnimation
 | 
			
		||||
        self._isPulseAnimationConnected = False
 | 
			
		||||
        self.shadow = QGraphicsDropShadowEffect(self)
 | 
			
		||||
        self.shadow.setBlurRadius(20)
 | 
			
		||||
        self.shadow.setColor(QColor(0, 0, 0, 150))
 | 
			
		||||
        self.shadow.setOffset(0, 0)
 | 
			
		||||
        self.setGraphicsEffect(self.shadow)
 | 
			
		||||
 | 
			
		||||
        # Тень
 | 
			
		||||
        shadow = QGraphicsDropShadowEffect(self)
 | 
			
		||||
        shadow.setBlurRadius(20)
 | 
			
		||||
        shadow.setColor(QColor(0, 0, 0, 150))
 | 
			
		||||
        shadow.setOffset(0, 0)
 | 
			
		||||
        self.setGraphicsEffect(shadow)
 | 
			
		||||
        self.layout_ = QVBoxLayout(self)
 | 
			
		||||
        self.layout_.setSpacing(5)
 | 
			
		||||
        self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
 | 
			
		||||
 | 
			
		||||
        # Отступы
 | 
			
		||||
        layout = QVBoxLayout(self)
 | 
			
		||||
        layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2)
 | 
			
		||||
        layout.setSpacing(5)
 | 
			
		||||
 | 
			
		||||
        # Контейнер обложки
 | 
			
		||||
        coverWidget = QWidget()
 | 
			
		||||
        coverWidget.setFixedSize(card_width, int(card_width * 1.2))
 | 
			
		||||
        coverLayout = QStackedLayout(coverWidget)
 | 
			
		||||
        self.coverWidget = QWidget()
 | 
			
		||||
        coverLayout = QStackedLayout(self.coverWidget)
 | 
			
		||||
        coverLayout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
 | 
			
		||||
 | 
			
		||||
        # Обложка
 | 
			
		||||
        self.coverLabel = QLabel()
 | 
			
		||||
        self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
 | 
			
		||||
        self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
 | 
			
		||||
        coverLayout.addWidget(self.coverLabel)
 | 
			
		||||
 | 
			
		||||
        # создаём слабую ссылку на label
 | 
			
		||||
        label_ref = weakref.ref(self.coverLabel)
 | 
			
		||||
        load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded)
 | 
			
		||||
 | 
			
		||||
        def on_cover_loaded(pixmap):
 | 
			
		||||
            label = label_ref()
 | 
			
		||||
            if label is None:
 | 
			
		||||
                return
 | 
			
		||||
            label.setPixmap(round_corners(pixmap, 15))
 | 
			
		||||
 | 
			
		||||
        # асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async)
 | 
			
		||||
        load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded)
 | 
			
		||||
 | 
			
		||||
        # Значок избранного (звёздочка) в левом верхнем углу обложки
 | 
			
		||||
        self.favoriteLabel = ClickableLabel(coverWidget)
 | 
			
		||||
        self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize)
 | 
			
		||||
        self.favoriteLabel.move(8, 8)
 | 
			
		||||
        self.favoriteLabel = ClickableLabel(self.coverWidget)
 | 
			
		||||
        self.favoriteLabel.clicked.connect(self.toggle_favorite)
 | 
			
		||||
        self.is_favorite = self.name in read_favorites()
 | 
			
		||||
        self.update_favorite_icon()
 | 
			
		||||
        self.favoriteLabel.raise_()
 | 
			
		||||
 | 
			
		||||
        # Определяем общие параметры для бейджей
 | 
			
		||||
        badge_width = int(card_width * 2/3)
 | 
			
		||||
        icon_size = int(card_width * 0.06)  # 6% от ширины карточки
 | 
			
		||||
        icon_space = int(card_width * 0.012)  # 1.2% от ширины карточки
 | 
			
		||||
        font_scale_factor = 0.06  # Шрифт будет 6% от card_width
 | 
			
		||||
 | 
			
		||||
        # ProtonDB бейдж
 | 
			
		||||
        tier_text = self.getProtonDBText(protondb_tier)
 | 
			
		||||
        if tier_text:
 | 
			
		||||
            icon_filename = self.getProtonDBIconFilename(protondb_tier)
 | 
			
		||||
@@ -146,67 +113,50 @@ class GameCard(QFrame):
 | 
			
		||||
            self.protondbLabel = ClickableLabel(
 | 
			
		||||
                tier_text,
 | 
			
		||||
                icon=icon,
 | 
			
		||||
                parent=coverWidget,
 | 
			
		||||
                icon_size=icon_size,
 | 
			
		||||
                icon_space=icon_space,
 | 
			
		||||
                font_scale_factor=font_scale_factor
 | 
			
		||||
                parent=self.coverWidget,
 | 
			
		||||
                font_scale_factor=0.06
 | 
			
		||||
            )
 | 
			
		||||
            self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
 | 
			
		||||
            self.protondbLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.protondbLabel.setCardWidth(card_width)
 | 
			
		||||
        else:
 | 
			
		||||
            self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
 | 
			
		||||
            self.protondbLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.protondbLabel = ClickableLabel("", parent=self.coverWidget)
 | 
			
		||||
            self.protondbLabel.setVisible(False)
 | 
			
		||||
 | 
			
		||||
        # Steam бейдж
 | 
			
		||||
        steam_icon = self.theme_manager.get_icon("steam")
 | 
			
		||||
        self.steamLabel = ClickableLabel(
 | 
			
		||||
            "Steam",
 | 
			
		||||
            icon=steam_icon,
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor
 | 
			
		||||
            parent=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06
 | 
			
		||||
        )
 | 
			
		||||
        self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
 | 
			
		||||
        self.steamLabel.setFixedWidth(badge_width)
 | 
			
		||||
        self.steamLabel.setCardWidth(card_width)
 | 
			
		||||
        self.steamLabel.setVisible(self.steam_visible)
 | 
			
		||||
 | 
			
		||||
        # Epic Games Store бейдж
 | 
			
		||||
        egs_icon = self.theme_manager.get_icon("epic_games")
 | 
			
		||||
        self.egsLabel = ClickableLabel(
 | 
			
		||||
            "Epic Games",
 | 
			
		||||
            icon=egs_icon,
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor,
 | 
			
		||||
            parent=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06,
 | 
			
		||||
            change_cursor=False
 | 
			
		||||
        )
 | 
			
		||||
        self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
 | 
			
		||||
        self.egsLabel.setFixedWidth(badge_width)
 | 
			
		||||
        self.egsLabel.setCardWidth(card_width)
 | 
			
		||||
        self.egsLabel.setVisible(self.egs_visible)
 | 
			
		||||
 | 
			
		||||
        # PortProton бейдж
 | 
			
		||||
        portproton_icon = self.theme_manager.get_icon("ppqt-tray")
 | 
			
		||||
        portproton_icon = self.theme_manager.get_icon("portproton")
 | 
			
		||||
        self.portprotonLabel = ClickableLabel(
 | 
			
		||||
            "PortProton",
 | 
			
		||||
            icon=portproton_icon,
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor
 | 
			
		||||
            parent=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06
 | 
			
		||||
        )
 | 
			
		||||
        self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
 | 
			
		||||
        self.portprotonLabel.setFixedWidth(badge_width)
 | 
			
		||||
        self.portprotonLabel.setCardWidth(card_width)
 | 
			
		||||
        self.portprotonLabel.setVisible(self.portproton_visible)
 | 
			
		||||
        self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
 | 
			
		||||
 | 
			
		||||
        # WeAntiCheatYet бейдж
 | 
			
		||||
        anticheat_text = self.getAntiCheatText(anticheat_status)
 | 
			
		||||
        if anticheat_text:
 | 
			
		||||
            icon_filename = self.getAntiCheatIconFilename(anticheat_status)
 | 
			
		||||
@@ -214,40 +164,57 @@ class GameCard(QFrame):
 | 
			
		||||
            self.anticheatLabel = ClickableLabel(
 | 
			
		||||
                anticheat_text,
 | 
			
		||||
                icon=icon,
 | 
			
		||||
                parent=coverWidget,
 | 
			
		||||
                icon_size=icon_size,
 | 
			
		||||
                icon_space=icon_space,
 | 
			
		||||
                font_scale_factor=font_scale_factor
 | 
			
		||||
                parent=self.coverWidget,
 | 
			
		||||
                font_scale_factor=0.06
 | 
			
		||||
            )
 | 
			
		||||
            self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
 | 
			
		||||
            self.anticheatLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.anticheatLabel.setCardWidth(card_width)
 | 
			
		||||
        else:
 | 
			
		||||
            self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
 | 
			
		||||
            self.anticheatLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.anticheatLabel = ClickableLabel("", parent=self.coverWidget)
 | 
			
		||||
            self.anticheatLabel.setVisible(False)
 | 
			
		||||
 | 
			
		||||
        # Расположение бейджей
 | 
			
		||||
        self._position_badges(card_width)
 | 
			
		||||
        self.protondbLabel.clicked.connect(self.open_protondb_report)
 | 
			
		||||
        self.steamLabel.clicked.connect(self.open_steam_page)
 | 
			
		||||
        self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
 | 
			
		||||
 | 
			
		||||
        layout.addWidget(coverWidget)
 | 
			
		||||
        self.layout_.addWidget(self.coverWidget)
 | 
			
		||||
 | 
			
		||||
        # Название игры
 | 
			
		||||
        nameLabel = QLabel(name)
 | 
			
		||||
        nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
 | 
			
		||||
        layout.addWidget(nameLabel)
 | 
			
		||||
        self.nameLabel = QLabel(name)
 | 
			
		||||
        self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
 | 
			
		||||
        self.layout_.addWidget(self.nameLabel)
 | 
			
		||||
 | 
			
		||||
    def _position_badges(self, card_width):
 | 
			
		||||
        """Позиционирует бейджи на основе ширины карточки."""
 | 
			
		||||
        right_margin = 8
 | 
			
		||||
        badge_spacing = int(card_width * 0.02)  # 2% от ширины карточки
 | 
			
		||||
        top_y = 10
 | 
			
		||||
        font_size = self.nameLabel.font().pointSizeF()
 | 
			
		||||
        self.base_font_size = font_size if font_size > 0 else 10.0
 | 
			
		||||
 | 
			
		||||
        self.update_scale()
 | 
			
		||||
 | 
			
		||||
        # Force initial layout update to ensure correct geometry
 | 
			
		||||
        self.updateGeometry()
 | 
			
		||||
        parent = self.parentWidget()
 | 
			
		||||
        if parent:
 | 
			
		||||
            layout = parent.layout()
 | 
			
		||||
            if layout:
 | 
			
		||||
                layout.invalidate()
 | 
			
		||||
            parent.updateGeometry()
 | 
			
		||||
 | 
			
		||||
    def on_cover_loaded(self, pixmap):
 | 
			
		||||
        self.base_pixmap = pixmap
 | 
			
		||||
        self.update_cover_pixmap()
 | 
			
		||||
 | 
			
		||||
    def update_cover_pixmap(self):
 | 
			
		||||
        if self.base_pixmap:
 | 
			
		||||
            scaled_width = int(self.base_card_width * self._scale)
 | 
			
		||||
            scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
 | 
			
		||||
            rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
 | 
			
		||||
            self.coverLabel.setPixmap(rounded_pixmap)
 | 
			
		||||
 | 
			
		||||
    def _position_badges(self, current_width):
 | 
			
		||||
        right_margin = int(8 * self._scale)
 | 
			
		||||
        badge_spacing = int(current_width * 0.02)
 | 
			
		||||
        top_y = int(10 * self._scale)
 | 
			
		||||
        badge_y_positions = []
 | 
			
		||||
        badge_width = int(card_width * 2/3)
 | 
			
		||||
        badge_width = int(current_width * 2/3)
 | 
			
		||||
 | 
			
		||||
        badges = [
 | 
			
		||||
            (self.steam_visible, self.steamLabel),
 | 
			
		||||
@@ -259,80 +226,99 @@ class GameCard(QFrame):
 | 
			
		||||
 | 
			
		||||
        for is_visible, badge in badges:
 | 
			
		||||
            if is_visible:
 | 
			
		||||
                badge_x = card_width - badge_width - right_margin
 | 
			
		||||
                badge_x = current_width - badge_width - right_margin
 | 
			
		||||
                badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
 | 
			
		||||
                badge.move(badge_x, badge_y)
 | 
			
		||||
                badge.move(int(badge_x), int(badge_y))
 | 
			
		||||
                badge_y_positions.append(badge_y + badge.height())
 | 
			
		||||
 | 
			
		||||
        # Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
 | 
			
		||||
        self.anticheatLabel.raise_()
 | 
			
		||||
        self.protondbLabel.raise_()
 | 
			
		||||
        self.portprotonLabel.raise_()
 | 
			
		||||
        self.egsLabel.raise_()
 | 
			
		||||
        self.steamLabel.raise_()
 | 
			
		||||
 | 
			
		||||
    def update_card_size(self, new_width: int):
 | 
			
		||||
        """Обновляет размер карточки, обложки и бейджей."""
 | 
			
		||||
        self.card_width = new_width
 | 
			
		||||
        extra_margin = 20
 | 
			
		||||
        self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin)
 | 
			
		||||
    def update_scale(self):
 | 
			
		||||
        scaled_width = int(self.base_card_width * self._scale)
 | 
			
		||||
        scaled_height = int(self.base_card_width * 1.8 * self._scale)
 | 
			
		||||
        scaled_extra = int(self.base_extra_margin * self._scale)
 | 
			
		||||
        self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
 | 
			
		||||
        self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
 | 
			
		||||
 | 
			
		||||
        if self.coverLabel is None:
 | 
			
		||||
            return
 | 
			
		||||
        self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
 | 
			
		||||
        self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
 | 
			
		||||
 | 
			
		||||
        coverWidget = self.coverLabel.parentWidget()
 | 
			
		||||
        if coverWidget is None:
 | 
			
		||||
            return
 | 
			
		||||
        self.update_cover_pixmap()
 | 
			
		||||
 | 
			
		||||
        coverWidget.setFixedSize(new_width, int(new_width * 1.2))
 | 
			
		||||
        self.coverLabel.setFixedSize(new_width, int(new_width * 1.2))
 | 
			
		||||
        favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale))
 | 
			
		||||
        self.favoriteLabel.setFixedSize(*favorite_size)
 | 
			
		||||
        self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale))
 | 
			
		||||
 | 
			
		||||
        label_ref = weakref.ref(self.coverLabel)
 | 
			
		||||
        def on_cover_loaded(pixmap):
 | 
			
		||||
            label = label_ref()
 | 
			
		||||
            if label:
 | 
			
		||||
                scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
 | 
			
		||||
                rounded_pixmap = round_corners(scaled_pixmap, 15)
 | 
			
		||||
                label.setPixmap(rounded_pixmap)
 | 
			
		||||
 | 
			
		||||
        load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded)
 | 
			
		||||
 | 
			
		||||
        # Обновляем размеры и шрифты бейджей
 | 
			
		||||
        badge_width = int(new_width * 2/3)
 | 
			
		||||
        icon_size = int(new_width * 0.06)
 | 
			
		||||
        icon_space = int(new_width * 0.012)
 | 
			
		||||
        badge_width = int(scaled_width * 2/3)
 | 
			
		||||
        icon_size = int(scaled_width * 0.06)
 | 
			
		||||
        icon_space = int(scaled_width * 0.012)
 | 
			
		||||
        for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
 | 
			
		||||
            if label is not None:
 | 
			
		||||
                label.setFixedWidth(badge_width)
 | 
			
		||||
                label.setIconSize(icon_size, icon_space)
 | 
			
		||||
                label.setCardWidth(new_width)  # Пересчитываем размер шрифта
 | 
			
		||||
                label.setCardWidth(scaled_width)
 | 
			
		||||
 | 
			
		||||
        # Перепозиционируем бейджи
 | 
			
		||||
        self._position_badges(new_width)
 | 
			
		||||
        self._position_badges(scaled_width)
 | 
			
		||||
 | 
			
		||||
        if self.base_font_size is not None:
 | 
			
		||||
            font = self.nameLabel.font()
 | 
			
		||||
            new_font_size = self.base_font_size * self._scale
 | 
			
		||||
            if new_font_size > 0:
 | 
			
		||||
                font.setPointSizeF(new_font_size)
 | 
			
		||||
                self.nameLabel.setFont(font)
 | 
			
		||||
 | 
			
		||||
        self.shadow.setBlurRadius(int(20 * self._scale))
 | 
			
		||||
 | 
			
		||||
        self.updateGeometry()
 | 
			
		||||
        self.update()
 | 
			
		||||
 | 
			
		||||
        # Ensure parent layout is updated safely
 | 
			
		||||
        parent = self.parentWidget()
 | 
			
		||||
        if parent:
 | 
			
		||||
            layout = parent.layout()
 | 
			
		||||
            if layout:
 | 
			
		||||
                layout.invalidate()
 | 
			
		||||
                layout.activate()
 | 
			
		||||
                layout.update()
 | 
			
		||||
            parent.updateGeometry()
 | 
			
		||||
 | 
			
		||||
    def update_card_size(self, new_width: int):
 | 
			
		||||
        self.base_card_width = new_width
 | 
			
		||||
        load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.5), self.on_cover_loaded)
 | 
			
		||||
        self.update_scale()
 | 
			
		||||
 | 
			
		||||
    def update_badge_visibility(self, display_filter: str):
 | 
			
		||||
        """Обновляет видимость бейджей на основе display_filter."""
 | 
			
		||||
        self.display_filter = display_filter
 | 
			
		||||
        self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites"))
 | 
			
		||||
        self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites"))
 | 
			
		||||
        self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
 | 
			
		||||
        self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
        self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
        self.portproton_visible = (str(self.game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
        protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
 | 
			
		||||
        anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
 | 
			
		||||
 | 
			
		||||
        # Обновляем видимость бейджей
 | 
			
		||||
        self.steamLabel.setVisible(self.steam_visible)
 | 
			
		||||
        self.egsLabel.setVisible(self.egs_visible)
 | 
			
		||||
        self.portprotonLabel.setVisible(self.portproton_visible)
 | 
			
		||||
        self.protondbLabel.setVisible(protondb_visible)
 | 
			
		||||
        self.anticheatLabel.setVisible(anticheat_visible)
 | 
			
		||||
 | 
			
		||||
        # Перепозиционируем бейджи
 | 
			
		||||
        self._position_badges(self.card_width)
 | 
			
		||||
        scaled_width = int(self.base_card_width * self._scale)
 | 
			
		||||
        self._position_badges(scaled_width)
 | 
			
		||||
 | 
			
		||||
        # Update layout after visibility changes
 | 
			
		||||
        self.updateGeometry()
 | 
			
		||||
        parent = self.parentWidget()
 | 
			
		||||
        if parent:
 | 
			
		||||
            layout = parent.layout()
 | 
			
		||||
            if layout:
 | 
			
		||||
                layout.invalidate()
 | 
			
		||||
                layout.update()
 | 
			
		||||
            parent.updateGeometry()
 | 
			
		||||
 | 
			
		||||
    def _show_context_menu(self, pos):
 | 
			
		||||
        """Delegate context menu display to ContextMenuManager."""
 | 
			
		||||
        if self.context_menu_manager:
 | 
			
		||||
            self.context_menu_manager.show_context_menu(self, pos)
 | 
			
		||||
 | 
			
		||||
@@ -390,7 +376,6 @@ class GameCard(QFrame):
 | 
			
		||||
        return ""
 | 
			
		||||
 | 
			
		||||
    def open_portproton_forum_topic(self):
 | 
			
		||||
        """Open the PortProton forum topic or search page for this game."""
 | 
			
		||||
        result = self.portproton_api.get_forum_topic_slug(self.name)
 | 
			
		||||
        base_url = "https://linux-gaming.ru/"
 | 
			
		||||
        if result.startswith("search?q="):
 | 
			
		||||
@@ -419,6 +404,13 @@ class GameCard(QFrame):
 | 
			
		||||
            self.favoriteLabel.setText("☆")
 | 
			
		||||
        self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
 | 
			
		||||
 | 
			
		||||
        parent = self.parent()
 | 
			
		||||
        while parent:
 | 
			
		||||
            if hasattr(parent, 'game_library_manager'):
 | 
			
		||||
                QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
 | 
			
		||||
                break
 | 
			
		||||
            parent = parent.parent()
 | 
			
		||||
 | 
			
		||||
    def toggle_favorite(self):
 | 
			
		||||
        favorites = read_favorites()
 | 
			
		||||
        if self.is_favorite:
 | 
			
		||||
@@ -450,138 +442,38 @@ class GameCard(QFrame):
 | 
			
		||||
            self.gradientAngleChanged.emit()
 | 
			
		||||
            self.update()
 | 
			
		||||
 | 
			
		||||
    def getScale(self) -> float:
 | 
			
		||||
        return self._scale
 | 
			
		||||
 | 
			
		||||
    def setScale(self, value: float):
 | 
			
		||||
        if self._scale != value:
 | 
			
		||||
            self._scale = value
 | 
			
		||||
            self.update_scale()
 | 
			
		||||
            self.scaleChanged.emit()
 | 
			
		||||
 | 
			
		||||
    borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
 | 
			
		||||
    gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
 | 
			
		||||
    scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def paintEvent(self, event):
 | 
			
		||||
        super().paintEvent(event)
 | 
			
		||||
        painter = QPainter(self)
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
 | 
			
		||||
        pen = QPen()
 | 
			
		||||
        pen.setWidth(self._borderWidth)
 | 
			
		||||
        if self._hovered or self._focused:
 | 
			
		||||
            center = self.rect().center()
 | 
			
		||||
            gradient = QConicalGradient(center, self._gradientAngle)
 | 
			
		||||
            for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
 | 
			
		||||
                gradient.setColorAt(stop["position"], QColor(stop["color"]))
 | 
			
		||||
            pen.setBrush(QBrush(gradient))
 | 
			
		||||
        else:
 | 
			
		||||
            pen.setColor(QColor(0, 0, 0, 0))
 | 
			
		||||
 | 
			
		||||
        painter.setPen(pen)
 | 
			
		||||
        radius = 18
 | 
			
		||||
        bw = round(self._borderWidth / 2)
 | 
			
		||||
        rect = self.rect().adjusted(bw, bw, -bw, -bw)
 | 
			
		||||
        painter.drawRoundedRect(rect, radius, radius)
 | 
			
		||||
 | 
			
		||||
    def startPulseAnimation(self):
 | 
			
		||||
        if not (self._hovered or self._focused):
 | 
			
		||||
            return
 | 
			
		||||
        if self.pulse_anim:
 | 
			
		||||
            self.pulse_anim.stop()
 | 
			
		||||
        self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
 | 
			
		||||
        self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["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.start()
 | 
			
		||||
        self.animations.paint_border(QPainter(self))
 | 
			
		||||
 | 
			
		||||
    def enterEvent(self, event):
 | 
			
		||||
        self._hovered = True
 | 
			
		||||
        self.hoverChanged.emit(self.name, True)
 | 
			
		||||
        self.setFocus(Qt.FocusReason.MouseFocusReason)
 | 
			
		||||
 | 
			
		||||
        self.thickness_anim.stop()
 | 
			
		||||
        if self._isPulseAnimationConnected:
 | 
			
		||||
            self.thickness_anim.finished.disconnect(self.startPulseAnimation)
 | 
			
		||||
            self._isPulseAnimationConnected = False
 | 
			
		||||
        self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
 | 
			
		||||
        self.thickness_anim.setStartValue(self._borderWidth)
 | 
			
		||||
        self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
 | 
			
		||||
        self.thickness_anim.finished.connect(self.startPulseAnimation)
 | 
			
		||||
        self._isPulseAnimationConnected = True
 | 
			
		||||
        self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
        if self.gradient_anim:
 | 
			
		||||
            self.gradient_anim.stop()
 | 
			
		||||
        self.gradient_anim = QPropertyAnimation(self, 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.setLoopCount(-1)
 | 
			
		||||
        self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
        self.animations.handle_enter_event()
 | 
			
		||||
        super().enterEvent(event)
 | 
			
		||||
 | 
			
		||||
    def leaveEvent(self, event):
 | 
			
		||||
        self._hovered = False
 | 
			
		||||
        self.hoverChanged.emit(self.name, False)
 | 
			
		||||
        if not self._focused:
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
                self.thickness_anim.finished.disconnect(self.startPulseAnimation)
 | 
			
		||||
                self._isPulseAnimationConnected = False
 | 
			
		||||
            self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
 | 
			
		||||
            self.thickness_anim.setStartValue(self._borderWidth)
 | 
			
		||||
            self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
        self.animations.handle_leave_event()
 | 
			
		||||
        super().leaveEvent(event)
 | 
			
		||||
 | 
			
		||||
    def focusInEvent(self, event):
 | 
			
		||||
        if not self._hovered:
 | 
			
		||||
            self._focused = True
 | 
			
		||||
            self.focusChanged.emit(self.name, True)
 | 
			
		||||
 | 
			
		||||
            self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
                self.thickness_anim.finished.disconnect(self.startPulseAnimation)
 | 
			
		||||
                self._isPulseAnimationConnected = False
 | 
			
		||||
            self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
 | 
			
		||||
            self.thickness_anim.setStartValue(self._borderWidth)
 | 
			
		||||
            self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
 | 
			
		||||
            self.thickness_anim.finished.connect(self.startPulseAnimation)
 | 
			
		||||
            self._isPulseAnimationConnected = True
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
            self.gradient_anim = QPropertyAnimation(self, 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.setLoopCount(-1)
 | 
			
		||||
            self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
        self.animations.handle_focus_in_event()
 | 
			
		||||
        super().focusInEvent(event)
 | 
			
		||||
 | 
			
		||||
    def focusOutEvent(self, event):
 | 
			
		||||
        self._focused = False
 | 
			
		||||
        self.focusChanged.emit(self.name, False)
 | 
			
		||||
        if not self._hovered:
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
                self.thickness_anim.finished.disconnect(self.startPulseAnimation)
 | 
			
		||||
                self._isPulseAnimationConnected = False
 | 
			
		||||
            self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
 | 
			
		||||
            self.thickness_anim.setStartValue(self._borderWidth)
 | 
			
		||||
            self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
        self.animations.handle_focus_out_event()
 | 
			
		||||
        super().focusOutEvent(event)
 | 
			
		||||
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
@@ -601,6 +493,7 @@ class GameCard(QFrame):
 | 
			
		||||
            )
 | 
			
		||||
        super().mousePressEvent(event)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def keyPressEvent(self, event):
 | 
			
		||||
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
 | 
			
		||||
            self.select_callback(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										470
									
								
								portprotonqt/game_library_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,470 @@
 | 
			
		||||
from typing import Protocol
 | 
			
		||||
from portprotonqt.game_card import GameCard
 | 
			
		||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
 | 
			
		||||
from PySide6.QtCore import Qt, QTimer
 | 
			
		||||
from portprotonqt.custom_widgets import FlowLayout
 | 
			
		||||
from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async
 | 
			
		||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
 | 
			
		||||
from collections import deque
 | 
			
		||||
 | 
			
		||||
class MainWindowProtocol(Protocol):
 | 
			
		||||
    """Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
 | 
			
		||||
 | 
			
		||||
    def openGameDetailPage(
 | 
			
		||||
        self,
 | 
			
		||||
        name: str,
 | 
			
		||||
        description: str,
 | 
			
		||||
        cover_path: str | None = None,
 | 
			
		||||
        appid: str = "",
 | 
			
		||||
        exec_line: str = "",
 | 
			
		||||
        controller_support: str = "",
 | 
			
		||||
        last_launch: str = "",
 | 
			
		||||
        formatted_playtime: str = "",
 | 
			
		||||
        protondb_tier: str = "",
 | 
			
		||||
        game_source: str = "",
 | 
			
		||||
        anticheat_status: str = "",
 | 
			
		||||
    ) -> None: ...
 | 
			
		||||
 | 
			
		||||
    def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ...
 | 
			
		||||
 | 
			
		||||
    def on_slider_released(self) -> None: ...
 | 
			
		||||
 | 
			
		||||
    # Required attributes
 | 
			
		||||
    searchEdit: CustomLineEdit
 | 
			
		||||
    _last_card_width: int
 | 
			
		||||
    card_width: int
 | 
			
		||||
    current_hovered_card: GameCard | None
 | 
			
		||||
    current_focused_card: GameCard | None
 | 
			
		||||
    gamesListWidget: QWidget | None
 | 
			
		||||
 | 
			
		||||
class GameLibraryManager:
 | 
			
		||||
    def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
 | 
			
		||||
        self.main_window = main_window
 | 
			
		||||
        self.theme = theme
 | 
			
		||||
        self.context_menu_manager: ContextMenuManager | None = context_menu_manager
 | 
			
		||||
        self.games: list[tuple] = []
 | 
			
		||||
        self.filtered_games: list[tuple] = []
 | 
			
		||||
        self.game_card_cache = {}
 | 
			
		||||
        self.pending_images = {}
 | 
			
		||||
        self.card_width = read_card_size()
 | 
			
		||||
        self.gamesListWidget: QWidget | None = None
 | 
			
		||||
        self.gamesListLayout: FlowLayout | None = None
 | 
			
		||||
        self.sizeSlider: QSlider | None = None
 | 
			
		||||
        self._update_timer: QTimer | None = None
 | 
			
		||||
        self._pending_update = False
 | 
			
		||||
        self.pending_deletions = deque()
 | 
			
		||||
        self.is_filtering = False
 | 
			
		||||
        self.dirty = False
 | 
			
		||||
 | 
			
		||||
    def create_games_library_widget(self):
 | 
			
		||||
        """Creates the games library widget with search, grid, and slider."""
 | 
			
		||||
        self.gamesLibraryWidget = QWidget()
 | 
			
		||||
        self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
 | 
			
		||||
        layout = QVBoxLayout(self.gamesLibraryWidget)
 | 
			
		||||
        layout.setSpacing(15)
 | 
			
		||||
 | 
			
		||||
        # Search widget
 | 
			
		||||
        searchWidget, self.searchEdit = self.main_window.createSearchWidget()
 | 
			
		||||
        layout.addWidget(searchWidget)
 | 
			
		||||
 | 
			
		||||
        # Scroll area for game grid
 | 
			
		||||
        scrollArea = QScrollArea()
 | 
			
		||||
        scrollArea.setWidgetResizable(True)
 | 
			
		||||
        scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
 | 
			
		||||
        QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
 | 
			
		||||
 | 
			
		||||
        self.gamesListWidget = QWidget()
 | 
			
		||||
        self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
 | 
			
		||||
        self.gamesListLayout = FlowLayout(self.gamesListWidget)
 | 
			
		||||
        self.gamesListWidget.setLayout(self.gamesListLayout)
 | 
			
		||||
 | 
			
		||||
        scrollArea.setWidget(self.gamesListWidget)
 | 
			
		||||
        layout.addWidget(scrollArea)
 | 
			
		||||
 | 
			
		||||
        # Slider for card size
 | 
			
		||||
        sliderLayout = QHBoxLayout()
 | 
			
		||||
        sliderLayout.addStretch()
 | 
			
		||||
 | 
			
		||||
        self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
 | 
			
		||||
        self.sizeSlider.setMinimum(200)
 | 
			
		||||
        self.sizeSlider.setMaximum(250)
 | 
			
		||||
        self.sizeSlider.setValue(self.card_width)
 | 
			
		||||
        self.sizeSlider.setTickInterval(10)
 | 
			
		||||
        self.sizeSlider.setFixedWidth(150)
 | 
			
		||||
        self.sizeSlider.setToolTip(f"{self.card_width} px")
 | 
			
		||||
        self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
 | 
			
		||||
        self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
 | 
			
		||||
        sliderLayout.addWidget(self.sizeSlider)
 | 
			
		||||
 | 
			
		||||
        layout.addLayout(sliderLayout)
 | 
			
		||||
 | 
			
		||||
        # Initialize update timer
 | 
			
		||||
        self._update_timer = QTimer()
 | 
			
		||||
        self._update_timer.setSingleShot(True)
 | 
			
		||||
        self._update_timer.setInterval(100)  # 100ms debounce
 | 
			
		||||
        self._update_timer.timeout.connect(self._perform_update)
 | 
			
		||||
 | 
			
		||||
        # Calculate initial card width
 | 
			
		||||
        def calculate_card_width():
 | 
			
		||||
            if self.gamesListLayout is None:
 | 
			
		||||
                return
 | 
			
		||||
            available_width = scrollArea.width() - 20
 | 
			
		||||
            spacing = self.gamesListLayout._spacing
 | 
			
		||||
            target_cards_per_row = 8
 | 
			
		||||
            calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
 | 
			
		||||
            calculated_width = max(200, min(calculated_width, 250))
 | 
			
		||||
 | 
			
		||||
        QTimer.singleShot(0, calculate_card_width)
 | 
			
		||||
 | 
			
		||||
        # Connect scroll event for lazy loading
 | 
			
		||||
        scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
 | 
			
		||||
 | 
			
		||||
        return self.gamesLibraryWidget
 | 
			
		||||
 | 
			
		||||
    def on_slider_released(self):
 | 
			
		||||
        """Handles slider release to update card size."""
 | 
			
		||||
        if self.sizeSlider is None:
 | 
			
		||||
            return
 | 
			
		||||
        self.card_width = self.sizeSlider.value()
 | 
			
		||||
        self.sizeSlider.setToolTip(f"{self.card_width} px")
 | 
			
		||||
        save_card_size(self.card_width)
 | 
			
		||||
        self.main_window.card_width = self.card_width
 | 
			
		||||
        self.main_window._last_card_width = self.card_width
 | 
			
		||||
        for card in self.game_card_cache.values():
 | 
			
		||||
            card.update_card_size(self.card_width)
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
 | 
			
		||||
    def load_visible_images(self):
 | 
			
		||||
        """Loads images for visible game cards."""
 | 
			
		||||
        if self.gamesListWidget is None:
 | 
			
		||||
            return
 | 
			
		||||
        visible_region = self.gamesListWidget.visibleRegion()
 | 
			
		||||
        max_concurrent_loads = 5
 | 
			
		||||
        loaded_count = 0
 | 
			
		||||
        for card_key, card in self.game_card_cache.items():
 | 
			
		||||
            if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads:
 | 
			
		||||
                cover_path, width, height, callback = self.pending_images.pop(card_key)
 | 
			
		||||
                load_pixmap_async(cover_path, width, height, callback)
 | 
			
		||||
                loaded_count += 1
 | 
			
		||||
 | 
			
		||||
    def _on_card_focused(self, game_name: str, is_focused: bool):
 | 
			
		||||
        """Handles card focus events."""
 | 
			
		||||
        card_key = None
 | 
			
		||||
        for key, card in self.game_card_cache.items():
 | 
			
		||||
            if card.name == game_name:
 | 
			
		||||
                card_key = key
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
        if not card_key:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        card = self.game_card_cache[card_key]
 | 
			
		||||
 | 
			
		||||
        if is_focused:
 | 
			
		||||
            if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
 | 
			
		||||
                self.main_window.current_hovered_card._hovered = False
 | 
			
		||||
                self.main_window.current_hovered_card.leaveEvent(None)
 | 
			
		||||
                self.main_window.current_hovered_card = None
 | 
			
		||||
            if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
 | 
			
		||||
                self.main_window.current_focused_card._focused = False
 | 
			
		||||
                self.main_window.current_focused_card.clearFocus()
 | 
			
		||||
            self.main_window.current_focused_card = card
 | 
			
		||||
        else:
 | 
			
		||||
            if self.main_window.current_focused_card == card:
 | 
			
		||||
                self.main_window.current_focused_card = None
 | 
			
		||||
 | 
			
		||||
    def _on_card_hovered(self, game_name: str, is_hovered: bool):
 | 
			
		||||
        """Handles card hover events."""
 | 
			
		||||
        card_key = None
 | 
			
		||||
        for key, card in self.game_card_cache.items():
 | 
			
		||||
            if card.name == game_name:
 | 
			
		||||
                card_key = key
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
        if not card_key:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        card = self.game_card_cache[card_key]
 | 
			
		||||
 | 
			
		||||
        if is_hovered:
 | 
			
		||||
            if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
 | 
			
		||||
                self.main_window.current_focused_card._focused = False
 | 
			
		||||
                self.main_window.current_focused_card.clearFocus()
 | 
			
		||||
            if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
 | 
			
		||||
                self.main_window.current_hovered_card._hovered = False
 | 
			
		||||
                self.main_window.current_hovered_card.leaveEvent(None)
 | 
			
		||||
            self.main_window.current_hovered_card = card
 | 
			
		||||
        else:
 | 
			
		||||
            if self.main_window.current_hovered_card == card:
 | 
			
		||||
                self.main_window.current_hovered_card = None
 | 
			
		||||
 | 
			
		||||
    def _perform_update(self):
 | 
			
		||||
        """Performs the actual grid update."""
 | 
			
		||||
        if not self._pending_update:
 | 
			
		||||
            return
 | 
			
		||||
        self._pending_update = False
 | 
			
		||||
        self._update_game_grid_immediate()
 | 
			
		||||
 | 
			
		||||
    def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False):
 | 
			
		||||
        """Schedules a game grid update with debouncing."""
 | 
			
		||||
        if not is_filter:
 | 
			
		||||
            if games_list is not None:
 | 
			
		||||
                self.filtered_games = games_list
 | 
			
		||||
            self.dirty = True  # Full rebuild only for non-filter
 | 
			
		||||
        self.is_filtering = is_filter
 | 
			
		||||
        self._pending_update = True
 | 
			
		||||
 | 
			
		||||
        if self._update_timer is not None:
 | 
			
		||||
            self._update_timer.start()
 | 
			
		||||
        else:
 | 
			
		||||
            self._update_game_grid_immediate()
 | 
			
		||||
 | 
			
		||||
    def force_update_cards_library(self):
 | 
			
		||||
        if self.gamesListWidget and self.gamesListLayout:
 | 
			
		||||
            self.gamesListLayout.invalidate()
 | 
			
		||||
            self.gamesListWidget.updateGeometry()
 | 
			
		||||
            widget = self.gamesListWidget
 | 
			
		||||
            QTimer.singleShot(0, lambda: (
 | 
			
		||||
                widget.adjustSize(),
 | 
			
		||||
                widget.updateGeometry()
 | 
			
		||||
            ))
 | 
			
		||||
 | 
			
		||||
    def _update_game_grid_immediate(self):
 | 
			
		||||
        """Updates the game grid with the provided or current game list."""
 | 
			
		||||
        if self.gamesListLayout is None or self.gamesListWidget is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        search_text = self.main_window.searchEdit.text().strip().lower()
 | 
			
		||||
 | 
			
		||||
        if self.is_filtering:
 | 
			
		||||
            # Filter mode: do not change layout, only hide/show cards
 | 
			
		||||
            self._apply_filter_visibility(search_text)
 | 
			
		||||
        else:
 | 
			
		||||
            # Full update: sorting, removal/addition, reorganization
 | 
			
		||||
            games_list = self.filtered_games if self.filtered_games else self.games
 | 
			
		||||
            favorites = read_favorites()
 | 
			
		||||
            sort_method = read_sort_method()
 | 
			
		||||
 | 
			
		||||
            # Batch layout updates (extended scope)
 | 
			
		||||
            self.gamesListWidget.setUpdatesEnabled(False)
 | 
			
		||||
            if self.gamesListLayout is not None:
 | 
			
		||||
                self.gamesListLayout.setEnabled(False)  # Disable layout during batch
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
                # Optimized sorting: Partition favorites first, then sort subgroups
 | 
			
		||||
                def partition_sort_key(game):
 | 
			
		||||
                    name = game[0]
 | 
			
		||||
                    is_fav = name in favorites
 | 
			
		||||
                    fav_order = 0 if is_fav else 1
 | 
			
		||||
                    if sort_method == "playtime":
 | 
			
		||||
                        return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0)
 | 
			
		||||
                    elif sort_method == "alphabetical":
 | 
			
		||||
                        return (fav_order, name.lower())
 | 
			
		||||
                    elif sort_method == "favorites":
 | 
			
		||||
                        return (fav_order,)
 | 
			
		||||
                    else:
 | 
			
		||||
                        return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
 | 
			
		||||
 | 
			
		||||
                # Quick partition: Sort favorites and non-favorites separately, then merge
 | 
			
		||||
                fav_games = [g for g in games_list if g[0] in favorites]
 | 
			
		||||
                non_fav_games = [g for g in games_list if g[0] not in favorites]
 | 
			
		||||
                sorted_fav = sorted(fav_games, key=partition_sort_key)
 | 
			
		||||
                sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
 | 
			
		||||
                sorted_games = sorted_fav + sorted_non_fav
 | 
			
		||||
 | 
			
		||||
                # Build set of current game keys for faster lookup
 | 
			
		||||
                current_game_keys = {(game[0], game[4]) for game in sorted_games}
 | 
			
		||||
 | 
			
		||||
                # Remove cards that no longer exist (batch)
 | 
			
		||||
                cards_to_remove = []
 | 
			
		||||
                for card_key in list(self.game_card_cache.keys()):
 | 
			
		||||
                    if card_key not in current_game_keys:
 | 
			
		||||
                        cards_to_remove.append(card_key)
 | 
			
		||||
 | 
			
		||||
                for card_key in cards_to_remove:
 | 
			
		||||
                    card = self.game_card_cache.pop(card_key)
 | 
			
		||||
                    if self.gamesListLayout is not None:
 | 
			
		||||
                        self.gamesListLayout.removeWidget(card)
 | 
			
		||||
                    self.pending_deletions.append(card)  # Defer
 | 
			
		||||
                    if card_key in self.pending_images:
 | 
			
		||||
                        del self.pending_images[card_key]
 | 
			
		||||
 | 
			
		||||
                # Track current layout order (only if dirty/full update needed)
 | 
			
		||||
                if self.dirty and self.gamesListLayout is not None:
 | 
			
		||||
                    current_layout_order = []
 | 
			
		||||
                    for i in range(self.gamesListLayout.count()):
 | 
			
		||||
                        item = self.gamesListLayout.itemAt(i)
 | 
			
		||||
                        if item is not None:
 | 
			
		||||
                            widget = item.widget()
 | 
			
		||||
                            if widget:
 | 
			
		||||
                                for key, card in self.game_card_cache.items():
 | 
			
		||||
                                    if card == widget:
 | 
			
		||||
                                        current_layout_order.append(key)
 | 
			
		||||
                                        break
 | 
			
		||||
                else:
 | 
			
		||||
                    current_layout_order = None  # Skip reorg if not dirty
 | 
			
		||||
 | 
			
		||||
                new_card_order = []
 | 
			
		||||
                cards_to_add = []
 | 
			
		||||
 | 
			
		||||
                for game_data in sorted_games:
 | 
			
		||||
                    game_name = game_data[0]
 | 
			
		||||
                    exec_line = game_data[4]
 | 
			
		||||
                    game_key = (game_name, exec_line)
 | 
			
		||||
                    should_be_visible = not search_text or search_text in game_name.lower()
 | 
			
		||||
 | 
			
		||||
                    if game_key in self.game_card_cache:
 | 
			
		||||
                        card = self.game_card_cache[game_key]
 | 
			
		||||
                        if card.isVisible() != should_be_visible:
 | 
			
		||||
                            card.setVisible(should_be_visible)
 | 
			
		||||
                        new_card_order.append(game_key)
 | 
			
		||||
                    else:
 | 
			
		||||
                        if self.context_menu_manager is None:
 | 
			
		||||
                            continue
 | 
			
		||||
 | 
			
		||||
                        card = self._create_game_card(game_data)
 | 
			
		||||
                        self.game_card_cache[game_key] = card
 | 
			
		||||
                        card.setVisible(should_be_visible)
 | 
			
		||||
                        new_card_order.append(game_key)
 | 
			
		||||
                        cards_to_add.append((game_key, card))
 | 
			
		||||
 | 
			
		||||
                # Only reorganize if order changed AND dirty
 | 
			
		||||
                if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order):
 | 
			
		||||
                    # Remove all widgets from layout (batch)
 | 
			
		||||
                    while self.gamesListLayout.count():
 | 
			
		||||
                        self.gamesListLayout.takeAt(0)
 | 
			
		||||
 | 
			
		||||
                    # Add widgets in new order (batch)
 | 
			
		||||
                    for game_key in new_card_order:
 | 
			
		||||
                        card = self.game_card_cache[game_key]
 | 
			
		||||
                        self.gamesListLayout.addWidget(card)
 | 
			
		||||
 | 
			
		||||
                self.dirty = False  # Reset flag
 | 
			
		||||
 | 
			
		||||
                # Deferred deletions (run in timer to avoid stack overflow)
 | 
			
		||||
                if self.pending_deletions:
 | 
			
		||||
                    QTimer.singleShot(0, lambda: self._flush_deletions())
 | 
			
		||||
 | 
			
		||||
                # Load visible images for new cards only
 | 
			
		||||
                if cards_to_add:
 | 
			
		||||
                    self.load_visible_images()
 | 
			
		||||
 | 
			
		||||
            finally:
 | 
			
		||||
                if self.gamesListLayout is not None:
 | 
			
		||||
                    self.gamesListLayout.setEnabled(True)
 | 
			
		||||
                self.gamesListWidget.setUpdatesEnabled(True)
 | 
			
		||||
                if self.gamesListLayout is not None:
 | 
			
		||||
                    self.gamesListLayout.update()
 | 
			
		||||
                self.gamesListWidget.updateGeometry()
 | 
			
		||||
                self.main_window._last_card_width = self.card_width
 | 
			
		||||
 | 
			
		||||
                self.force_update_cards_library()
 | 
			
		||||
 | 
			
		||||
        self.is_filtering = False  # Reset flag in any case
 | 
			
		||||
 | 
			
		||||
    def _apply_filter_visibility(self, search_text: str):
 | 
			
		||||
        """Applies visibility to cards based on search, without changing the layout."""
 | 
			
		||||
        visible_count = 0
 | 
			
		||||
        for game_key, card in self.game_card_cache.items():
 | 
			
		||||
            game_name = card.name  # Assume GameCard has 'name' attribute
 | 
			
		||||
            should_be_visible = not search_text or search_text in game_name.lower()
 | 
			
		||||
            if card.isVisible() != should_be_visible:
 | 
			
		||||
                card.setVisible(should_be_visible)
 | 
			
		||||
            if should_be_visible:
 | 
			
		||||
                visible_count += 1
 | 
			
		||||
                # Load image only for newly visible cards
 | 
			
		||||
                if game_key in self.pending_images:
 | 
			
		||||
                    cover_path, width, height, callback = self.pending_images.pop(game_key)
 | 
			
		||||
                    load_pixmap_async(cover_path, width, height, callback)
 | 
			
		||||
 | 
			
		||||
        # Force full relayout after visibility changes
 | 
			
		||||
        if self.gamesListLayout is not None:
 | 
			
		||||
            self.gamesListLayout.invalidate()  # Принудительно инвалидируем для пересчёта
 | 
			
		||||
            self.gamesListLayout.update()
 | 
			
		||||
        if self.gamesListWidget is not None:
 | 
			
		||||
            self.gamesListWidget.updateGeometry()
 | 
			
		||||
        self.main_window._last_card_width = self.card_width
 | 
			
		||||
 | 
			
		||||
        # If search is empty, load images for visible ones
 | 
			
		||||
        if not search_text:
 | 
			
		||||
            self.load_visible_images()
 | 
			
		||||
 | 
			
		||||
    def _create_game_card(self, game_data: tuple) -> GameCard:
 | 
			
		||||
        """Creates a new game card with all necessary connections."""
 | 
			
		||||
        card = GameCard(
 | 
			
		||||
            *game_data,
 | 
			
		||||
            select_callback=self.main_window.openGameDetailPage,
 | 
			
		||||
            theme=self.theme,
 | 
			
		||||
            card_width=self.card_width,
 | 
			
		||||
            context_menu_manager=self.context_menu_manager
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        card.hoverChanged.connect(self._on_card_hovered)
 | 
			
		||||
        card.focusChanged.connect(self._on_card_focused)
 | 
			
		||||
 | 
			
		||||
        if self.context_menu_manager:
 | 
			
		||||
            card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
 | 
			
		||||
            card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
 | 
			
		||||
            card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
 | 
			
		||||
            card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu)
 | 
			
		||||
            card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop)
 | 
			
		||||
            card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop)
 | 
			
		||||
            card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
 | 
			
		||||
            card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
 | 
			
		||||
            card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
 | 
			
		||||
 | 
			
		||||
        return card
 | 
			
		||||
 | 
			
		||||
    def _flush_deletions(self):
 | 
			
		||||
        """Delete pending widgets off the main update cycle."""
 | 
			
		||||
        for card in list(self.pending_deletions):
 | 
			
		||||
            card.deleteLater()
 | 
			
		||||
            self.pending_deletions.remove(card)
 | 
			
		||||
 | 
			
		||||
    def clear_layout(self, layout):
 | 
			
		||||
        """Clears all widgets from the layout."""
 | 
			
		||||
        if layout is None:
 | 
			
		||||
            return
 | 
			
		||||
        while layout.count():
 | 
			
		||||
            child = layout.takeAt(0)
 | 
			
		||||
            if child.widget():
 | 
			
		||||
                widget = child.widget()
 | 
			
		||||
                for key, card in list(self.game_card_cache.items()):
 | 
			
		||||
                    if card == widget:
 | 
			
		||||
                        del self.game_card_cache[key]
 | 
			
		||||
                        if key in self.pending_images:
 | 
			
		||||
                            del self.pending_images[key]
 | 
			
		||||
                widget.deleteLater()
 | 
			
		||||
 | 
			
		||||
    def set_games(self, games: list[tuple]):
 | 
			
		||||
        """Sets the games list and updates the filtered games."""
 | 
			
		||||
        self.games = games
 | 
			
		||||
        self.filtered_games = self.games
 | 
			
		||||
        self.dirty = True  # Full resort needed
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
 | 
			
		||||
    def add_game_incremental(self, game_data: tuple):
 | 
			
		||||
        """Add a single game without full reload."""
 | 
			
		||||
        self.games.append(game_data)
 | 
			
		||||
        self.filtered_games.append(game_data)  # Assume no filter active; adjust if needed
 | 
			
		||||
        self.dirty = True
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
 | 
			
		||||
    def remove_game_incremental(self, game_name: str, exec_line: str):
 | 
			
		||||
        """Remove a single game without full reload."""
 | 
			
		||||
        key = (game_name, exec_line)
 | 
			
		||||
        self.games = [g for g in self.games if (g[0], g[4]) != key]
 | 
			
		||||
        self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key]
 | 
			
		||||
        if key in self.game_card_cache and self.gamesListLayout is not None:
 | 
			
		||||
            card = self.game_card_cache.pop(key)
 | 
			
		||||
            self.gamesListLayout.removeWidget(card)
 | 
			
		||||
            self.pending_deletions.append(card)  # Defer deleteLater
 | 
			
		||||
            if key in self.pending_images:
 | 
			
		||||
                del self.pending_images[key]
 | 
			
		||||
        self.dirty = True
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
 | 
			
		||||
    def filter_games_delayed(self):
 | 
			
		||||
        """Filters games based on search text and updates the grid."""
 | 
			
		||||
        self.update_game_grid(is_filter=True)
 | 
			
		||||
@@ -1,71 +1,37 @@
 | 
			
		||||
import orjson
 | 
			
		||||
import re
 | 
			
		||||
import os
 | 
			
		||||
from dataclasses import dataclass, field
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from typing import Any
 | 
			
		||||
from difflib import SequenceMatcher
 | 
			
		||||
 | 
			
		||||
from threading import Thread
 | 
			
		||||
import requests
 | 
			
		||||
from bs4 import BeautifulSoup, Tag
 | 
			
		||||
from portprotonqt.config_utils import read_proxy_config
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SearchModifiers(Enum):
 | 
			
		||||
    """Модификаторы поиска для фильтрации результатов."""
 | 
			
		||||
    NONE = ""
 | 
			
		||||
    ONLY_DLC = "only_dlc"
 | 
			
		||||
    ONLY_MODS = "only_mods"
 | 
			
		||||
    ONLY_HACKS = "only_hacks"
 | 
			
		||||
    HIDE_DLC = "hide_dlc"
 | 
			
		||||
 | 
			
		||||
from portprotonqt.time_utils import format_playtime
 | 
			
		||||
from PySide6.QtCore import QObject, Signal
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class GameEntry:
 | 
			
		||||
    """Информация об игре из HowLongToBeat."""
 | 
			
		||||
    # Основная информация
 | 
			
		||||
    game_id: int = -1
 | 
			
		||||
    game_name: str | None = None
 | 
			
		||||
    game_alias: str | None = None
 | 
			
		||||
    game_type: str | None = None
 | 
			
		||||
    game_image_url: str | None = None
 | 
			
		||||
    game_web_link: str | None = None
 | 
			
		||||
    review_score: float | None = None
 | 
			
		||||
    developer: str | None = None
 | 
			
		||||
    platforms: list[str] = field(default_factory=list)
 | 
			
		||||
    release_year: int | None = None
 | 
			
		||||
    similarity: float = -1.0
 | 
			
		||||
 | 
			
		||||
    # Времена прохождения (в часах)
 | 
			
		||||
    main_story: float | None = None
 | 
			
		||||
    main_extra: float | None = None
 | 
			
		||||
    completionist: float | None = None
 | 
			
		||||
    all_styles: float | None = None
 | 
			
		||||
    coop_time: float | None = None
 | 
			
		||||
    multiplayer_time: float | None = None
 | 
			
		||||
 | 
			
		||||
    # Флаги сложности
 | 
			
		||||
    has_single_player: bool = False
 | 
			
		||||
    has_coop: bool = False
 | 
			
		||||
    has_multiplayer: bool = False
 | 
			
		||||
    has_combined_complexity: bool = False
 | 
			
		||||
 | 
			
		||||
    # Исходные данные JSON
 | 
			
		||||
    similarity: float = -1.0
 | 
			
		||||
    raw_data: dict[str, Any] = field(default_factory=dict)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class SearchConfig:
 | 
			
		||||
    """Конфигурация для поиска."""
 | 
			
		||||
    api_key: str | None = None
 | 
			
		||||
    search_url: str | None = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class APIKeyExtractor:
 | 
			
		||||
    """Извлекает API ключ и URL поиска из скриптов сайта."""
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def extract_from_script(script_content: str) -> SearchConfig:
 | 
			
		||||
        """Извлекает конфигурацию из содержимого скрипта."""
 | 
			
		||||
        config = SearchConfig()
 | 
			
		||||
        config.api_key = APIKeyExtractor._extract_api_key(script_content)
 | 
			
		||||
        config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key)
 | 
			
		||||
@@ -73,53 +39,40 @@ class APIKeyExtractor:
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _extract_api_key(script_content: str) -> str | None:
 | 
			
		||||
        """Извлекает API ключ из скрипта."""
 | 
			
		||||
        # Паттерн для поиска user ID
 | 
			
		||||
        user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"'
 | 
			
		||||
        matches = re.findall(user_id_pattern, script_content)
 | 
			
		||||
        if matches:
 | 
			
		||||
            return ''.join(matches)
 | 
			
		||||
 | 
			
		||||
        # Паттерн для поиска конкатенированного API ключа
 | 
			
		||||
        concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+'
 | 
			
		||||
        matches = re.findall(concat_pattern, script_content)
 | 
			
		||||
        if matches:
 | 
			
		||||
            parts = str(matches).split('.concat')
 | 
			
		||||
            cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]]
 | 
			
		||||
            return ''.join(cleaned_parts)
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _extract_search_url(script_content: str, api_key: str | None) -> str | None:
 | 
			
		||||
        """Извлекает URL поиска из скрипта."""
 | 
			
		||||
        if not api_key:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        pattern = re.compile(
 | 
			
		||||
            r'fetch\(\s*["\'](\/api\/[^"\']*)["\']'
 | 
			
		||||
            r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)'
 | 
			
		||||
            r'\s*,',
 | 
			
		||||
            re.DOTALL
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        for match in pattern.finditer(script_content):
 | 
			
		||||
            endpoint = match.group(1)
 | 
			
		||||
            concat_calls = match.group(2)
 | 
			
		||||
            concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls)
 | 
			
		||||
            concatenated_str = ''.join(concat_strings)
 | 
			
		||||
 | 
			
		||||
            if concatenated_str == api_key:
 | 
			
		||||
                return endpoint
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HTTPClient:
 | 
			
		||||
    """HTTP клиент для работы с API HowLongToBeat."""
 | 
			
		||||
 | 
			
		||||
    BASE_URL = 'https://howlongtobeat.com/'
 | 
			
		||||
    GAME_URL = BASE_URL + "game"
 | 
			
		||||
    SEARCH_URL = BASE_URL + "api/s/"
 | 
			
		||||
 | 
			
		||||
    def __init__(self, timeout: int = 60):
 | 
			
		||||
@@ -129,35 +82,23 @@ class HTTPClient:
 | 
			
		||||
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
 | 
			
		||||
            'referer': self.BASE_URL
 | 
			
		||||
        })
 | 
			
		||||
        # Apply proxy settings from config
 | 
			
		||||
        proxy_config = read_proxy_config()
 | 
			
		||||
        if proxy_config:
 | 
			
		||||
            self.session.proxies.update(proxy_config)
 | 
			
		||||
 | 
			
		||||
    def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None:
 | 
			
		||||
        """Получает конфигурацию поиска с главной страницы."""
 | 
			
		||||
        try:
 | 
			
		||||
            response = self.session.get(self.BASE_URL, timeout=self.timeout)
 | 
			
		||||
            response.raise_for_status()
 | 
			
		||||
            soup = BeautifulSoup(response.text, 'html.parser')
 | 
			
		||||
            scripts = soup.find_all('script', src=True)
 | 
			
		||||
 | 
			
		||||
            # Filter for Tag objects and ensure src is a string
 | 
			
		||||
            if parse_all_scripts:
 | 
			
		||||
                script_urls = []
 | 
			
		||||
                for script in scripts:
 | 
			
		||||
                    if isinstance(script, Tag):
 | 
			
		||||
                        src = script.get('src')
 | 
			
		||||
                        if src is not None and isinstance(src, str):
 | 
			
		||||
            script_urls = []
 | 
			
		||||
            for script in scripts:
 | 
			
		||||
                if isinstance(script, Tag):
 | 
			
		||||
                    src = script.get('src')
 | 
			
		||||
                    if src is not None and isinstance(src, str):
 | 
			
		||||
                        if parse_all_scripts or '_app-' in src:
 | 
			
		||||
                            script_urls.append(src)
 | 
			
		||||
            else:
 | 
			
		||||
                script_urls = []
 | 
			
		||||
                for script in scripts:
 | 
			
		||||
                    if isinstance(script, Tag):
 | 
			
		||||
                        src = script.get('src')
 | 
			
		||||
                        if src is not None and isinstance(src, str) and '_app-' in src:
 | 
			
		||||
                            script_urls.append(src)
 | 
			
		||||
 | 
			
		||||
            for script_url in script_urls:
 | 
			
		||||
                full_url = self.BASE_URL + script_url
 | 
			
		||||
                script_response = self.session.get(full_url, timeout=self.timeout)
 | 
			
		||||
@@ -169,28 +110,21 @@ class HTTPClient:
 | 
			
		||||
            pass
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def search_games(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE,
 | 
			
		||||
                    page: int = 1, config: SearchConfig | None = None) -> str | None:
 | 
			
		||||
        """Выполняет поиск игр."""
 | 
			
		||||
    def search_games(self, game_name: str, page: int = 1, config: SearchConfig | None = None) -> str | None:
 | 
			
		||||
        if not config:
 | 
			
		||||
            config = self.get_search_config()
 | 
			
		||||
            if not config:
 | 
			
		||||
                config = self.get_search_config(parse_all_scripts=True)
 | 
			
		||||
 | 
			
		||||
        if not config or not config.api_key:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        search_url = self.SEARCH_URL
 | 
			
		||||
        if config.search_url:
 | 
			
		||||
            search_url = self.BASE_URL + config.search_url.lstrip('/')
 | 
			
		||||
 | 
			
		||||
        payload = self._build_search_payload(game_name, search_modifiers, page, config)
 | 
			
		||||
        payload = self._build_search_payload(game_name, page, config)
 | 
			
		||||
        headers = {
 | 
			
		||||
            'content-type': 'application/json',
 | 
			
		||||
            'accept': '*/*'
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # Попытка с API ключом в URL
 | 
			
		||||
        try:
 | 
			
		||||
            response = self.session.post(
 | 
			
		||||
                search_url + config.api_key,
 | 
			
		||||
@@ -202,8 +136,6 @@ class HTTPClient:
 | 
			
		||||
                return response.text
 | 
			
		||||
        except requests.RequestException:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # Попытка с API ключом в payload
 | 
			
		||||
        try:
 | 
			
		||||
            response = self.session.post(
 | 
			
		||||
                search_url,
 | 
			
		||||
@@ -215,37 +147,14 @@ class HTTPClient:
 | 
			
		||||
                return response.text
 | 
			
		||||
        except requests.RequestException:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def get_game_title(self, game_id: int) -> str | None:
 | 
			
		||||
        """Получает название игры по ID."""
 | 
			
		||||
        try:
 | 
			
		||||
            params = {'id': str(game_id)}
 | 
			
		||||
            response = self.session.get(self.GAME_URL, params=params, timeout=self.timeout)
 | 
			
		||||
            response.raise_for_status()
 | 
			
		||||
 | 
			
		||||
            soup = BeautifulSoup(response.text, 'html.parser')
 | 
			
		||||
            title_tag = soup.title
 | 
			
		||||
 | 
			
		||||
            if title_tag and title_tag.string:
 | 
			
		||||
                # Обрезаем стандартные части заголовка
 | 
			
		||||
                title = title_tag.string[12:-17].strip()
 | 
			
		||||
                return title
 | 
			
		||||
 | 
			
		||||
        except requests.RequestException:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def _build_search_payload(self, game_name: str, search_modifiers: SearchModifiers,
 | 
			
		||||
                             page: int, config: SearchConfig) -> dict[str, Any]:
 | 
			
		||||
        """Строит payload для поискового запроса."""
 | 
			
		||||
    def _build_search_payload(self, game_name: str, page: int, config: SearchConfig) -> dict[str, Any]:
 | 
			
		||||
        payload = {
 | 
			
		||||
            'searchType': "games",
 | 
			
		||||
            'searchTerms': game_name.split(),
 | 
			
		||||
            'searchPage': page,
 | 
			
		||||
            'size': 20,
 | 
			
		||||
            'size': 1,  # Limit to 1 result
 | 
			
		||||
            'searchOptions': {
 | 
			
		||||
                'games': {
 | 
			
		||||
                    'userId': 0,
 | 
			
		||||
@@ -260,7 +169,7 @@ class HTTPClient:
 | 
			
		||||
                        "difficulty": ""
 | 
			
		||||
                    },
 | 
			
		||||
                    'rangeYear': {'max': "", 'min': ""},
 | 
			
		||||
                    'modifier': search_modifiers.value,
 | 
			
		||||
                    'modifier': ""  # Hardcoded to empty string for SearchModifiers.NONE
 | 
			
		||||
                },
 | 
			
		||||
                'users': {'sortCategory': "postcount"},
 | 
			
		||||
                'lists': {'sortCategory': "follows"},
 | 
			
		||||
@@ -268,194 +177,197 @@ class HTTPClient:
 | 
			
		||||
                'sort': 0,
 | 
			
		||||
                'randomizer': 0
 | 
			
		||||
            },
 | 
			
		||||
            'useCache': True
 | 
			
		||||
            'useCache': True,
 | 
			
		||||
            'fields': ["game_id", "game_name", "comp_main", "comp_plus", "comp_100"]  # Request only needed fields
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if config.api_key:
 | 
			
		||||
            payload['searchOptions']['users']['id'] = config.api_key
 | 
			
		||||
 | 
			
		||||
        return payload
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ResultParser:
 | 
			
		||||
    """Парсер результатов поиска."""
 | 
			
		||||
 | 
			
		||||
    IMAGE_URL_PREFIX = "https://howlongtobeat.com/games/"
 | 
			
		||||
    GAME_URL_PREFIX = "https://howlongtobeat.com/game/"
 | 
			
		||||
 | 
			
		||||
    def __init__(self, search_query: str, minimum_similarity: float = 0.4,
 | 
			
		||||
                 case_sensitive: bool = True, auto_filter_times: bool = False):
 | 
			
		||||
    def __init__(self, search_query: str, minimum_similarity: float = 0.4, case_sensitive: bool = True):
 | 
			
		||||
        self.search_query = search_query
 | 
			
		||||
        self.minimum_similarity = minimum_similarity
 | 
			
		||||
        self.case_sensitive = case_sensitive
 | 
			
		||||
        self.auto_filter_times = auto_filter_times
 | 
			
		||||
        self.search_numbers = self._extract_numbers(search_query)
 | 
			
		||||
 | 
			
		||||
    def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]:
 | 
			
		||||
        """Парсит JSON ответ и возвращает список игр."""
 | 
			
		||||
        try:
 | 
			
		||||
            data = orjson.loads(json_response)
 | 
			
		||||
            games = []
 | 
			
		||||
 | 
			
		||||
            for game_data in data.get("data", []):
 | 
			
		||||
            # Only process the first result
 | 
			
		||||
            if data.get("data"):
 | 
			
		||||
                game_data = data["data"][0]
 | 
			
		||||
                game = self._parse_game_entry(game_data)
 | 
			
		||||
 | 
			
		||||
                if target_game_id is not None:
 | 
			
		||||
                    if game.game_id == target_game_id:
 | 
			
		||||
                        games.append(game)
 | 
			
		||||
                elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity:
 | 
			
		||||
                    games.append(game)
 | 
			
		||||
 | 
			
		||||
            return games
 | 
			
		||||
 | 
			
		||||
        except (orjson.JSONDecodeError, KeyError):
 | 
			
		||||
        except (orjson.JSONDecodeError, KeyError, IndexError):
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
    def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry:
 | 
			
		||||
        """Парсит данные одной игры."""
 | 
			
		||||
        game = GameEntry()
 | 
			
		||||
 | 
			
		||||
        # Основная информация
 | 
			
		||||
        game.game_id = game_data.get("game_id", -1)
 | 
			
		||||
        game.game_name = game_data.get("game_name")
 | 
			
		||||
        game.game_alias = game_data.get("game_alias")
 | 
			
		||||
        game.game_type = game_data.get("game_type")
 | 
			
		||||
        game.review_score = game_data.get("review_score")
 | 
			
		||||
        game.developer = game_data.get("profile_dev")
 | 
			
		||||
        game.release_year = game_data.get("release_world")
 | 
			
		||||
        game.raw_data = game_data
 | 
			
		||||
 | 
			
		||||
        # URL изображения
 | 
			
		||||
        if "game_image" in game_data:
 | 
			
		||||
            game.game_image_url = self.IMAGE_URL_PREFIX + game_data["game_image"]
 | 
			
		||||
 | 
			
		||||
        # Ссылка на игру
 | 
			
		||||
        game.game_web_link = f"{self.GAME_URL_PREFIX}{game.game_id}"
 | 
			
		||||
 | 
			
		||||
        # Платформы
 | 
			
		||||
        if "profile_platform" in game_data:
 | 
			
		||||
            game.platforms = game_data["profile_platform"].split(", ")
 | 
			
		||||
 | 
			
		||||
        # Времена прохождения (конвертация из секунд в часы)
 | 
			
		||||
        time_fields = [
 | 
			
		||||
            ("comp_main", "main_story"),
 | 
			
		||||
            ("comp_plus", "main_extra"),
 | 
			
		||||
            ("comp_100", "completionist"),
 | 
			
		||||
            ("comp_all", "all_styles"),
 | 
			
		||||
            ("invested_co", "coop_time"),
 | 
			
		||||
            ("invested_mp", "multiplayer_time")
 | 
			
		||||
            ("comp_100", "completionist")
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        all_zero = all(game_data.get(json_field, 0) == 0 for json_field, _ in time_fields)
 | 
			
		||||
        for json_field, attr_name in time_fields:
 | 
			
		||||
            if json_field in game_data:
 | 
			
		||||
                time_hours = round(game_data[json_field] / 3600, 2)
 | 
			
		||||
                time_seconds = game_data[json_field]
 | 
			
		||||
                time_hours = None if all_zero else round(time_seconds / 3600, 2)
 | 
			
		||||
                setattr(game, attr_name, time_hours)
 | 
			
		||||
 | 
			
		||||
        # Флаги сложности
 | 
			
		||||
        game.has_combined_complexity = bool(game_data.get("comp_lvl_combine", 0))
 | 
			
		||||
        game.has_single_player = bool(game_data.get("comp_lvl_sp", 0))
 | 
			
		||||
        game.has_coop = bool(game_data.get("comp_lvl_co", 0))
 | 
			
		||||
        game.has_multiplayer = bool(game_data.get("comp_lvl_mp", 0))
 | 
			
		||||
 | 
			
		||||
        # Автофильтрация времен
 | 
			
		||||
        if self.auto_filter_times:
 | 
			
		||||
            if not game.has_single_player:
 | 
			
		||||
                game.main_story = None
 | 
			
		||||
                game.main_extra = None
 | 
			
		||||
                game.completionist = None
 | 
			
		||||
                game.all_styles = None
 | 
			
		||||
            if not game.has_coop:
 | 
			
		||||
                game.coop_time = None
 | 
			
		||||
            if not game.has_multiplayer:
 | 
			
		||||
                game.multiplayer_time = None
 | 
			
		||||
 | 
			
		||||
        # Вычисление similarity
 | 
			
		||||
        game.similarity = self._calculate_similarity(game)
 | 
			
		||||
 | 
			
		||||
        return game
 | 
			
		||||
 | 
			
		||||
    def _calculate_similarity(self, game: GameEntry) -> float:
 | 
			
		||||
        """Вычисляет similarity между поисковым запросом и игрой."""
 | 
			
		||||
        name_similarity = self._compare_strings(self.search_query, game.game_name)
 | 
			
		||||
        alias_similarity = self._compare_strings(self.search_query, game.game_alias)
 | 
			
		||||
 | 
			
		||||
        return max(name_similarity, alias_similarity)
 | 
			
		||||
        return self._compare_strings(self.search_query, game.game_name)
 | 
			
		||||
 | 
			
		||||
    def _compare_strings(self, a: str | None, b: str | None) -> float:
 | 
			
		||||
        """Сравнивает две строки и возвращает коэффициент similarity."""
 | 
			
		||||
        if not a or not b:
 | 
			
		||||
            return 0.0
 | 
			
		||||
 | 
			
		||||
        if self.case_sensitive:
 | 
			
		||||
            similarity = SequenceMatcher(None, a, b).ratio()
 | 
			
		||||
        else:
 | 
			
		||||
            similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio()
 | 
			
		||||
 | 
			
		||||
        # Штраф за отсутствие чисел из оригинального запроса
 | 
			
		||||
        if self.search_numbers and not self._contains_numbers(b, self.search_numbers):
 | 
			
		||||
            similarity -= 0.1
 | 
			
		||||
 | 
			
		||||
        return max(0.0, similarity)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _extract_numbers(text: str) -> list[str]:
 | 
			
		||||
        """Извлекает числа из текста."""
 | 
			
		||||
        return [word for word in text.split() if word.isdigit()]
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _contains_numbers(text: str, numbers: list[str]) -> bool:
 | 
			
		||||
        """Проверяет, содержит ли текст указанные числа."""
 | 
			
		||||
        if not numbers:
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        cleaned_text = re.sub(r'([^\s\w]|_)+', '', text)
 | 
			
		||||
        text_numbers = [word for word in cleaned_text.split() if word.isdigit()]
 | 
			
		||||
 | 
			
		||||
        return any(num in text_numbers for num in numbers)
 | 
			
		||||
 | 
			
		||||
def get_cache_dir():
 | 
			
		||||
    """Возвращает путь к каталогу кэша, создаёт его при необходимости."""
 | 
			
		||||
    xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
 | 
			
		||||
    cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
 | 
			
		||||
    os.makedirs(cache_dir, exist_ok=True)
 | 
			
		||||
    return cache_dir
 | 
			
		||||
 | 
			
		||||
class HowLongToBeat:
 | 
			
		||||
class HowLongToBeat(QObject):
 | 
			
		||||
    """Основной класс для работы с API HowLongToBeat."""
 | 
			
		||||
    searchCompleted = Signal(list)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, minimum_similarity: float = 0.4, auto_filter_times: bool = False,
 | 
			
		||||
                 timeout: int = 60):
 | 
			
		||||
    def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.minimum_similarity = minimum_similarity
 | 
			
		||||
        self.auto_filter_times = auto_filter_times
 | 
			
		||||
        self.http_client = HTTPClient(timeout)
 | 
			
		||||
        self.cache_dir = get_cache_dir()
 | 
			
		||||
 | 
			
		||||
    def search(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE,
 | 
			
		||||
               case_sensitive: bool = True) -> list[GameEntry] | None:
 | 
			
		||||
        """Ищет игры по названию."""
 | 
			
		||||
    def _get_cache_file_path(self, game_name: str) -> str:
 | 
			
		||||
        """Возвращает путь к файлу кэша для заданного имени игры."""
 | 
			
		||||
        safe_game_name = re.sub(r'[^\w\s-]', '', game_name).replace(' ', '_').lower()
 | 
			
		||||
        cache_file = f"hltb_{safe_game_name}.json"
 | 
			
		||||
        return os.path.join(self.cache_dir, cache_file)
 | 
			
		||||
 | 
			
		||||
    def _load_from_cache(self, game_name: str) -> str | None:
 | 
			
		||||
        """Пытается загрузить данные из кэша, если они существуют."""
 | 
			
		||||
        cache_file = self._get_cache_file_path(game_name)
 | 
			
		||||
        try:
 | 
			
		||||
            if os.path.exists(cache_file):
 | 
			
		||||
                with open(cache_file, 'rb') as f:
 | 
			
		||||
                    return f.read().decode('utf-8')
 | 
			
		||||
        except (OSError, UnicodeDecodeError):
 | 
			
		||||
            pass
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def _save_to_cache(self, game_name: str, json_response: str):
 | 
			
		||||
        """Сохраняет данные в кэш, храня только первую игру и необходимые поля."""
 | 
			
		||||
        cache_file = self._get_cache_file_path(game_name)
 | 
			
		||||
        try:
 | 
			
		||||
            # Парсим JSON и берем только первую игру
 | 
			
		||||
            data = orjson.loads(json_response)
 | 
			
		||||
            if data.get("data"):
 | 
			
		||||
                first_game = data["data"][0]
 | 
			
		||||
                simplified_data = {
 | 
			
		||||
                    "data": [{
 | 
			
		||||
                        "game_id": first_game.get("game_id", -1),
 | 
			
		||||
                        "game_name": first_game.get("game_name"),
 | 
			
		||||
                        "comp_main": first_game.get("comp_main", 0),
 | 
			
		||||
                        "comp_plus": first_game.get("comp_plus", 0),
 | 
			
		||||
                        "comp_100": first_game.get("comp_100", 0)
 | 
			
		||||
                    }]
 | 
			
		||||
                }
 | 
			
		||||
                with open(cache_file, 'wb') as f:
 | 
			
		||||
                    f.write(orjson.dumps(simplified_data))
 | 
			
		||||
        except (OSError, orjson.JSONDecodeError, IndexError):
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    def search(self, game_name: str, case_sensitive: bool = True) -> list[GameEntry] | None:
 | 
			
		||||
        if not game_name or not game_name.strip():
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        json_response = self.http_client.search_games(game_name, search_modifiers)
 | 
			
		||||
        # Проверяем кэш
 | 
			
		||||
        cached_response = self._load_from_cache(game_name)
 | 
			
		||||
        if cached_response:
 | 
			
		||||
            try:
 | 
			
		||||
                cached_data = orjson.loads(cached_response)
 | 
			
		||||
                full_json = {
 | 
			
		||||
                    "data": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "game_id": game["game_id"],
 | 
			
		||||
                            "game_name": game["game_name"],
 | 
			
		||||
                            "comp_main": game["comp_main"],
 | 
			
		||||
                            "comp_plus": game["comp_plus"],
 | 
			
		||||
                            "comp_100": game["comp_100"]
 | 
			
		||||
                        }
 | 
			
		||||
                        for game in cached_data.get("data", [])
 | 
			
		||||
                    ]
 | 
			
		||||
                }
 | 
			
		||||
                parser = ResultParser(
 | 
			
		||||
                    game_name,
 | 
			
		||||
                    self.minimum_similarity,
 | 
			
		||||
                    case_sensitive
 | 
			
		||||
                )
 | 
			
		||||
                return parser.parse_results(orjson.dumps(full_json).decode('utf-8'))
 | 
			
		||||
            except orjson.JSONDecodeError:
 | 
			
		||||
                pass
 | 
			
		||||
        # Если нет в кэше, делаем запрос
 | 
			
		||||
        json_response = self.http_client.search_games(game_name)
 | 
			
		||||
        if not json_response:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        # Сохраняем в кэш только первую игру
 | 
			
		||||
        self._save_to_cache(game_name, json_response)
 | 
			
		||||
        parser = ResultParser(
 | 
			
		||||
            game_name,
 | 
			
		||||
            self.minimum_similarity,
 | 
			
		||||
            case_sensitive,
 | 
			
		||||
            self.auto_filter_times
 | 
			
		||||
            case_sensitive
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return parser.parse_results(json_response)
 | 
			
		||||
 | 
			
		||||
    def search_by_id(self, game_id: int) -> GameEntry | None:
 | 
			
		||||
        """Ищет игру по ID."""
 | 
			
		||||
        if not game_id or game_id <= 0:
 | 
			
		||||
    def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None:
 | 
			
		||||
        time_value = getattr(game_entry, time_field, None)
 | 
			
		||||
        if time_value is None:
 | 
			
		||||
            return None
 | 
			
		||||
        time_seconds = int(time_value * 3600)
 | 
			
		||||
        return format_playtime(time_seconds)
 | 
			
		||||
 | 
			
		||||
        game_title = self.http_client.get_game_title(game_id)
 | 
			
		||||
        if not game_title:
 | 
			
		||||
            return None
 | 
			
		||||
    def search_with_callback(self, game_name: str, case_sensitive: bool = True):
 | 
			
		||||
        """Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами."""
 | 
			
		||||
        def search_thread():
 | 
			
		||||
            try:
 | 
			
		||||
                results = self.search(game_name, case_sensitive)
 | 
			
		||||
                self.searchCompleted.emit(results if results else [])
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print(f"Error in search_with_callback: {e}")
 | 
			
		||||
                self.searchCompleted.emit([])
 | 
			
		||||
 | 
			
		||||
        json_response = self.http_client.search_games(game_title)
 | 
			
		||||
        if not json_response:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        parser = ResultParser(game_title, 0.0, False, self.auto_filter_times)
 | 
			
		||||
        results = parser.parse_results(json_response, target_game_id=game_id)
 | 
			
		||||
 | 
			
		||||
        return results[0] if results else None
 | 
			
		||||
        thread = Thread(target=search_thread)
 | 
			
		||||
        thread.daemon = True
 | 
			
		||||
        thread.start()
 | 
			
		||||
@@ -3,7 +3,6 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
 | 
			
		||||
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
 | 
			
		||||
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
 | 
			
		||||
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
@@ -21,6 +20,13 @@ image_load_queue = Queue()
 | 
			
		||||
image_executor = ThreadPoolExecutor(max_workers=4)
 | 
			
		||||
queue_lock = threading.Lock()
 | 
			
		||||
 | 
			
		||||
def get_device_pixel_ratio() -> float:
 | 
			
		||||
    """
 | 
			
		||||
    Retrieves the device pixel ratio from QApplication, with a fallback of 1.0 if not available.
 | 
			
		||||
    """
 | 
			
		||||
    app = QApplication.instance()
 | 
			
		||||
    return app.devicePixelRatio() if isinstance(app, QApplication) else 1.0
 | 
			
		||||
 | 
			
		||||
def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""):
 | 
			
		||||
    """
 | 
			
		||||
    Асинхронно загружает обложку через очередь задач.
 | 
			
		||||
@@ -77,6 +83,43 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Ошибка обработки URL {cover}: {e}")
 | 
			
		||||
 | 
			
		||||
        # SteamGridDB (SGDB)
 | 
			
		||||
        if cover and cover.startswith("https://cdn2.steamgriddb.com"):
 | 
			
		||||
            try:
 | 
			
		||||
                parts = cover.split("/")
 | 
			
		||||
                filename = parts[-1] if parts else "sgdb_cover.png"
 | 
			
		||||
                # SGDB ссылки содержат уникальный хеш в названии — используем как имя
 | 
			
		||||
                local_path = os.path.join(image_folder, filename)
 | 
			
		||||
 | 
			
		||||
                if os.path.exists(local_path):
 | 
			
		||||
                    pixmap = QPixmap(local_path)
 | 
			
		||||
                    finish_with(pixmap)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                def on_downloaded(result: str | None):
 | 
			
		||||
                    pixmap = QPixmap()
 | 
			
		||||
                    if result and os.path.exists(result):
 | 
			
		||||
                        pixmap.load(result)
 | 
			
		||||
                    if pixmap.isNull():
 | 
			
		||||
                        placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
 | 
			
		||||
                        if placeholder_path and QFile.exists(placeholder_path):
 | 
			
		||||
                            pixmap.load(placeholder_path)
 | 
			
		||||
                        else:
 | 
			
		||||
                            pixmap = QPixmap(width, height)
 | 
			
		||||
                            pixmap.fill(QColor("#333333"))
 | 
			
		||||
                            painter = QPainter(pixmap)
 | 
			
		||||
                            painter.setPen(QPen(QColor("white")))
 | 
			
		||||
                            painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
 | 
			
		||||
                            painter.end()
 | 
			
		||||
                    finish_with(pixmap)
 | 
			
		||||
 | 
			
		||||
                logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
 | 
			
		||||
                downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Ошибка обработки SGDB URL {cover}: {e}")
 | 
			
		||||
 | 
			
		||||
        if cover and cover.startswith(("http://", "https://")):
 | 
			
		||||
            try:
 | 
			
		||||
                local_path = os.path.join(image_folder, f"{app_name}.jpg")
 | 
			
		||||
@@ -164,23 +207,21 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
        :param theme: Объект темы для стилизации (если None, используется default_styles)
 | 
			
		||||
        """
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        # Удаление диалога после закрытия
 | 
			
		||||
        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
 | 
			
		||||
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.setFocus()
 | 
			
		||||
 | 
			
		||||
        self.images = images
 | 
			
		||||
        self.current_index = current_index
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
 | 
			
		||||
        # Убираем стандартные элементы управления окна
 | 
			
		||||
        self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
 | 
			
		||||
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
 | 
			
		||||
 | 
			
		||||
        self.init_ui()
 | 
			
		||||
        self.update_display()
 | 
			
		||||
 | 
			
		||||
        # Фильтруем события для закрытия диалога по клику
 | 
			
		||||
        self.imageLabel.installEventFilter(self)
 | 
			
		||||
        self.captionLabel.installEventFilter(self)
 | 
			
		||||
 | 
			
		||||
@@ -190,32 +231,28 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
        self.mainLayout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        self.mainLayout.setSpacing(0)
 | 
			
		||||
 | 
			
		||||
        # Контейнер для изображения и стрелок
 | 
			
		||||
        self.imageContainer = QWidget()
 | 
			
		||||
        self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
 | 
			
		||||
        self.imageContainerLayout = QHBoxLayout(self.imageContainer)
 | 
			
		||||
        self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        self.imageContainerLayout.setSpacing(0)
 | 
			
		||||
 | 
			
		||||
        # Левая стрелка
 | 
			
		||||
        self.prevButton = QToolButton()
 | 
			
		||||
        self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
 | 
			
		||||
        self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE)
 | 
			
		||||
        self.prevButton.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
 | 
			
		||||
        self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.prevButton.setFixedSize(40, 40)
 | 
			
		||||
        self.prevButton.clicked.connect(self.show_prev)
 | 
			
		||||
        self.imageContainerLayout.addWidget(self.prevButton)
 | 
			
		||||
 | 
			
		||||
        # Метка для изображения
 | 
			
		||||
        self.imageLabel = QLabel()
 | 
			
		||||
        self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
 | 
			
		||||
        self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
 | 
			
		||||
 | 
			
		||||
        # Правая стрелка
 | 
			
		||||
        self.nextButton = QToolButton()
 | 
			
		||||
        self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
 | 
			
		||||
        self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE)
 | 
			
		||||
        self.nextButton.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
 | 
			
		||||
        self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.nextButton.setFixedSize(40, 40)
 | 
			
		||||
        self.nextButton.clicked.connect(self.show_next)
 | 
			
		||||
@@ -223,16 +260,14 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
 | 
			
		||||
        self.mainLayout.addWidget(self.imageContainer)
 | 
			
		||||
 | 
			
		||||
        # Небольшой отступ между изображением и подписью
 | 
			
		||||
        spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
 | 
			
		||||
        self.mainLayout.addItem(spacer)
 | 
			
		||||
 | 
			
		||||
        # Подпись
 | 
			
		||||
        self.captionLabel = QLabel()
 | 
			
		||||
        self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        self.captionLabel.setFixedHeight(40)
 | 
			
		||||
        self.captionLabel.setWordWrap(True)
 | 
			
		||||
        self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE)
 | 
			
		||||
        self.captionLabel.setStyleSheet(getattr(self.theme, "CAPTION_LABEL_STYLE", ""))
 | 
			
		||||
        self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.mainLayout.addWidget(self.captionLabel)
 | 
			
		||||
 | 
			
		||||
@@ -241,28 +276,37 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
        if not self.images:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Очищаем старое содержимое
 | 
			
		||||
        self.imageLabel.clear()
 | 
			
		||||
        self.captionLabel.clear()
 | 
			
		||||
        QApplication.processEvents()
 | 
			
		||||
 | 
			
		||||
        pixmap, caption = self.images[self.current_index]
 | 
			
		||||
        # Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера
 | 
			
		||||
        # Учитываем devicePixelRatio для масштабирования высокого качества
 | 
			
		||||
        device_pixel_ratio = get_device_pixel_ratio()
 | 
			
		||||
        target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
 | 
			
		||||
        target_height = int(self.FIXED_HEIGHT * device_pixel_ratio)
 | 
			
		||||
 | 
			
		||||
        # Масштабируем изображение из оригинального pixmap
 | 
			
		||||
        scaled_pixmap = pixmap.scaled(
 | 
			
		||||
            self.FIXED_WIDTH - 80,  # учитываем ширину стрелок
 | 
			
		||||
            self.FIXED_HEIGHT,
 | 
			
		||||
            target_width,
 | 
			
		||||
            target_height,
 | 
			
		||||
            Qt.AspectRatioMode.KeepAspectRatio,
 | 
			
		||||
            Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
        )
 | 
			
		||||
        scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
 | 
			
		||||
        self.imageLabel.setPixmap(scaled_pixmap)
 | 
			
		||||
        self.captionLabel.setText(caption)
 | 
			
		||||
        self.setWindowTitle(caption)
 | 
			
		||||
 | 
			
		||||
        # Принудительная перерисовка виджетов
 | 
			
		||||
        self.imageLabel.repaint()
 | 
			
		||||
        self.captionLabel.repaint()
 | 
			
		||||
        self.repaint()
 | 
			
		||||
 | 
			
		||||
    def resizeEvent(self, event):
 | 
			
		||||
        """Обновляет изображение при изменении размера окна."""
 | 
			
		||||
        super().resizeEvent(event)
 | 
			
		||||
        self.update_display()  # Перерисовываем изображение с учетом нового размера
 | 
			
		||||
 | 
			
		||||
    def show_prev(self):
 | 
			
		||||
        """Показывает предыдущее изображение."""
 | 
			
		||||
        if self.images:
 | 
			
		||||
@@ -292,7 +336,6 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
        """Закрывает диалог при клике на пустую область."""
 | 
			
		||||
        pos = event.pos()
 | 
			
		||||
        # Проверяем, находится ли клик вне imageContainer и captionLabel
 | 
			
		||||
        if not (self.imageContainer.geometry().contains(pos) or
 | 
			
		||||
                self.captionLabel.geometry().contains(pos)):
 | 
			
		||||
            self.close()
 | 
			
		||||
@@ -305,15 +348,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
 | 
			
		||||
        """
 | 
			
		||||
        :param pixmap: QPixmap для отображения в карусели
 | 
			
		||||
        :param pixmap: QPixmap для отображения в карусели (оригинальное, высокое разрешение)
 | 
			
		||||
        :param caption: Подпись к изображению
 | 
			
		||||
        :param images_list: Список всех изображений (кортежей (QPixmap, caption)),
 | 
			
		||||
                            чтобы в диалоге можно было перелистывать.
 | 
			
		||||
                            Если не передан, будет использован только текущее изображение.
 | 
			
		||||
        :param index: Индекс текущего изображения в images_list.
 | 
			
		||||
        :param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками.
 | 
			
		||||
        :param images_list: Список всех изображений (кортежей (QPixmap, caption))
 | 
			
		||||
        :param index: Индекс текущего изображения в images_list
 | 
			
		||||
        :param carousel: Ссылка на родительскую карусель (ImageCarousel)
 | 
			
		||||
        """
 | 
			
		||||
        super().__init__(pixmap)
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.original_pixmap = pixmap  # Store original high-resolution pixmap
 | 
			
		||||
        self.caption = caption
 | 
			
		||||
        self.images_list = images_list if images_list is not None else [(pixmap, caption)]
 | 
			
		||||
        self.index = index
 | 
			
		||||
@@ -323,6 +365,20 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
 | 
			
		||||
        self._click_start_position = None
 | 
			
		||||
        self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
 | 
			
		||||
        self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
 | 
			
		||||
        self.update_pixmap()  # Set initial pixmap
 | 
			
		||||
 | 
			
		||||
    def update_pixmap(self, height=300):
 | 
			
		||||
        """Update the displayed pixmap by scaling from the original high-resolution pixmap."""
 | 
			
		||||
        if self.original_pixmap.isNull():
 | 
			
		||||
            return
 | 
			
		||||
        # Scale pixmap to desired height, considering device pixel ratio
 | 
			
		||||
        device_pixel_ratio = get_device_pixel_ratio()
 | 
			
		||||
        scaled_pixmap = self.original_pixmap.scaledToHeight(
 | 
			
		||||
            int(height * device_pixel_ratio),
 | 
			
		||||
            Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
        )
 | 
			
		||||
        scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
 | 
			
		||||
        self.setPixmap(scaled_pixmap)
 | 
			
		||||
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
        if event.button() == Qt.MouseButton.LeftButton:
 | 
			
		||||
@@ -339,17 +395,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
 | 
			
		||||
        event.accept()
 | 
			
		||||
 | 
			
		||||
    def show_fullscreen(self):
 | 
			
		||||
        # Скрываем стрелки карусели перед открытием FullscreenDialog
 | 
			
		||||
        if self.carousel:
 | 
			
		||||
            self.carousel.prevArrow.hide()
 | 
			
		||||
            self.carousel.nextArrow.hide()
 | 
			
		||||
        dialog = FullscreenDialog(self.images_list, current_index=self.index)
 | 
			
		||||
        dialog.exec()
 | 
			
		||||
        # После закрытия диалога обновляем видимость стрелок
 | 
			
		||||
        if self.carousel:
 | 
			
		||||
            self.carousel.update_arrows_visibility()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ImageCarousel(QGraphicsView):
 | 
			
		||||
    """
 | 
			
		||||
    Карусель изображений с адаптивностью, возможностью увеличения по клику
 | 
			
		||||
@@ -357,19 +410,17 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
 | 
			
		||||
        # Аннотируем тип scene как QGraphicsScene
 | 
			
		||||
        self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
 | 
			
		||||
        self.setScene(self.carousel_scene)
 | 
			
		||||
 | 
			
		||||
        self.images = images  # Список кортежей: (QPixmap, caption)
 | 
			
		||||
        self.image_items = []
 | 
			
		||||
        self._animation = None
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.max_height = 300  # Default height for images
 | 
			
		||||
        self.init_ui()
 | 
			
		||||
        self.create_arrows()
 | 
			
		||||
 | 
			
		||||
        # Переменные для поддержки перетаскивания
 | 
			
		||||
        self._drag_active = False
 | 
			
		||||
        self._drag_start_position = None
 | 
			
		||||
        self._scroll_start_value = None
 | 
			
		||||
@@ -380,30 +431,38 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
 | 
			
		||||
        self.setFrameShape(QFrame.Shape.NoFrame)
 | 
			
		||||
 | 
			
		||||
        x_offset = 10  # Отступ между изображениями
 | 
			
		||||
        max_height = 300  # Фиксированная высота изображений
 | 
			
		||||
        self.update_scene()
 | 
			
		||||
 | 
			
		||||
    def update_scene(self):
 | 
			
		||||
        """Update the scene with scaled images based on current size and scale."""
 | 
			
		||||
        self.carousel_scene.clear()
 | 
			
		||||
        self.image_items.clear()
 | 
			
		||||
 | 
			
		||||
        x_offset = 10
 | 
			
		||||
        x = 0
 | 
			
		||||
        device_pixel_ratio = get_device_pixel_ratio()
 | 
			
		||||
 | 
			
		||||
        for i, (pixmap, caption) in enumerate(self.images):
 | 
			
		||||
            item = ClickablePixmapItem(
 | 
			
		||||
                pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation),
 | 
			
		||||
                pixmap,  # Pass original pixmap
 | 
			
		||||
                caption,
 | 
			
		||||
                images_list=self.images,
 | 
			
		||||
                index=i,
 | 
			
		||||
                carousel=self  # Передаем ссылку на карусель
 | 
			
		||||
                carousel=self
 | 
			
		||||
            )
 | 
			
		||||
            item.update_pixmap(self.max_height)  # Scale to current height
 | 
			
		||||
            item.setPos(x, 0)
 | 
			
		||||
            self.carousel_scene.addItem(item)
 | 
			
		||||
            self.image_items.append(item)
 | 
			
		||||
            x += item.pixmap().width() + x_offset
 | 
			
		||||
            x += item.pixmap().width() / device_pixel_ratio + x_offset
 | 
			
		||||
 | 
			
		||||
        self.setSceneRect(0, 0, x, max_height)
 | 
			
		||||
        self.setSceneRect(0, 0, x, self.max_height)
 | 
			
		||||
 | 
			
		||||
    def create_arrows(self):
 | 
			
		||||
        """Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
 | 
			
		||||
        self.prevArrow = QToolButton(self)
 | 
			
		||||
        self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
 | 
			
		||||
        self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore
 | 
			
		||||
        self.prevArrow.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
 | 
			
		||||
        self.prevArrow.setFixedSize(40, 40)
 | 
			
		||||
        self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.prevArrow.setAutoRepeat(True)
 | 
			
		||||
@@ -414,7 +473,7 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
 | 
			
		||||
        self.nextArrow = QToolButton(self)
 | 
			
		||||
        self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
 | 
			
		||||
        self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore
 | 
			
		||||
        self.nextArrow.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
 | 
			
		||||
        self.nextArrow.setFixedSize(40, 40)
 | 
			
		||||
        self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.nextArrow.setAutoRepeat(True)
 | 
			
		||||
@@ -423,14 +482,9 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        self.nextArrow.clicked.connect(self.scroll_right)
 | 
			
		||||
        self.nextArrow.raise_()
 | 
			
		||||
 | 
			
		||||
        # Проверяем видимость стрелок при создании
 | 
			
		||||
        self.update_arrows_visibility()
 | 
			
		||||
 | 
			
		||||
    def update_arrows_visibility(self):
 | 
			
		||||
        """
 | 
			
		||||
        Показывает стрелки, если контент шире видимой области.
 | 
			
		||||
        Иначе скрывает их.
 | 
			
		||||
        """
 | 
			
		||||
        if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
 | 
			
		||||
            if self.horizontalScrollBar().maximum() == 0:
 | 
			
		||||
                self.prevArrow.hide()
 | 
			
		||||
@@ -444,7 +498,8 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        margin = 10
 | 
			
		||||
        self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
 | 
			
		||||
        self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
 | 
			
		||||
                              (self.height() - self.nextArrow.height()) // 2)
 | 
			
		||||
                            (self.height() - self.nextArrow.height()) // 2)
 | 
			
		||||
        self.update_scene()  # Re-scale images on resize
 | 
			
		||||
        self.update_arrows_visibility()
 | 
			
		||||
 | 
			
		||||
    def animate_scroll(self, end_value):
 | 
			
		||||
@@ -469,19 +524,15 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        self.animate_scroll(new_value)
 | 
			
		||||
 | 
			
		||||
    def update_images(self, new_images):
 | 
			
		||||
        self.carousel_scene.clear()
 | 
			
		||||
        self.images = new_images
 | 
			
		||||
        self.image_items.clear()
 | 
			
		||||
        self.init_ui()
 | 
			
		||||
        self.update_scene()
 | 
			
		||||
        self.update_arrows_visibility()
 | 
			
		||||
 | 
			
		||||
    # Обработка событий мыши для перетаскивания
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
        if event.button() == Qt.MouseButton.LeftButton:
 | 
			
		||||
            self._drag_active = True
 | 
			
		||||
            self._drag_start_position = event.pos()
 | 
			
		||||
            self._scroll_start_value = self.horizontalScrollBar().value()
 | 
			
		||||
            # Скрываем стрелки при начале перетаскивания
 | 
			
		||||
            if hasattr(self, "prevArrow"):
 | 
			
		||||
                self.prevArrow.hide()
 | 
			
		||||
            if hasattr(self, "nextArrow"):
 | 
			
		||||
@@ -497,6 +548,5 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
 | 
			
		||||
    def mouseReleaseEvent(self, event):
 | 
			
		||||
        self._drag_active = False
 | 
			
		||||
        # Показываем стрелки после завершения перетаскивания (с проверкой видимости)
 | 
			
		||||
        self.update_arrows_visibility()
 | 
			
		||||
        super().mouseReleaseEvent(event)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										73
									
								
								portprotonqt/keyboard_layouts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,73 @@
 | 
			
		||||
# keyboard_layouts.py
 | 
			
		||||
keyboard_layouts = {
 | 
			
		||||
    'en': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
 | 
			
		||||
            ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
 | 
			
		||||
            ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"],
 | 
			
		||||
            ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'],
 | 
			
		||||
            ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'],
 | 
			
		||||
            ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'],
 | 
			
		||||
            ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?']
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    'ru': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
 | 
			
		||||
            ['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'],
 | 
			
		||||
            ['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'],
 | 
			
		||||
            ['⬆', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['Ё', '!', '"', '№', ';', '%', ':', '?', '*', '(', ')', '_', '+'],
 | 
			
		||||
            ['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'],
 | 
			
		||||
            ['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'],
 | 
			
		||||
            ['⬆', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ',']
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    'fr': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='],
 | 
			
		||||
            ['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'],
 | 
			
		||||
            ['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'],
 | 
			
		||||
            ['⬆', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'],
 | 
			
		||||
            ['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'],
 | 
			
		||||
            ['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'],
 | 
			
		||||
            ['⬆', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§']
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    'es': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'],
 | 
			
		||||
            ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'],
 | 
			
		||||
            ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'],
 | 
			
		||||
            ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'],
 | 
			
		||||
            ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'],
 | 
			
		||||
            ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'],
 | 
			
		||||
            ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    'de': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'],
 | 
			
		||||
            ['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'],
 | 
			
		||||
            ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'],
 | 
			
		||||
            ['⬆', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'],
 | 
			
		||||
            ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''],
 | 
			
		||||
            ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'],
 | 
			
		||||
            ['⬆', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: de_DE\n"
 | 
			
		||||
@@ -23,13 +23,7 @@ msgstr ""
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgid "PortProton directory not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
@@ -38,6 +32,15 @@ msgstr ""
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -65,9 +68,6 @@ msgstr ""
 | 
			
		||||
msgid "Edit Shortcut"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Stopped '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
@@ -155,7 +155,7 @@ msgid "Menu"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No executable command in .desktop file for '{game_name}'"
 | 
			
		||||
msgid "No executable command found in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -163,25 +163,13 @@ msgid "Failed to parse .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to read .desktop file: {error}"
 | 
			
		||||
msgid "Error reading .desktop file: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Invalid executable command: {exec_line}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found: {path}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse executable: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Deletion"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -203,6 +191,10 @@ msgstr ""
 | 
			
		||||
msgid "Failed to delete custom data: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{game_name}' successfully"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Game name and executable path are required"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -260,15 +252,50 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -305,6 +332,39 @@ msgstr ""
 | 
			
		||||
msgid "No cover selected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Set"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Libraries"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Information"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fonts"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Warning"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No components selected."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed. Check logs."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Components installed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Epic Games Store games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -353,9 +413,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -365,6 +422,34 @@ msgstr ""
 | 
			
		||||
msgid "Themes"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -377,13 +462,106 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgid "Prefix:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Configuration"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Uninstaller"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Create Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Load Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Compatibility Tool"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start restore process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete prefix: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Compatibility tool '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete compatibility tool: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -419,6 +597,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -443,6 +624,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -455,21 +642,6 @@ msgstr ""
 | 
			
		||||
msgid "Gamepad haptic feedback:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open Legendary Login"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary Authentication:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Enter Legendary Authorization Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Authorization Code:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Submit Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Save Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -479,28 +651,6 @@ msgstr ""
 | 
			
		||||
msgid "Clear Cache"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Opened Legendary login page in browser"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to open Legendary login page"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Please enter an authorization code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Successfully authenticated with Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Legendary authentication failed: {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary executable not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Unexpected error during authentication"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Reset"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -554,15 +704,21 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "PLAY TIME"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "MAIN STORY"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "MAIN + SIDES"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "COMPLETIONIST"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "full"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -651,3 +807,24 @@ msgstr ""
 | 
			
		||||
msgid "sec."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Show"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Recent Games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Hide"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: es_ES\n"
 | 
			
		||||
@@ -23,13 +23,7 @@ msgstr ""
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgid "PortProton directory not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
@@ -38,6 +32,15 @@ msgstr ""
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -65,9 +68,6 @@ msgstr ""
 | 
			
		||||
msgid "Edit Shortcut"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Stopped '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
@@ -155,7 +155,7 @@ msgid "Menu"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No executable command in .desktop file for '{game_name}'"
 | 
			
		||||
msgid "No executable command found in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -163,25 +163,13 @@ msgid "Failed to parse .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to read .desktop file: {error}"
 | 
			
		||||
msgid "Error reading .desktop file: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Invalid executable command: {exec_line}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found: {path}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse executable: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Deletion"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -203,6 +191,10 @@ msgstr ""
 | 
			
		||||
msgid "Failed to delete custom data: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{game_name}' successfully"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Game name and executable path are required"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -260,15 +252,50 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -305,6 +332,39 @@ msgstr ""
 | 
			
		||||
msgid "No cover selected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Set"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Libraries"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Information"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fonts"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Warning"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No components selected."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed. Check logs."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Components installed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Epic Games Store games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -353,9 +413,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -365,6 +422,34 @@ msgstr ""
 | 
			
		||||
msgid "Themes"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -377,13 +462,106 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgid "Prefix:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Configuration"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Uninstaller"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Create Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Load Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Compatibility Tool"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start restore process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete prefix: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Compatibility tool '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete compatibility tool: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -419,6 +597,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -443,6 +624,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -455,21 +642,6 @@ msgstr ""
 | 
			
		||||
msgid "Gamepad haptic feedback:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open Legendary Login"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary Authentication:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Enter Legendary Authorization Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Authorization Code:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Submit Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Save Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -479,28 +651,6 @@ msgstr ""
 | 
			
		||||
msgid "Clear Cache"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Opened Legendary login page in browser"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to open Legendary login page"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Please enter an authorization code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Successfully authenticated with Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Legendary authentication failed: {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary executable not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Unexpected error during authentication"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Reset"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -554,15 +704,21 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "PLAY TIME"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "MAIN STORY"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "MAIN + SIDES"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "COMPLETIONIST"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "full"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -651,3 +807,24 @@ msgstr ""
 | 
			
		||||
msgid "sec."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Show"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Recent Games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Hide"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language-Team: LANGUAGE <LL@li.org>\n"
 | 
			
		||||
@@ -21,13 +21,7 @@ msgstr ""
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgid "PortProton directory not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
@@ -36,6 +30,15 @@ msgstr ""
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -63,9 +66,6 @@ msgstr ""
 | 
			
		||||
msgid "Edit Shortcut"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Stopped '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
@@ -153,7 +153,7 @@ msgid "Menu"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No executable command in .desktop file for '{game_name}'"
 | 
			
		||||
msgid "No executable command found in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -161,25 +161,13 @@ msgid "Failed to parse .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to read .desktop file: {error}"
 | 
			
		||||
msgid "Error reading .desktop file: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Invalid executable command: {exec_line}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found: {path}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse executable: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Deletion"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -201,6 +189,10 @@ msgstr ""
 | 
			
		||||
msgid "Failed to delete custom data: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{game_name}' successfully"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Game name and executable path are required"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -258,15 +250,50 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -303,6 +330,39 @@ msgstr ""
 | 
			
		||||
msgid "No cover selected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Set"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Libraries"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Information"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fonts"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Warning"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No components selected."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed. Check logs."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Components installed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Epic Games Store games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -351,9 +411,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -363,6 +420,34 @@ msgstr ""
 | 
			
		||||
msgid "Themes"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -375,13 +460,106 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgid "Prefix:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Configuration"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Uninstaller"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Create Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Load Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Compatibility Tool"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start restore process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete prefix: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Compatibility tool '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete compatibility tool: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -417,6 +595,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -441,6 +622,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -453,21 +640,6 @@ msgstr ""
 | 
			
		||||
msgid "Gamepad haptic feedback:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open Legendary Login"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary Authentication:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Enter Legendary Authorization Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Authorization Code:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Submit Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Save Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -477,28 +649,6 @@ msgstr ""
 | 
			
		||||
msgid "Clear Cache"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Opened Legendary login page in browser"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to open Legendary login page"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Please enter an authorization code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Successfully authenticated with Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Legendary authentication failed: {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary executable not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Unexpected error during authentication"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Reset"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -552,15 +702,21 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "PLAY TIME"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "MAIN STORY"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "MAIN + SIDES"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "COMPLETIONIST"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "full"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -649,3 +805,24 @@ msgstr ""
 | 
			
		||||
msgid "sec."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Show"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Recent Games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Hide"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-07-06 17:56+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"Last-Translator: \n"
 | 
			
		||||
"Language: ru_RU\n"
 | 
			
		||||
"Language-Team: ru_RU <LL@li.org>\n"
 | 
			
		||||
@@ -24,14 +24,8 @@ msgstr ""
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr "Ошибка"
 | 
			
		||||
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr "PortProton не найден"
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr "Остановить игру"
 | 
			
		||||
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr "Запустить игру"
 | 
			
		||||
msgid "PortProton directory not found"
 | 
			
		||||
msgstr "Не найден каталог PortProton"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr "Удалить из Избранного"
 | 
			
		||||
@@ -39,6 +33,15 @@ msgstr "Удалить из Избранного"
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr "Добавить в Избранное"
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr "Удалить из PortProton"
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr "Остановить игру"
 | 
			
		||||
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr "Запустить игру"
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr "Импортировать игру"
 | 
			
		||||
 | 
			
		||||
@@ -66,9 +69,6 @@ msgstr "Добавить в меню"
 | 
			
		||||
msgid "Edit Shortcut"
 | 
			
		||||
msgstr "Редактировать"
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr "Удалить из PortProton"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Stopped '{game_name}'"
 | 
			
		||||
msgstr "Остановлен(а) '{game_name}'"
 | 
			
		||||
@@ -158,33 +158,21 @@ msgid "Menu"
 | 
			
		||||
msgstr "Меню"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No executable command in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда"
 | 
			
		||||
msgid "No executable command found in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse .desktop file for '{game_name}'"
 | 
			
		||||
msgstr "Не удалось разобрать файл .desktop для '{game_name}'"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to read .desktop file: {error}"
 | 
			
		||||
msgstr "Не удалось прочитать файл .desktop: {error}"
 | 
			
		||||
msgid "Error reading .desktop file: {error}"
 | 
			
		||||
msgstr "Ошибка при чтении файла .desktop: {error}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
msgstr "Файл .desktop для '{game_name}' не найден"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Invalid executable command: {exec_line}"
 | 
			
		||||
msgstr "Недопустимая исполняемая команда: {exec_line}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found: {path}"
 | 
			
		||||
msgstr "Исполняемый файл не найден: {path}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse executable: {error}"
 | 
			
		||||
msgstr "Не удалось разобрать исполняемый файл: {error}"
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Deletion"
 | 
			
		||||
msgstr "Подтвердите удаление"
 | 
			
		||||
 | 
			
		||||
@@ -208,6 +196,10 @@ msgstr "'{game_name}' был(а) успешно удалён(а)"
 | 
			
		||||
msgid "Failed to delete custom data: {error}"
 | 
			
		||||
msgstr "Не удалось удалить пользовательские данные: {error}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{game_name}' successfully"
 | 
			
		||||
msgstr "'{game_name}' успешно добавлен(а)"
 | 
			
		||||
 | 
			
		||||
msgid "Game name and executable path are required"
 | 
			
		||||
msgstr "Требуются название игры и путь к исполняемому файлу"
 | 
			
		||||
 | 
			
		||||
@@ -267,15 +259,50 @@ msgstr "Удалить"
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr "Выбрать всё"
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr "Выбрать"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr "Открыть"
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr "Выбрать папку"
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr "Предыдущий каталог"
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "Отмена"
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr "Переключить"
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr "Установить"
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr "Принудительно установить"
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr "Предыдущая вкладка"
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr "Следующая вкладка"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr "Идёт запуск {0}"
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr "Проводник"
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr "Выбрать"
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr "Путь: "
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgstr "Доступ запрещён: %s"
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
msgstr "Редактировать игру"
 | 
			
		||||
 | 
			
		||||
@@ -312,6 +339,39 @@ msgstr "Скачивание обложки..."
 | 
			
		||||
msgid "No cover selected"
 | 
			
		||||
msgstr "Обложка не выбрана"
 | 
			
		||||
 | 
			
		||||
msgid "Prefix Manager"
 | 
			
		||||
msgstr "Менеджер префиксов"
 | 
			
		||||
 | 
			
		||||
msgid "Set"
 | 
			
		||||
msgstr "Выбор"
 | 
			
		||||
 | 
			
		||||
msgid "Libraries"
 | 
			
		||||
msgstr "Библиотеки"
 | 
			
		||||
 | 
			
		||||
msgid "Information"
 | 
			
		||||
msgstr "Описание"
 | 
			
		||||
 | 
			
		||||
msgid "Fonts"
 | 
			
		||||
msgstr "Шрифты"
 | 
			
		||||
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr "Настройки"
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr "Winetricks не найден. Повторите попытку."
 | 
			
		||||
 | 
			
		||||
msgid "Warning"
 | 
			
		||||
msgstr "Предупреждение"
 | 
			
		||||
 | 
			
		||||
msgid "No components selected."
 | 
			
		||||
msgstr "Не выбрано ни одного компонента."
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed. Check logs."
 | 
			
		||||
msgstr "Установка не удалась. Проверьте журналы."
 | 
			
		||||
 | 
			
		||||
msgid "Components installed successfully."
 | 
			
		||||
msgstr "Компоненты успешно установлены."
 | 
			
		||||
 | 
			
		||||
msgid "Loading Epic Games Store games..."
 | 
			
		||||
msgstr "Загрузка игр из Epic Games Store..."
 | 
			
		||||
 | 
			
		||||
@@ -360,9 +420,6 @@ msgstr "Библиотека"
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr "Автоустановка"
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr "Эмуляторы"
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr "Настройки wine"
 | 
			
		||||
 | 
			
		||||
@@ -372,6 +429,34 @@ msgstr "Настройки PortProton"
 | 
			
		||||
msgid "Themes"
 | 
			
		||||
msgstr "Темы"
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr "Назад"
 | 
			
		||||
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr "Полный экран"
 | 
			
		||||
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr "Поиск"
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr "Установка уже выполняется."
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr "Не удалось запустить установку."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr "В процессе установки {}..."
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr "Установка завершена успешно."
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr "Установка не удалась."
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr "Ошибка установки."
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr "Загрузка игр из Steam..."
 | 
			
		||||
 | 
			
		||||
@@ -384,14 +469,109 @@ msgstr "Игровая библиотека"
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr "Найти игры..."
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr "Здесь можно настроить автоматическую установку игр..."
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr "'{name}' добавлен(а)"
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr "Список доступных эмуляторов и их настройка..."
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr "Инструмент совместимости:"
 | 
			
		||||
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgstr "Различные параметры и версии wine..."
 | 
			
		||||
msgid "Prefix:"
 | 
			
		||||
msgstr "Префикс:"
 | 
			
		||||
 | 
			
		||||
msgid "Wine Configuration"
 | 
			
		||||
msgstr "Конфигурация Wine"
 | 
			
		||||
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr "Редактор реестра"
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr "Командная строка"
 | 
			
		||||
 | 
			
		||||
msgid "Uninstaller"
 | 
			
		||||
msgstr "Удаление программ"
 | 
			
		||||
 | 
			
		||||
msgid "Create Prefix Backup"
 | 
			
		||||
msgstr "Создать резервную копию префикса"
 | 
			
		||||
 | 
			
		||||
msgid "Load Prefix Backup"
 | 
			
		||||
msgstr "Загрузить резервную копию префикса"
 | 
			
		||||
 | 
			
		||||
msgid "Delete Compatibility Tool"
 | 
			
		||||
msgstr "Удалить Инструмент совместимости"
 | 
			
		||||
 | 
			
		||||
msgid "Delete Prefix"
 | 
			
		||||
msgstr "Удалить Префикс"
 | 
			
		||||
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr "Очистить Префикс"
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr "Запуск инструмента..."
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr "Не удалось запустить процесс."
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr "Подтвердите очистку"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr "Префикс '{}' успешно удален."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Префикс '{}' очищен с ошибками:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr "Не удалось запустить процесс резервного копирования."
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start restore process."
 | 
			
		||||
msgstr "Не удалось запустить процесс восстановления."
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup completed."
 | 
			
		||||
msgstr "Резервное копирование префикса завершено."
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup failed."
 | 
			
		||||
msgstr "Сбой резервного копирования префикса."
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore completed."
 | 
			
		||||
msgstr "Восстановление префикса завершено."
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore failed."
 | 
			
		||||
msgstr "Восстановление префикса не удалось."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete prefix '{}'?"
 | 
			
		||||
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' deleted."
 | 
			
		||||
msgstr "Префикс «{}» удален."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete prefix: {}"
 | 
			
		||||
msgstr "Не удалось удалить префикс: {}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
 | 
			
		||||
msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Compatibility tool '{}' deleted."
 | 
			
		||||
msgstr "Инструмент совместимости «{}» удален."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete compatibility tool: {}"
 | 
			
		||||
msgstr "Не удалось удалить инструмент совместимости: {}"
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
msgstr "Основные параметры PortProton..."
 | 
			
		||||
@@ -426,6 +606,9 @@ msgstr "все"
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr "Фильтр игр:"
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr "Тип геймпада:"
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr "Адрес прокси"
 | 
			
		||||
 | 
			
		||||
@@ -450,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения:"
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr "Сворачивать в трей при закрытии"
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr "Режим закрытия приложения:"
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
 | 
			
		||||
 | 
			
		||||
@@ -462,21 +651,6 @@ msgstr "Тактильная отдача на геймпаде"
 | 
			
		||||
msgid "Gamepad haptic feedback:"
 | 
			
		||||
msgstr "Тактильная отдача на геймпаде:"
 | 
			
		||||
 | 
			
		||||
msgid "Open Legendary Login"
 | 
			
		||||
msgstr "Открыть браузер для входа в Legendary"
 | 
			
		||||
 | 
			
		||||
msgid "Legendary Authentication:"
 | 
			
		||||
msgstr "Авторизация в Legendary:"
 | 
			
		||||
 | 
			
		||||
msgid "Enter Legendary Authorization Code"
 | 
			
		||||
msgstr "Введите код авторизации Legendary"
 | 
			
		||||
 | 
			
		||||
msgid "Authorization Code:"
 | 
			
		||||
msgstr "Код авторизации:"
 | 
			
		||||
 | 
			
		||||
msgid "Submit Code"
 | 
			
		||||
msgstr "Отправить код"
 | 
			
		||||
 | 
			
		||||
msgid "Save Settings"
 | 
			
		||||
msgstr "Сохранить настройки"
 | 
			
		||||
 | 
			
		||||
@@ -486,28 +660,6 @@ msgstr "Сбросить настройки"
 | 
			
		||||
msgid "Clear Cache"
 | 
			
		||||
msgstr "Очистить кэш"
 | 
			
		||||
 | 
			
		||||
msgid "Opened Legendary login page in browser"
 | 
			
		||||
msgstr "Открытие страницы входа в Legendary в браузере"
 | 
			
		||||
 | 
			
		||||
msgid "Failed to open Legendary login page"
 | 
			
		||||
msgstr "Не удалось открыть страницу входа в Legendary"
 | 
			
		||||
 | 
			
		||||
msgid "Please enter an authorization code"
 | 
			
		||||
msgstr "Пожалуйста, введите код авторизации"
 | 
			
		||||
 | 
			
		||||
msgid "Successfully authenticated with Legendary"
 | 
			
		||||
msgstr "Успешная аутентификация в Legendary"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Legendary authentication failed: {0}"
 | 
			
		||||
msgstr "Не удалось выполнить аутентификацию Legendary: {0}"
 | 
			
		||||
 | 
			
		||||
msgid "Legendary executable not found"
 | 
			
		||||
msgstr "Не найден исполняемый файл Legendary"
 | 
			
		||||
 | 
			
		||||
msgid "Unexpected error during authentication"
 | 
			
		||||
msgstr "Неожиданная ошибка при аутентификации"
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Reset"
 | 
			
		||||
msgstr "Подтвердите удаление"
 | 
			
		||||
 | 
			
		||||
@@ -563,15 +715,21 @@ msgstr "Тема '{0}' применена успешно"
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr "Ошибка при применение темы '{0}'"
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr "Назад"
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr "Последний запуск"
 | 
			
		||||
 | 
			
		||||
msgid "PLAY TIME"
 | 
			
		||||
msgstr "Время игры"
 | 
			
		||||
 | 
			
		||||
msgid "MAIN STORY"
 | 
			
		||||
msgstr "СЮЖЕТ"
 | 
			
		||||
 | 
			
		||||
msgid "MAIN + SIDES"
 | 
			
		||||
msgstr "СЮЖЕТ + ПОБОЧКИ"
 | 
			
		||||
 | 
			
		||||
msgid "COMPLETIONIST"
 | 
			
		||||
msgstr "100%"
 | 
			
		||||
 | 
			
		||||
msgid "full"
 | 
			
		||||
msgstr "полная"
 | 
			
		||||
 | 
			
		||||
@@ -660,3 +818,24 @@ msgstr "мин."
 | 
			
		||||
msgid "sec."
 | 
			
		||||
msgstr "сек."
 | 
			
		||||
 | 
			
		||||
msgid "Show"
 | 
			
		||||
msgstr "Показать"
 | 
			
		||||
 | 
			
		||||
msgid "Favorites"
 | 
			
		||||
msgstr "Избранное"
 | 
			
		||||
 | 
			
		||||
msgid "Recent Games"
 | 
			
		||||
msgstr "Недавние"
 | 
			
		||||
 | 
			
		||||
msgid "Exit"
 | 
			
		||||
msgstr "Выход"
 | 
			
		||||
 | 
			
		||||
msgid "Hide"
 | 
			
		||||
msgstr "Скрыть"
 | 
			
		||||
 | 
			
		||||
msgid "No favorites"
 | 
			
		||||
msgstr "Нет избранных"
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr "Нет недавних игр"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,34 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
def setup_logger():
 | 
			
		||||
def setup_logger(level='NOTSET'):
 | 
			
		||||
    """Настройка базовой конфигурации логирования."""
 | 
			
		||||
    logging.basicConfig(
 | 
			
		||||
        level=logging.INFO,
 | 
			
		||||
        format='[%(levelname)s] %(message)s',
 | 
			
		||||
        handlers=[logging.StreamHandler()]
 | 
			
		||||
    )
 | 
			
		||||
    # Clear existing handlers to prevent duplicates
 | 
			
		||||
    root_logger = logging.getLogger()
 | 
			
		||||
    for handler in root_logger.handlers[:]:
 | 
			
		||||
        root_logger.removeHandler(handler)
 | 
			
		||||
 | 
			
		||||
    # Convert string level to logging level constant, map ALL to DEBUG
 | 
			
		||||
    if level.upper() == 'ALL':
 | 
			
		||||
        log_level = logging.DEBUG
 | 
			
		||||
    else:
 | 
			
		||||
        log_level = getattr(logging, level.upper(), logging.NOTSET)
 | 
			
		||||
 | 
			
		||||
    # Configure logging with null handler if level is NOTSET
 | 
			
		||||
    if log_level == logging.NOTSET:
 | 
			
		||||
        logging.basicConfig(
 | 
			
		||||
            level=logging.NOTSET,
 | 
			
		||||
            handlers=[logging.NullHandler()]
 | 
			
		||||
        )
 | 
			
		||||
    else:
 | 
			
		||||
        logging.basicConfig(
 | 
			
		||||
            level=log_level,
 | 
			
		||||
            format='[%(levelname)s] %(message)s',
 | 
			
		||||
            handlers=[logging.StreamHandler()]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
def get_logger(name):
 | 
			
		||||
    """Возвращает логгер для указанного модуля."""
 | 
			
		||||
    return logging.getLogger(name)
 | 
			
		||||
 | 
			
		||||
# Инициализация логгера при импорте модуля
 | 
			
		||||
# Инициализация логгера при импорте модуля (без логов по умолчанию)
 | 
			
		||||
setup_logger()
 | 
			
		||||
 
 | 
			
		||||
@@ -4,12 +4,18 @@ import orjson
 | 
			
		||||
import requests
 | 
			
		||||
import urllib.parse
 | 
			
		||||
import time
 | 
			
		||||
import glob
 | 
			
		||||
import re
 | 
			
		||||
import hashlib
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from PySide6.QtCore import QThread, Signal
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds
 | 
			
		||||
AUTOINSTALL_CACHE_DURATION = 3600  # 1 hour for autoinstall cache
 | 
			
		||||
 | 
			
		||||
def normalize_name(s):
 | 
			
		||||
    """
 | 
			
		||||
@@ -52,7 +58,11 @@ class PortProtonAPI:
 | 
			
		||||
        self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
        self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
 | 
			
		||||
        os.makedirs(self.custom_data_dir, exist_ok=True)
 | 
			
		||||
        self.portproton_location = get_portproton_location()
 | 
			
		||||
        self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 | 
			
		||||
        self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
 | 
			
		||||
        self._topics_data = None
 | 
			
		||||
        self._autoinstall_cache = None  # New: In-memory cache
 | 
			
		||||
 | 
			
		||||
    def _get_game_dir(self, exe_name: str) -> str:
 | 
			
		||||
        game_dir = os.path.join(self.custom_data_dir, exe_name)
 | 
			
		||||
@@ -68,40 +78,6 @@ class PortProtonAPI:
 | 
			
		||||
            logger.debug(f"Failed to check file at {url}: {e}")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]:
 | 
			
		||||
        game_dir = self._get_game_dir(exe_name)
 | 
			
		||||
        results: dict[str, str | None] = {"cover": None, "metadata": None}
 | 
			
		||||
        cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
 | 
			
		||||
        cover_url_base = f"{self.base_url}/{exe_name}/cover"
 | 
			
		||||
        metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
 | 
			
		||||
 | 
			
		||||
        for ext in cover_extensions:
 | 
			
		||||
            cover_url = f"{cover_url_base}{ext}"
 | 
			
		||||
            if self._check_file_exists(cover_url, timeout):
 | 
			
		||||
                local_cover_path = os.path.join(game_dir, f"cover{ext}")
 | 
			
		||||
                result = self.downloader.download(cover_url, local_cover_path, timeout=timeout)
 | 
			
		||||
                if result:
 | 
			
		||||
                    results["cover"] = result
 | 
			
		||||
                    logger.info(f"Downloaded cover for {exe_name} to {result}")
 | 
			
		||||
                    break
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.error(f"Failed to download cover for {exe_name} from {cover_url}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.debug(f"No cover found for {exe_name} with extension {ext}")
 | 
			
		||||
 | 
			
		||||
        if self._check_file_exists(metadata_url, timeout):
 | 
			
		||||
            local_metadata_path = os.path.join(game_dir, "metadata.txt")
 | 
			
		||||
            result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout)
 | 
			
		||||
            if result:
 | 
			
		||||
                results["metadata"] = result
 | 
			
		||||
                logger.info(f"Downloaded metadata for {exe_name} to {result}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.debug(f"No metadata found for {exe_name}")
 | 
			
		||||
 | 
			
		||||
        return results
 | 
			
		||||
 | 
			
		||||
    def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None:
 | 
			
		||||
        game_dir = self._get_game_dir(exe_name)
 | 
			
		||||
        cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
 | 
			
		||||
@@ -163,6 +139,236 @@ class PortProtonAPI:
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(results)
 | 
			
		||||
 | 
			
		||||
    def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
 | 
			
		||||
        """Download only autoinstall cover image (PNG only, no metadata)."""
 | 
			
		||||
        xdg_data_home = os.getenv("XDG_DATA_HOME",
 | 
			
		||||
                                os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
        autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
 | 
			
		||||
        user_game_folder = os.path.join(autoinstall_root, exe_name)
 | 
			
		||||
 | 
			
		||||
        if not os.path.isdir(user_game_folder):
 | 
			
		||||
            try:
 | 
			
		||||
                os.mkdir(user_game_folder)
 | 
			
		||||
            except FileExistsError:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
        cover_url = f"{self.base_url}/{exe_name}/cover.png"
 | 
			
		||||
        local_cover_path = os.path.join(user_game_folder, "cover.png")
 | 
			
		||||
 | 
			
		||||
        def on_cover_downloaded(local_path: str | None):
 | 
			
		||||
            if local_path:
 | 
			
		||||
                logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.debug(f"No autoinstall cover downloaded for {exe_name}")
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(local_path)
 | 
			
		||||
 | 
			
		||||
        if self._check_file_exists(cover_url, timeout):
 | 
			
		||||
            self.downloader.download_async(
 | 
			
		||||
                cover_url,
 | 
			
		||||
                local_cover_path,
 | 
			
		||||
                timeout=timeout,
 | 
			
		||||
                callback=on_cover_downloaded
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            logger.debug(f"No autoinstall cover found for {exe_name}")
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(None)
 | 
			
		||||
 | 
			
		||||
    def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]:
 | 
			
		||||
        """Extract display_name from # name comment and exe_name from autoinstall bash script."""
 | 
			
		||||
        try:
 | 
			
		||||
            with open(file_path, encoding='utf-8') as f:
 | 
			
		||||
                content = f.read()
 | 
			
		||||
 | 
			
		||||
            # Skip emulators
 | 
			
		||||
            if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE):
 | 
			
		||||
                return None, None
 | 
			
		||||
 | 
			
		||||
            display_name = None
 | 
			
		||||
            exe_name = None
 | 
			
		||||
 | 
			
		||||
            # Extract display_name from "# name:" comment
 | 
			
		||||
            name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE)
 | 
			
		||||
            if name_match:
 | 
			
		||||
                display_name = name_match.group(1).strip()
 | 
			
		||||
 | 
			
		||||
            # --- pw_create_unique_exe ---
 | 
			
		||||
            pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content)
 | 
			
		||||
            if pw_match:
 | 
			
		||||
                arg = pw_match.group(1)
 | 
			
		||||
                if arg:
 | 
			
		||||
                    exe_name = arg.strip()
 | 
			
		||||
                    if not exe_name.lower().endswith(".exe"):
 | 
			
		||||
                        exe_name += ".exe"
 | 
			
		||||
                else:
 | 
			
		||||
                    export_match = re.search(
 | 
			
		||||
                        r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']',
 | 
			
		||||
                        content, re.IGNORECASE)
 | 
			
		||||
                    if export_match:
 | 
			
		||||
                        exe_name = f"{export_match.group(1).strip()}.exe"
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                portwine_match = None
 | 
			
		||||
                for line in content.splitlines():
 | 
			
		||||
                    stripped = line.strip()
 | 
			
		||||
                    if stripped.startswith("#"):
 | 
			
		||||
                        continue
 | 
			
		||||
                    if "portwine_exe" in stripped and "=" in stripped:
 | 
			
		||||
                        portwine_match = stripped
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
                if portwine_match:
 | 
			
		||||
                    exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ")
 | 
			
		||||
                    exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr)
 | 
			
		||||
                    if exe_candidates:
 | 
			
		||||
                        exe_name = os.path.basename(exe_candidates[-1].strip())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            # Fallback
 | 
			
		||||
            if not display_name and exe_name:
 | 
			
		||||
                display_name = exe_name
 | 
			
		||||
 | 
			
		||||
            return display_name, exe_name
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to parse {file_path}: {e}")
 | 
			
		||||
            return None, None
 | 
			
		||||
 | 
			
		||||
    def _compute_scripts_signature(self, auto_dir: str) -> str:
 | 
			
		||||
        """Compute a hash-based signature of the autoinstall scripts to detect changes."""
 | 
			
		||||
        if not os.path.exists(auto_dir):
 | 
			
		||||
            return ""
 | 
			
		||||
        scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
			
		||||
        # Simple hash: concatenate sorted filenames and hash
 | 
			
		||||
        filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
 | 
			
		||||
        return hashlib.md5(filenames_str.encode()).hexdigest()
 | 
			
		||||
 | 
			
		||||
    def _load_autoinstall_cache(self):
 | 
			
		||||
        """Load cached autoinstall games if fresh and scripts unchanged."""
 | 
			
		||||
        if self._autoinstall_cache is not None:
 | 
			
		||||
            return self._autoinstall_cache
 | 
			
		||||
        cache_dir = get_cache_dir()
 | 
			
		||||
        cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
 | 
			
		||||
        if os.path.exists(cache_file):
 | 
			
		||||
            try:
 | 
			
		||||
                mod_time = os.path.getmtime(cache_file)
 | 
			
		||||
                if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
 | 
			
		||||
                    with open(cache_file, "rb") as f:
 | 
			
		||||
                        data = orjson.loads(f.read())
 | 
			
		||||
                        # Check signature
 | 
			
		||||
                        cached_signature = data.get("scripts_signature", "")
 | 
			
		||||
                        current_signature = self._compute_scripts_signature(
 | 
			
		||||
                            os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
 | 
			
		||||
                        )
 | 
			
		||||
                        if cached_signature != current_signature:
 | 
			
		||||
                            logger.info("Scripts signature mismatch; invalidating cache")
 | 
			
		||||
                            return None
 | 
			
		||||
                        self._autoinstall_cache = data["games"]
 | 
			
		||||
                        logger.info(f"Loaded {len(self._autoinstall_cache)} cached autoinstall games")
 | 
			
		||||
                        return self._autoinstall_cache
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to load autoinstall cache: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def _save_autoinstall_cache(self, games):
 | 
			
		||||
        """Save parsed autoinstall games to cache with scripts signature."""
 | 
			
		||||
        try:
 | 
			
		||||
            cache_dir = get_cache_dir()
 | 
			
		||||
            cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
 | 
			
		||||
            auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
 | 
			
		||||
            scripts_signature = self._compute_scripts_signature(auto_dir)
 | 
			
		||||
            data = {"games": games, "scripts_signature": scripts_signature, "timestamp": time.time()}
 | 
			
		||||
            with open(cache_file, "wb") as f:
 | 
			
		||||
                f.write(orjson.dumps(data))
 | 
			
		||||
            logger.debug(f"Saved {len(games)} autoinstall games to cache with signature {scripts_signature}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to save autoinstall cache: {e}")
 | 
			
		||||
 | 
			
		||||
    def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
 | 
			
		||||
        """Start loading auto-install games in a background thread. Returns the thread for management."""
 | 
			
		||||
        # Check cache first (sync, fast)
 | 
			
		||||
        cached_games = self._load_autoinstall_cache()
 | 
			
		||||
        if cached_games is not None:
 | 
			
		||||
            # Emit via callback immediately if cached
 | 
			
		||||
            QThread.msleep(0)  # Yield to Qt event loop
 | 
			
		||||
            callback(cached_games)
 | 
			
		||||
            return None  # No thread needed
 | 
			
		||||
 | 
			
		||||
        # No cache: Start background thread
 | 
			
		||||
        class AutoinstallWorker(QThread):
 | 
			
		||||
            finished = Signal(list)
 | 
			
		||||
            api: "PortProtonAPI"
 | 
			
		||||
            portproton_location: str | None
 | 
			
		||||
 | 
			
		||||
            def run(self):
 | 
			
		||||
                games = []
 | 
			
		||||
                auto_dir = os.path.join(
 | 
			
		||||
                    self.portproton_location or "", "data", "scripts", "pw_autoinstall"
 | 
			
		||||
                ) if self.portproton_location else ""
 | 
			
		||||
                if not os.path.exists(auto_dir):
 | 
			
		||||
                    self.finished.emit(games)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
			
		||||
                if not scripts:
 | 
			
		||||
                    self.finished.emit(games)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                xdg_data_home = os.getenv(
 | 
			
		||||
                    "XDG_DATA_HOME",
 | 
			
		||||
                    os.path.join(os.path.expanduser("~"), ".local", "share"),
 | 
			
		||||
                )
 | 
			
		||||
                base_autoinstall_dir = os.path.join(
 | 
			
		||||
                    xdg_data_home, "PortProtonQt", "custom_data", "autoinstall"
 | 
			
		||||
                )
 | 
			
		||||
                os.makedirs(base_autoinstall_dir, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
                for script_path in scripts:
 | 
			
		||||
                    display_name, exe_name = self.api.parse_autoinstall_script(script_path)
 | 
			
		||||
                    script_name = os.path.splitext(os.path.basename(script_path))[0]
 | 
			
		||||
 | 
			
		||||
                    if not (display_name and exe_name):
 | 
			
		||||
                        continue
 | 
			
		||||
 | 
			
		||||
                    exe_name = os.path.splitext(exe_name)[0]
 | 
			
		||||
                    user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
 | 
			
		||||
                    os.makedirs(user_game_folder, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
                    # Find cover
 | 
			
		||||
                    cover_path = ""
 | 
			
		||||
                    user_files = (
 | 
			
		||||
                        set(os.listdir(user_game_folder))
 | 
			
		||||
                        if os.path.exists(user_game_folder)
 | 
			
		||||
                        else set()
 | 
			
		||||
                    )
 | 
			
		||||
                    for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
 | 
			
		||||
                        candidate = f"cover{ext}"
 | 
			
		||||
                        if candidate in user_files:
 | 
			
		||||
                            cover_path = os.path.join(user_game_folder, candidate)
 | 
			
		||||
                            break
 | 
			
		||||
 | 
			
		||||
                    if not cover_path:
 | 
			
		||||
                        logger.debug(f"No local cover found for autoinstall {exe_name}")
 | 
			
		||||
 | 
			
		||||
                    game_tuple = (
 | 
			
		||||
                        display_name, "", cover_path, "", f"autoinstall:{script_name}",
 | 
			
		||||
                        "", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
 | 
			
		||||
                    )
 | 
			
		||||
                    games.append(game_tuple)
 | 
			
		||||
 | 
			
		||||
                self.api._save_autoinstall_cache(games)
 | 
			
		||||
                self.api._autoinstall_cache = games
 | 
			
		||||
                self.finished.emit(games)
 | 
			
		||||
 | 
			
		||||
        worker = AutoinstallWorker()
 | 
			
		||||
        worker.api = self
 | 
			
		||||
        worker.portproton_location = self.portproton_location
 | 
			
		||||
        worker.finished.connect(lambda games: callback(games))
 | 
			
		||||
        worker.start()
 | 
			
		||||
        logger.info("Started background load of autoinstall games")
 | 
			
		||||
        return worker
 | 
			
		||||
 | 
			
		||||
    def _load_topics_data(self):
 | 
			
		||||
        """Load and cache linux_gaming_topics_min.json from the archive."""
 | 
			
		||||
        if self._topics_data is not None:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										49
									
								
								portprotonqt/preloader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,49 @@
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from PySide6.QtCore import QRect
 | 
			
		||||
from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient
 | 
			
		||||
from PySide6.QtWidgets import QWidget
 | 
			
		||||
 | 
			
		||||
class Preloader(QWidget):
 | 
			
		||||
    def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.setFixedSize(150, 150)
 | 
			
		||||
        self._speed = speed
 | 
			
		||||
        self._line_width = line_line_width
 | 
			
		||||
        self._color1 = color
 | 
			
		||||
        self._color2 = QColor(color.red(), color.green(), color.blue(), 0)
 | 
			
		||||
        self._start_time = time.time()
 | 
			
		||||
 | 
			
		||||
    def showEvent(self, event):
 | 
			
		||||
        self._start_time = time.time()
 | 
			
		||||
 | 
			
		||||
    def paintEvent(self, event):
 | 
			
		||||
        rect = self._get_preloader_rect()
 | 
			
		||||
        center = rect.center()
 | 
			
		||||
        painter = QPainter(self)
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
        painter.setPen(self._get_pen())
 | 
			
		||||
        painter.translate(center)
 | 
			
		||||
        painter.rotate(self._get_angle())
 | 
			
		||||
        painter.translate(-center)
 | 
			
		||||
        painter.drawArc(rect, 0, 270 * 16)
 | 
			
		||||
        self.update()
 | 
			
		||||
 | 
			
		||||
    def _get_pen(self) -> QPen:
 | 
			
		||||
        gradient = QConicalGradient()
 | 
			
		||||
        gradient.setCenter(self.rect().center())
 | 
			
		||||
        gradient.setColorAt(0, self._color1)
 | 
			
		||||
        gradient.setColorAt(1, self._color2)
 | 
			
		||||
        pen = QPen(QBrush(gradient), self._line_width)
 | 
			
		||||
        pen.setCapStyle(Qt.PenCapStyle.RoundCap)
 | 
			
		||||
        return pen
 | 
			
		||||
 | 
			
		||||
    def _get_angle(self) -> float:
 | 
			
		||||
        duration = time.time() - self._start_time
 | 
			
		||||
        return (self._speed * duration) % 360.0
 | 
			
		||||
 | 
			
		||||
    def _get_preloader_rect(self) -> QRect:
 | 
			
		||||
        size = self._line_width // 2
 | 
			
		||||
        rect = self.rect()
 | 
			
		||||
        rect.adjust(size, size, -size, -size)
 | 
			
		||||
        return rect
 | 
			
		||||
@@ -13,11 +13,17 @@ from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.localization import get_steam_language
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.dialogs import generate_thumbnail
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import re
 | 
			
		||||
import shutil
 | 
			
		||||
import zlib
 | 
			
		||||
import websocket
 | 
			
		||||
import requests
 | 
			
		||||
import random
 | 
			
		||||
import base64
 | 
			
		||||
import glob
 | 
			
		||||
import urllib.parse
 | 
			
		||||
 | 
			
		||||
downloader = Downloader()
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -40,14 +46,14 @@ def safe_vdf_load(path: str | Path) -> dict:
 | 
			
		||||
 | 
			
		||||
def decode_text(text: str) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    Декодирует HTML-сущности в строке.
 | 
			
		||||
    Например, "&quot;" преобразуется в '"'.
 | 
			
		||||
    Остальные символы и HTML-теги остаются без изменений.
 | 
			
		||||
    Decodes HTML entities in a string.
 | 
			
		||||
    For example, "&quot;" is converted to '"'.
 | 
			
		||||
    Other characters and HTML tags remain unchanged.
 | 
			
		||||
    """
 | 
			
		||||
    return html.unescape(text)
 | 
			
		||||
 | 
			
		||||
def get_cache_dir():
 | 
			
		||||
    """Возвращает путь к каталогу кэша, создаёт его при необходимости."""
 | 
			
		||||
    """Returns the path to the cache directory, creating it if necessary."""
 | 
			
		||||
    xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
 | 
			
		||||
    cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
 | 
			
		||||
    os.makedirs(cache_dir, exist_ok=True)
 | 
			
		||||
@@ -60,7 +66,7 @@ STEAM_DATA_DIRS = (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
def get_steam_home():
 | 
			
		||||
    """Возвращает путь к директории Steam, используя список возможных директорий."""
 | 
			
		||||
    """Returns the path to the Steam directory using a list of possible directories."""
 | 
			
		||||
    for dir_path in STEAM_DATA_DIRS:
 | 
			
		||||
        expanded_path = Path(os.path.expanduser(dir_path))
 | 
			
		||||
        if expanded_path.exists():
 | 
			
		||||
@@ -68,7 +74,7 @@ def get_steam_home():
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def get_last_steam_user(steam_home: Path) -> dict | None:
 | 
			
		||||
    """Возвращает данные последнего пользователя Steam из loginusers.vdf."""
 | 
			
		||||
    """Returns data for the last Steam user from loginusers.vdf."""
 | 
			
		||||
    loginusers_path = steam_home / "config/loginusers.vdf"
 | 
			
		||||
    data = safe_vdf_load(loginusers_path)
 | 
			
		||||
    if not data:
 | 
			
		||||
@@ -79,20 +85,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
 | 
			
		||||
            try:
 | 
			
		||||
                return {'SteamID': int(user_id)}
 | 
			
		||||
            except ValueError:
 | 
			
		||||
                logger.error(f"Неверный формат SteamID: {user_id}")
 | 
			
		||||
                logger.error(f"Invalid SteamID format: {user_id}")
 | 
			
		||||
                return None
 | 
			
		||||
    logger.info("Не найден пользователь с MostRecent=1")
 | 
			
		||||
    logger.info("No user found with MostRecent=1")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def convert_steam_id(steam_id: int) -> int:
 | 
			
		||||
    """
 | 
			
		||||
    Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число.
 | 
			
		||||
    Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения.
 | 
			
		||||
    Converts a signed 32-bit integer to an unsigned 32-bit integer.
 | 
			
		||||
    Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values.
 | 
			
		||||
    """
 | 
			
		||||
    return steam_id & 0xFFFFFFFF
 | 
			
		||||
 | 
			
		||||
def get_steam_libs(steam_dir: Path) -> set[Path]:
 | 
			
		||||
    """Возвращает набор директорий Steam libraryfolders."""
 | 
			
		||||
    """Returns a set of Steam library folders."""
 | 
			
		||||
    libs = set()
 | 
			
		||||
    libs_vdf = steam_dir / "steamapps/libraryfolders.vdf"
 | 
			
		||||
    data = safe_vdf_load(libs_vdf)
 | 
			
		||||
@@ -108,7 +114,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]:
 | 
			
		||||
    return libs
 | 
			
		||||
 | 
			
		||||
def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]:
 | 
			
		||||
    """Возвращает данные о времени игры для последнего пользователя."""
 | 
			
		||||
    """Returns playtime data for the last user."""
 | 
			
		||||
    play_data: dict[int, tuple[int, int]] = {}
 | 
			
		||||
    if steam_home is None:
 | 
			
		||||
        steam_home = get_steam_home()
 | 
			
		||||
@@ -128,14 +134,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
 | 
			
		||||
        return play_data
 | 
			
		||||
 | 
			
		||||
    if not last_user:
 | 
			
		||||
        logger.info("Не удалось определить последнего пользователя Steam")
 | 
			
		||||
        logger.info("Could not identify the last Steam user")
 | 
			
		||||
        return play_data
 | 
			
		||||
 | 
			
		||||
    user_id = last_user['SteamID']
 | 
			
		||||
    unsigned_id = convert_steam_id(user_id)
 | 
			
		||||
    user_dir = userdata_dir / str(unsigned_id)
 | 
			
		||||
    if not user_dir.exists():
 | 
			
		||||
        logger.info(f"Директория пользователя {unsigned_id} не найдена")
 | 
			
		||||
        logger.info(f"User directory {unsigned_id} not found")
 | 
			
		||||
        return play_data
 | 
			
		||||
 | 
			
		||||
    localconfig = user_dir / "config/localconfig.vdf"
 | 
			
		||||
@@ -149,11 +155,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
 | 
			
		||||
            playtime = int(info.get('Playtime', 0))
 | 
			
		||||
            play_data[appid] = (last_played, playtime)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            logger.warning(f"Некорректные данные playtime для app {appid_str}")
 | 
			
		||||
            logger.warning(f"Invalid playtime data for app {appid_str}")
 | 
			
		||||
    return play_data
 | 
			
		||||
 | 
			
		||||
def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
 | 
			
		||||
    """Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec)."""
 | 
			
		||||
    """Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec)."""
 | 
			
		||||
    games: list[tuple[str, int, int, int]] = []
 | 
			
		||||
    steam_home = get_steam_home()
 | 
			
		||||
    if steam_home is None or not steam_home.exists():
 | 
			
		||||
@@ -182,13 +188,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
 | 
			
		||||
 | 
			
		||||
def normalize_name(s):
 | 
			
		||||
    """
 | 
			
		||||
    Приведение строки к нормальному виду:
 | 
			
		||||
      - перевод в нижний регистр,
 | 
			
		||||
      - удаление символов ™ и ®,
 | 
			
		||||
      - замена разделителей (-, :, ,) на пробел,
 | 
			
		||||
      - удаление лишних пробелов,
 | 
			
		||||
      - удаление суффиксов 'bin' или 'app' в конце строки,
 | 
			
		||||
      - удаление ключевых слов типа 'ultimate', 'edition' и т.п.
 | 
			
		||||
    Normalizes a string by:
 | 
			
		||||
      - converting to lowercase,
 | 
			
		||||
      - removing ™ and ® symbols,
 | 
			
		||||
      - replacing separators (-, :, ,) with spaces,
 | 
			
		||||
      - removing extra spaces,
 | 
			
		||||
      - removing 'bin' or 'app' suffixes,
 | 
			
		||||
      - removing keywords like 'ultimate', 'edition', etc.
 | 
			
		||||
    """
 | 
			
		||||
    s = s.lower()
 | 
			
		||||
    for ch in ["™", "®"]:
 | 
			
		||||
@@ -206,14 +212,28 @@ def normalize_name(s):
 | 
			
		||||
 | 
			
		||||
def is_valid_candidate(candidate):
 | 
			
		||||
    """
 | 
			
		||||
    Проверяет, содержит ли кандидат запрещённые подстроки:
 | 
			
		||||
      - win32
 | 
			
		||||
      - win64
 | 
			
		||||
      - gamelauncher
 | 
			
		||||
    Для проверки дополнительно используется строка без пробелов.
 | 
			
		||||
    Возвращает True, если кандидат допустим, иначе False.
 | 
			
		||||
    Determines whether a given candidate string is valid for use as a game name.
 | 
			
		||||
 | 
			
		||||
    The function performs the following checks:
 | 
			
		||||
      1. Normalizes the candidate using `normalize_name()`.
 | 
			
		||||
      2. Rejects the candidate if the normalized name is exactly "game"
 | 
			
		||||
         (to avoid overly generic names).
 | 
			
		||||
      3. Removes spaces and checks for forbidden substrings:
 | 
			
		||||
         - "win32"
 | 
			
		||||
         - "win64"
 | 
			
		||||
         - "gamelauncher"
 | 
			
		||||
         These are checked in the space-free version of the string.
 | 
			
		||||
      4. Returns True only if none of the forbidden conditions are met.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        candidate (str): The candidate string to validate.
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        bool: True if the candidate is valid, False otherwise.
 | 
			
		||||
    """
 | 
			
		||||
    normalized_candidate = normalize_name(candidate)
 | 
			
		||||
    if normalized_candidate == "game":
 | 
			
		||||
        return False
 | 
			
		||||
    normalized_no_space = normalized_candidate.replace(" ", "")
 | 
			
		||||
    forbidden = ["win32", "win64", "gamelauncher"]
 | 
			
		||||
    for token in forbidden:
 | 
			
		||||
@@ -223,7 +243,7 @@ def is_valid_candidate(candidate):
 | 
			
		||||
 | 
			
		||||
def filter_candidates(candidates):
 | 
			
		||||
    """
 | 
			
		||||
    Фильтрует список кандидатов, отбрасывая недопустимые.
 | 
			
		||||
    Filters a list of candidates, discarding invalid ones.
 | 
			
		||||
    """
 | 
			
		||||
    valid = []
 | 
			
		||||
    dropped = []
 | 
			
		||||
@@ -233,18 +253,18 @@ def filter_candidates(candidates):
 | 
			
		||||
        else:
 | 
			
		||||
            dropped.append(cand)
 | 
			
		||||
    if dropped:
 | 
			
		||||
        logger.info("Отбрасываю кандидатов: %s", dropped)
 | 
			
		||||
        logger.info("Discarding candidates: %s", dropped)
 | 
			
		||||
    return valid
 | 
			
		||||
 | 
			
		||||
def remove_duplicates(candidates):
 | 
			
		||||
    """
 | 
			
		||||
    Удаляет дубликаты из списка, сохраняя порядок.
 | 
			
		||||
    Removes duplicates from a list while preserving order.
 | 
			
		||||
    """
 | 
			
		||||
    return list(dict.fromkeys(candidates))
 | 
			
		||||
 | 
			
		||||
@functools.lru_cache(maxsize=256)
 | 
			
		||||
def get_exiftool_data(game_exe):
 | 
			
		||||
    """Получает метаданные через exiftool"""
 | 
			
		||||
    """Retrieves metadata using exiftool."""
 | 
			
		||||
    try:
 | 
			
		||||
        proc = subprocess.run(
 | 
			
		||||
            ["exiftool", "-j", game_exe],
 | 
			
		||||
@@ -253,18 +273,28 @@ def get_exiftool_data(game_exe):
 | 
			
		||||
            check=False
 | 
			
		||||
        )
 | 
			
		||||
        if proc.returncode != 0:
 | 
			
		||||
            logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}")
 | 
			
		||||
            logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
 | 
			
		||||
            return {}
 | 
			
		||||
        meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
 | 
			
		||||
        return meta_data_list[0] if meta_data_list else {}
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
 | 
			
		||||
        logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
 | 
			
		||||
        return {}
 | 
			
		||||
 | 
			
		||||
def delete_cached_app_files(cache_dir: str, pattern: str):
 | 
			
		||||
    """Deletes cached files matching the given pattern in the cache directory."""
 | 
			
		||||
    try:
 | 
			
		||||
        for file_path in glob.glob(os.path.join(cache_dir, pattern)):
 | 
			
		||||
            os.remove(file_path)
 | 
			
		||||
            logger.info(f"Deleted cached file: {file_path}")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to delete cached files matching {pattern}: {e}")
 | 
			
		||||
 | 
			
		||||
def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously loads the list of Steam applications, using cache if available.
 | 
			
		||||
    Calls the callback with the list of apps.
 | 
			
		||||
    Deletes cached app detail files when downloading a new steam_apps.json.
 | 
			
		||||
    """
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_tar = os.path.join(cache_dir, "games_appid.tar.xz")
 | 
			
		||||
@@ -290,12 +320,14 @@ def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
                f.write(orjson.dumps(data))
 | 
			
		||||
            if os.path.exists(cache_tar):
 | 
			
		||||
                os.remove(cache_tar)
 | 
			
		||||
                logger.info("Archive %s deleted after extraction", cache_tar)
 | 
			
		||||
            steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or []
 | 
			
		||||
                logger.info("Deleted archive: %s", cache_tar)
 | 
			
		||||
            # Delete all cached app detail files (steam_app_*.json)
 | 
			
		||||
            delete_cached_app_files(cache_dir, "steam_app_*.json")
 | 
			
		||||
            steam_apps = data if isinstance(data, list) else []
 | 
			
		||||
            logger.info("Loaded %d apps from archive", len(steam_apps))
 | 
			
		||||
            callback(steam_apps)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error extracting Steam apps archive: %s", e)
 | 
			
		||||
            logger.error("Failed to extract Steam apps archive: %s", e)
 | 
			
		||||
            callback([])
 | 
			
		||||
 | 
			
		||||
    if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
 | 
			
		||||
@@ -303,26 +335,43 @@ def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
        try:
 | 
			
		||||
            with open(cache_json, "rb") as f:
 | 
			
		||||
                data = orjson.loads(f.read())
 | 
			
		||||
            steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or []
 | 
			
		||||
            # Validate JSON structure
 | 
			
		||||
            if not isinstance(data, list):
 | 
			
		||||
                logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
 | 
			
		||||
                raise ValueError("Invalid JSON structure")
 | 
			
		||||
            # Validate each app entry
 | 
			
		||||
            for app in data:
 | 
			
		||||
                if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
 | 
			
		||||
                    logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json)
 | 
			
		||||
                    raise ValueError("Invalid app entry structure")
 | 
			
		||||
            steam_apps = data
 | 
			
		||||
            logger.info("Loaded %d apps from cache", len(steam_apps))
 | 
			
		||||
            callback(steam_apps)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error reading cached JSON: %s", e)
 | 
			
		||||
            callback([])
 | 
			
		||||
            logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e)
 | 
			
		||||
            # Attempt to re-download if cache is invalid or corrupted
 | 
			
		||||
            app_list_url = (
 | 
			
		||||
                "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
 | 
			
		||||
            )
 | 
			
		||||
            # Delete cached app detail files before re-downloading
 | 
			
		||||
            delete_cached_app_files(cache_dir, "steam_app_*.json")
 | 
			
		||||
            downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
    else:
 | 
			
		||||
        app_list_url = (
 | 
			
		||||
            "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
 | 
			
		||||
        )
 | 
			
		||||
        # Delete cached app detail files before downloading
 | 
			
		||||
        delete_cached_app_files(cache_dir, "steam_app_*.json")
 | 
			
		||||
        downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
 | 
			
		||||
def build_index(steam_apps):
 | 
			
		||||
    """
 | 
			
		||||
    Строит индекс приложений по полю normalized_name.
 | 
			
		||||
    Builds an index of applications by normalized_name field.
 | 
			
		||||
    """
 | 
			
		||||
    steam_apps_index = {}
 | 
			
		||||
    if not steam_apps:
 | 
			
		||||
        return steam_apps_index
 | 
			
		||||
    logger.info("Построение индекса Steam приложений:")
 | 
			
		||||
    logger.info("Building Steam apps index")
 | 
			
		||||
    for app in steam_apps:
 | 
			
		||||
        normalized = app["normalized_name"]
 | 
			
		||||
        steam_apps_index[normalized] = app
 | 
			
		||||
@@ -330,25 +379,24 @@ def build_index(steam_apps):
 | 
			
		||||
 | 
			
		||||
def search_app(candidate, steam_apps_index):
 | 
			
		||||
    """
 | 
			
		||||
    Ищет приложение по кандидату: сначала пытается точное совпадение, затем ищет подстроку.
 | 
			
		||||
    Searches for an application by candidate: tries exact match first, then substring match.
 | 
			
		||||
    """
 | 
			
		||||
    candidate_norm = normalize_name(candidate)
 | 
			
		||||
    logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm)
 | 
			
		||||
    logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
 | 
			
		||||
    if candidate_norm in steam_apps_index:
 | 
			
		||||
        logger.info("    Найдено точное совпадение: '%s'", candidate_norm)
 | 
			
		||||
        logger.info("Found exact match: '%s'", candidate_norm)
 | 
			
		||||
        return steam_apps_index[candidate_norm]
 | 
			
		||||
    for name_norm, app in steam_apps_index.items():
 | 
			
		||||
        if candidate_norm in name_norm:
 | 
			
		||||
            ratio = len(candidate_norm) / len(name_norm)
 | 
			
		||||
            if ratio > 0.8:
 | 
			
		||||
                logger.info("    Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)",
 | 
			
		||||
                            candidate_norm, name_norm, ratio)
 | 
			
		||||
                logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
 | 
			
		||||
                return app
 | 
			
		||||
    logger.info("    Приложение для кандидата '%s' не найдено", candidate_norm)
 | 
			
		||||
    logger.info("No app found for candidate '%s'", candidate_norm)
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def load_app_details(app_id):
 | 
			
		||||
    """Загружает кэшированные данные для игры по appid, если они не устарели."""
 | 
			
		||||
    """Loads cached game data by appid if not outdated."""
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
 | 
			
		||||
    if os.path.exists(cache_file):
 | 
			
		||||
@@ -358,12 +406,45 @@ def load_app_details(app_id):
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def save_app_details(app_id, data):
 | 
			
		||||
    """Сохраняет данные по appid в файл кэша."""
 | 
			
		||||
    """Saves appid data to a cache file."""
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
 | 
			
		||||
    with open(cache_file, "wb") as f:
 | 
			
		||||
        f.write(orjson.dumps(data))
 | 
			
		||||
 | 
			
		||||
def fetch_sgdb_cover(game_name: str) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    Fetch a cover image URL from steamgrid.usebottles.com for the given game.
 | 
			
		||||
    The API returns a single string (quoted URL).
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        encoded = urllib.parse.quote(game_name)
 | 
			
		||||
        url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
 | 
			
		||||
        resp = requests.get(url, timeout=5)
 | 
			
		||||
        if resp.status_code != 200:
 | 
			
		||||
            logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
 | 
			
		||||
            return ""
 | 
			
		||||
        text = resp.text.strip()
 | 
			
		||||
        # Убираем возможные кавычки вокруг строки
 | 
			
		||||
        if text.startswith('"') and text.endswith('"'):
 | 
			
		||||
            text = text[1:-1]
 | 
			
		||||
        if text:
 | 
			
		||||
            logger.info("Fetched SGDB cover for %s: %s", game_name, text)
 | 
			
		||||
        return text
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_url_exists(url: str) -> bool:
 | 
			
		||||
    """Check whether a URL returns HTTP 200."""
 | 
			
		||||
    try:
 | 
			
		||||
        r = requests.head(url, timeout=3)
 | 
			
		||||
        return r.status_code == 200
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously fetches detailed app info from Steam API.
 | 
			
		||||
@@ -401,7 +482,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
 | 
			
		||||
            save_app_details(app_id, app_data)
 | 
			
		||||
            callback(app_data)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error processing Steam app info for appid %s: %s", app_id, e)
 | 
			
		||||
            logger.error("Failed to process Steam app info for appid %s: %s", app_id, e)
 | 
			
		||||
            callback(None)
 | 
			
		||||
 | 
			
		||||
    downloader.download_async(url, cache_file, timeout=5, callback=process_response)
 | 
			
		||||
@@ -410,6 +491,7 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously loads the list of WeAntiCheatYet data, using cache if available.
 | 
			
		||||
    Calls the callback with the list of anti-cheat data.
 | 
			
		||||
    Deletes cached anti-cheat files when downloading a new anticheat_games.json.
 | 
			
		||||
    """
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz")
 | 
			
		||||
@@ -435,12 +517,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
                f.write(orjson.dumps(data))
 | 
			
		||||
            if os.path.exists(cache_tar):
 | 
			
		||||
                os.remove(cache_tar)
 | 
			
		||||
                logger.info("Archive %s deleted after extraction", cache_tar)
 | 
			
		||||
                logger.info("Deleted archive: %s", cache_tar)
 | 
			
		||||
            anti_cheat_data = data or []
 | 
			
		||||
            logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
 | 
			
		||||
            callback(anti_cheat_data)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error extracting WeAntiCheatYet archive: %s", e)
 | 
			
		||||
            logger.error("Failed to extract WeAntiCheatYet archive: %s", e)
 | 
			
		||||
            callback([])
 | 
			
		||||
 | 
			
		||||
    if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
 | 
			
		||||
@@ -448,12 +530,25 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
        try:
 | 
			
		||||
            with open(cache_json, "rb") as f:
 | 
			
		||||
                data = orjson.loads(f.read())
 | 
			
		||||
            anti_cheat_data = data or []
 | 
			
		||||
            # Validate JSON structure
 | 
			
		||||
            if not isinstance(data, list):
 | 
			
		||||
                logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
 | 
			
		||||
                raise ValueError("Invalid JSON structure")
 | 
			
		||||
            # Validate each anti-cheat entry
 | 
			
		||||
            for entry in data:
 | 
			
		||||
                if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
 | 
			
		||||
                    logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json)
 | 
			
		||||
                    raise ValueError("Invalid anti-cheat entry structure")
 | 
			
		||||
            anti_cheat_data = data
 | 
			
		||||
            logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
 | 
			
		||||
            callback(anti_cheat_data)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error reading cached WeAntiCheatYet JSON: %s", e)
 | 
			
		||||
            callback([])
 | 
			
		||||
            logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e)
 | 
			
		||||
            # Attempt to re-download if cache is invalid or corrupted
 | 
			
		||||
            app_list_url = (
 | 
			
		||||
                "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
 | 
			
		||||
            )
 | 
			
		||||
            downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
    else:
 | 
			
		||||
        app_list_url = (
 | 
			
		||||
            "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
 | 
			
		||||
@@ -462,12 +557,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
 | 
			
		||||
def build_weanticheatyet_index(anti_cheat_data):
 | 
			
		||||
    """
 | 
			
		||||
    Строит индекс античит-данных по полю normalized_name.
 | 
			
		||||
    Builds an index of anti-cheat data by normalized_name field.
 | 
			
		||||
    """
 | 
			
		||||
    anti_cheat_index = {}
 | 
			
		||||
    if not anti_cheat_data:
 | 
			
		||||
        return anti_cheat_index
 | 
			
		||||
    logger.info("Построение индекса WeAntiCheatYet данных:")
 | 
			
		||||
    logger.info("Building WeAntiCheatYet data index")
 | 
			
		||||
    for entry in anti_cheat_data:
 | 
			
		||||
        normalized = entry["normalized_name"]
 | 
			
		||||
        anti_cheat_index[normalized] = entry
 | 
			
		||||
@@ -475,20 +570,19 @@ def build_weanticheatyet_index(anti_cheat_data):
 | 
			
		||||
 | 
			
		||||
def search_anticheat_status(candidate, anti_cheat_index):
 | 
			
		||||
    candidate_norm = normalize_name(candidate)
 | 
			
		||||
    logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm)
 | 
			
		||||
    logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
 | 
			
		||||
    if candidate_norm in anti_cheat_index:
 | 
			
		||||
        status = anti_cheat_index[candidate_norm]["status"]
 | 
			
		||||
        logger.info("    Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status)
 | 
			
		||||
        logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
 | 
			
		||||
        return status
 | 
			
		||||
    for name_norm, entry in anti_cheat_index.items():
 | 
			
		||||
        if candidate_norm in name_norm:
 | 
			
		||||
            ratio = len(candidate_norm) / len(name_norm)
 | 
			
		||||
            if ratio > 0.8:
 | 
			
		||||
                status = entry["status"]
 | 
			
		||||
                logger.info("    Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'",
 | 
			
		||||
                            candidate_norm, name_norm, ratio, status)
 | 
			
		||||
                logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
 | 
			
		||||
                return status
 | 
			
		||||
    logger.info("    Античит-статус для кандидата '%s' не найден", candidate_norm)
 | 
			
		||||
    logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
 | 
			
		||||
@@ -504,7 +598,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No
 | 
			
		||||
    load_weanticheatyet_data_async(on_anticheat_data)
 | 
			
		||||
 | 
			
		||||
def load_protondb_status(appid):
 | 
			
		||||
    """Загружает закешированные данные ProtonDB для игры по appid, если они не устарели."""
 | 
			
		||||
    """Loads cached ProtonDB data for a game by appid if not outdated."""
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
 | 
			
		||||
    if os.path.exists(cache_file):
 | 
			
		||||
@@ -513,18 +607,18 @@ def load_protondb_status(appid):
 | 
			
		||||
                with open(cache_file, "rb") as f:
 | 
			
		||||
                    return orjson.loads(f.read())
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e)
 | 
			
		||||
                logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e)
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def save_protondb_status(appid, data):
 | 
			
		||||
    """Сохраняет данные ProtonDB для игры по appid в файл кэша."""
 | 
			
		||||
    """Saves ProtonDB data for a game by appid to a cache file."""
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
 | 
			
		||||
    try:
 | 
			
		||||
        with open(cache_file, "wb") as f:
 | 
			
		||||
            f.write(orjson.dumps(data))
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e)
 | 
			
		||||
        logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e)
 | 
			
		||||
 | 
			
		||||
def get_protondb_tier_async(appid: int, callback: Callable[[str], None]):
 | 
			
		||||
    """
 | 
			
		||||
@@ -569,6 +663,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None])
 | 
			
		||||
        title = decode_text(app_info.get("name", ""))
 | 
			
		||||
        description = decode_text(app_info.get("short_description", ""))
 | 
			
		||||
        cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
			
		||||
        if not check_url_exists(cover):
 | 
			
		||||
            logger.info("Steam cover not found for %s, trying SGDB", title)
 | 
			
		||||
            alt_cover = fetch_sgdb_cover(title)
 | 
			
		||||
            if alt_cover:
 | 
			
		||||
                cover = alt_cover
 | 
			
		||||
 | 
			
		||||
        def on_protondb_tier(tier: str):
 | 
			
		||||
            def on_anticheat_status(anticheat_status: str):
 | 
			
		||||
@@ -612,7 +711,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
			
		||||
                        if game_exe.lower().endswith('.exe'):
 | 
			
		||||
                            break
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error("Error processing bat file %s: %s", game_exe, e)
 | 
			
		||||
                logger.error("Failed to process bat file %s: %s", game_exe, e)
 | 
			
		||||
        else:
 | 
			
		||||
            logger.error("Bat file not found: %s", game_exe)
 | 
			
		||||
 | 
			
		||||
@@ -662,12 +761,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
			
		||||
        game_name = desktop_name or exe_name.capitalize()
 | 
			
		||||
 | 
			
		||||
        if not matching_app:
 | 
			
		||||
            cover = fetch_sgdb_cover(game_name) or ""
 | 
			
		||||
            logger.info("Using SGDB cover for non-Steam game '%s': %s", game_name, cover)
 | 
			
		||||
 | 
			
		||||
            def on_anticheat_status(anticheat_status: str):
 | 
			
		||||
                callback({
 | 
			
		||||
                    "appid": "",
 | 
			
		||||
                    "name": decode_text(game_name),
 | 
			
		||||
                    "description": "",
 | 
			
		||||
                    "cover": "",
 | 
			
		||||
                    "cover": cover,
 | 
			
		||||
                    "controller_support": "",
 | 
			
		||||
                    "protondb_tier": "",
 | 
			
		||||
                    "steam_game": "false",
 | 
			
		||||
@@ -698,6 +800,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
			
		||||
            title = decode_text(app_info.get("name", game_name))
 | 
			
		||||
            description = decode_text(app_info.get("short_description", ""))
 | 
			
		||||
            cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
			
		||||
            if not check_url_exists(cover):
 | 
			
		||||
                logger.info("Steam cover not found for %s, trying SGDB", title)
 | 
			
		||||
                alt_cover = fetch_sgdb_cover(title)
 | 
			
		||||
                if alt_cover:
 | 
			
		||||
                    cover = alt_cover
 | 
			
		||||
            controller_support = app_info.get("controller_support", "")
 | 
			
		||||
 | 
			
		||||
            def on_protondb_tier(tier: str):
 | 
			
		||||
@@ -745,6 +852,126 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
 | 
			
		||||
 | 
			
		||||
    load_steam_apps_async(on_steam_apps)
 | 
			
		||||
 | 
			
		||||
def enable_steam_cef() -> tuple[bool, str]:
 | 
			
		||||
    """
 | 
			
		||||
    Checks and enables Steam CEF remote debugging if necessary.
 | 
			
		||||
 | 
			
		||||
    Creates a .cef-enable-remote-debugging file in the Steam directory.
 | 
			
		||||
    Steam must be restarted after the file is first created.
 | 
			
		||||
 | 
			
		||||
    Returns a tuple:
 | 
			
		||||
    - (True, "already_enabled") if already enabled.
 | 
			
		||||
    - (True, "restart_needed") if just enabled and Steam restart is needed.
 | 
			
		||||
    - (False, "steam_not_found") if Steam directory is not found.
 | 
			
		||||
    """
 | 
			
		||||
    steam_home = get_steam_home()
 | 
			
		||||
    if not steam_home:
 | 
			
		||||
        return (False, "steam_not_found")
 | 
			
		||||
 | 
			
		||||
    cef_flag_file = steam_home / ".cef-enable-remote-debugging"
 | 
			
		||||
    logger.info(f"Checking CEF flag: {cef_flag_file}")
 | 
			
		||||
 | 
			
		||||
    if cef_flag_file.exists():
 | 
			
		||||
        logger.info("CEF Remote Debugging is already enabled")
 | 
			
		||||
        return (True, "already_enabled")
 | 
			
		||||
    else:
 | 
			
		||||
        try:
 | 
			
		||||
            os.makedirs(cef_flag_file.parent, exist_ok=True)
 | 
			
		||||
            cef_flag_file.touch()
 | 
			
		||||
            logger.info("Enabled CEF Remote Debugging. Steam restart required")
 | 
			
		||||
            return (True, "restart_needed")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}")
 | 
			
		||||
            return (False, str(e))
 | 
			
		||||
 | 
			
		||||
def call_steam_api(js_cmd: str, *args) -> dict | None:
 | 
			
		||||
    """
 | 
			
		||||
    Executes a JavaScript function in the Steam context via CEF Remote Debugging.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        js_cmd: Name of the JS function to call (e.g., 'createShortcut').
 | 
			
		||||
        *args: Arguments to pass to the JS function.
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        Dictionary with the result or None if an error occurs.
 | 
			
		||||
    """
 | 
			
		||||
    status, message = enable_steam_cef()
 | 
			
		||||
    if not (status is True and message == "already_enabled"):
 | 
			
		||||
        if message == "restart_needed":
 | 
			
		||||
            logger.warning("Steam CEF API is available but requires Steam restart for full activation")
 | 
			
		||||
        elif message == "steam_not_found":
 | 
			
		||||
            logger.error("Could not find Steam directory to check CEF API")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.error(f"Steam CEF API is unavailable or not ready: {message}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    steam_debug_url = "http://localhost:8080/json"
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        response = requests.get(steam_debug_url, timeout=2)
 | 
			
		||||
        response.raise_for_status()
 | 
			
		||||
        contexts = response.json()
 | 
			
		||||
        ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
 | 
			
		||||
        if not ws_url:
 | 
			
		||||
            logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?")
 | 
			
		||||
            return None
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    js_code = """
 | 
			
		||||
        async function createShortcut(name, exe, dir, icon, args) {
 | 
			
		||||
            const id = await SteamClient.Apps.AddShortcut(name, exe, dir, args);
 | 
			
		||||
            console.log("Shortcut created with ID:", id);
 | 
			
		||||
            await SteamClient.Apps.SetShortcutName(id, name);
 | 
			
		||||
            if (icon)
 | 
			
		||||
                await SteamClient.Apps.SetShortcutIcon(id, icon);
 | 
			
		||||
            if (args)
 | 
			
		||||
                await SteamClient.Apps.SetAppLaunchOptions(id, args);
 | 
			
		||||
            return { id };
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        async function setGrid(id, i, ext, image) {
 | 
			
		||||
            await SteamClient.Apps.SetCustomArtworkForApp(id, image, ext, i);
 | 
			
		||||
            return true;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        async function removeShortcut(id) {
 | 
			
		||||
            await SteamClient.Apps.RemoveShortcut(+id);
 | 
			
		||||
            return true;
 | 
			
		||||
        };
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        ws = websocket.create_connection(ws_url, timeout=5)
 | 
			
		||||
        js_args = ", ".join(orjson.dumps(arg).decode('utf-8') for arg in args)
 | 
			
		||||
        expression = f"{js_code} {js_cmd}({js_args});"
 | 
			
		||||
        payload = {
 | 
			
		||||
            "id": random.randint(0, 32767),
 | 
			
		||||
            "method": "Runtime.evaluate",
 | 
			
		||||
            "params": {
 | 
			
		||||
                "expression": expression,
 | 
			
		||||
                "awaitPromise": True,
 | 
			
		||||
                "returnByValue": True
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ws.send(orjson.dumps(payload))
 | 
			
		||||
        response_str = ws.recv()
 | 
			
		||||
        ws.close()
 | 
			
		||||
 | 
			
		||||
        response_data = orjson.loads(response_str)
 | 
			
		||||
        if "error" in response_data:
 | 
			
		||||
            logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}")
 | 
			
		||||
            return None
 | 
			
		||||
        result = response_data.get('result', {}).get('result', {})
 | 
			
		||||
        if result.get('type') == 'object' and result.get('subtype') == 'error':
 | 
			
		||||
            logger.error(f"JavaScript execution error in Steam: {result.get('description')}")
 | 
			
		||||
            return None
 | 
			
		||||
        return result.get('value')
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"WebSocket interaction error with Steam: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
 | 
			
		||||
    """
 | 
			
		||||
    Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
 | 
			
		||||
@@ -777,7 +1004,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
 | 
			
		||||
        return (False, f"Executable file not found: {exe_path}")
 | 
			
		||||
 | 
			
		||||
    portproton_dir = get_portproton_location()
 | 
			
		||||
    if not portproton_dir:
 | 
			
		||||
    start_sh = get_portproton_start_command()
 | 
			
		||||
    if not portproton_dir or not start_sh:
 | 
			
		||||
        logger.error("PortProton directory not found")
 | 
			
		||||
        return (False, "PortProton directory not found")
 | 
			
		||||
 | 
			
		||||
@@ -786,17 +1014,12 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
 | 
			
		||||
 | 
			
		||||
    safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
 | 
			
		||||
    script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
 | 
			
		||||
    start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
 | 
			
		||||
 | 
			
		||||
    if not os.path.exists(start_sh_path):
 | 
			
		||||
        logger.error(f"start.sh not found at {start_sh_path}")
 | 
			
		||||
        return (False, f"start.sh not found at {start_sh_path}")
 | 
			
		||||
 | 
			
		||||
    if not os.path.exists(script_path):
 | 
			
		||||
        script_content = f"""#!/usr/bin/env bash
 | 
			
		||||
export LD_PRELOAD=
 | 
			
		||||
export START_FROM_STEAM=1
 | 
			
		||||
"{start_sh_path}" "{exe_path}" "$@"
 | 
			
		||||
"{start_sh}" "{exe_path}" "$@"
 | 
			
		||||
"""
 | 
			
		||||
        try:
 | 
			
		||||
            with open(script_path, "w", encoding="utf-8") as f:
 | 
			
		||||
@@ -819,24 +1042,24 @@ export START_FROM_STEAM=1
 | 
			
		||||
        else:
 | 
			
		||||
            success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
 | 
			
		||||
            if not success or not os.path.exists(generated_icon_path):
 | 
			
		||||
                logger.warning(f"generate_thumbnail failed to create icon for {exe_path}")
 | 
			
		||||
                logger.warning(f"Failed to generate thumbnail for {exe_path}")
 | 
			
		||||
                icon_path = ""
 | 
			
		||||
            else:
 | 
			
		||||
                logger.info(f"Generated thumbnail: {generated_icon_path}")
 | 
			
		||||
        icon_path = generated_icon_path
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Error generating thumbnail for {exe_path}: {e}")
 | 
			
		||||
        logger.error(f"Failed to generate thumbnail for {exe_path}: {e}")
 | 
			
		||||
        icon_path = ""
 | 
			
		||||
 | 
			
		||||
    steam_home = get_steam_home()
 | 
			
		||||
    if not steam_home:
 | 
			
		||||
        logger.error("Steam home directory not found")
 | 
			
		||||
        return (False, "Steam directory not found.")
 | 
			
		||||
        return (False, "Steam directory not found")
 | 
			
		||||
 | 
			
		||||
    last_user = get_last_steam_user(steam_home)
 | 
			
		||||
    if not last_user or 'SteamID' not in last_user:
 | 
			
		||||
        logger.error("Failed to retrieve Steam user ID")
 | 
			
		||||
        return (False, "Failed to get Steam user ID.")
 | 
			
		||||
        return (False, "Failed to get Steam user ID")
 | 
			
		||||
 | 
			
		||||
    userdata_dir = steam_home / "userdata"
 | 
			
		||||
    user_id = last_user['SteamID']
 | 
			
		||||
@@ -846,45 +1069,42 @@ export START_FROM_STEAM=1
 | 
			
		||||
    grid_dir = user_dir / "config" / "grid"
 | 
			
		||||
    os.makedirs(grid_dir, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
            logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
            return (False, f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
    appid = None
 | 
			
		||||
    was_api_used = False
 | 
			
		||||
 | 
			
		||||
    unique_string = f"{script_path}{game_name}"
 | 
			
		||||
    baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
    appid = baseid | 0x80000000
 | 
			
		||||
    if appid > 0x7FFFFFFF:
 | 
			
		||||
        aidvdf = appid - 0x100000000
 | 
			
		||||
    logger.info("Attempting to add shortcut via Steam CEF API")
 | 
			
		||||
    api_response = call_steam_api(
 | 
			
		||||
        "createShortcut",
 | 
			
		||||
        game_name,
 | 
			
		||||
        script_path,
 | 
			
		||||
        str(Path(script_path).parent),
 | 
			
		||||
        icon_path,
 | 
			
		||||
        ""
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if api_response and isinstance(api_response, dict) and 'id' in api_response:
 | 
			
		||||
        appid = api_response['id']
 | 
			
		||||
        was_api_used = True
 | 
			
		||||
        logger.info(f"Shortcut successfully added via API. AppID: {appid}")
 | 
			
		||||
    else:
 | 
			
		||||
        aidvdf = appid
 | 
			
		||||
        logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf")
 | 
			
		||||
        backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
        if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
            try:
 | 
			
		||||
                shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
                logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
                return (False, f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
    downloaded_count = 0
 | 
			
		||||
    total_covers = 4  # количество обложек
 | 
			
		||||
        unique_string = f"{script_path}{game_name}"
 | 
			
		||||
        baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
        appid = baseid | 0x80000000
 | 
			
		||||
        if appid > 0x7FFFFFFF:
 | 
			
		||||
            aidvdf = appid - 0x100000000
 | 
			
		||||
        else:
 | 
			
		||||
            aidvdf = appid
 | 
			
		||||
 | 
			
		||||
    download_lock = threading.Lock()
 | 
			
		||||
 | 
			
		||||
    def on_cover_download(cover_file: str, cover_type: str):
 | 
			
		||||
        nonlocal downloaded_count
 | 
			
		||||
        try:
 | 
			
		||||
            if cover_file and os.path.exists(cover_file):
 | 
			
		||||
                logger.info(f"Downloaded cover {cover_type} to {cover_file}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
 | 
			
		||||
        with download_lock:
 | 
			
		||||
            downloaded_count += 1
 | 
			
		||||
            if downloaded_count == total_covers:
 | 
			
		||||
                finalize_shortcut()
 | 
			
		||||
 | 
			
		||||
    def finalize_shortcut():
 | 
			
		||||
        tags_dict = {'0': 'PortProton'}
 | 
			
		||||
        shortcut = {
 | 
			
		||||
            "appid": aidvdf,
 | 
			
		||||
            "AppName": game_name,
 | 
			
		||||
@@ -899,7 +1119,7 @@ export START_FROM_STEAM=1
 | 
			
		||||
            "Devkit": 0,
 | 
			
		||||
            "DevkitGameID": "",
 | 
			
		||||
            "LastPlayTime": 0,
 | 
			
		||||
            "tags": tags_dict
 | 
			
		||||
            "tags": {'0': 'PortProton'}
 | 
			
		||||
        }
 | 
			
		||||
        logger.info(f"Shortcut entry to be written: {shortcut}")
 | 
			
		||||
 | 
			
		||||
@@ -929,6 +1149,7 @@ export START_FROM_STEAM=1
 | 
			
		||||
 | 
			
		||||
            with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
                vdf.binary_dump({"shortcuts": shortcuts}, f)
 | 
			
		||||
            logger.info(f"Game '{game_name}' successfully added to Steam with covers")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
            if os.path.exists(backup_path):
 | 
			
		||||
@@ -937,34 +1158,54 @@ export START_FROM_STEAM=1
 | 
			
		||||
                    logger.info("Restored shortcuts.vdf from backup due to update failure")
 | 
			
		||||
                except Exception as restore_err:
 | 
			
		||||
                    logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
 | 
			
		||||
            return (False, f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
            appid = None
 | 
			
		||||
 | 
			
		||||
        logger.info(f"Game '{game_name}' successfully added to Steam with covers")
 | 
			
		||||
        return (True, f"Game '{game_name}' added to Steam with covers")
 | 
			
		||||
    if not appid:
 | 
			
		||||
        return (False, "Failed to create shortcut using any method")
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
 | 
			
		||||
    def on_game_info(game_info: dict):
 | 
			
		||||
        nonlocal steam_appid
 | 
			
		||||
        steam_appid = game_info.get("appid")
 | 
			
		||||
        if not steam_appid or not isinstance(steam_appid, int):
 | 
			
		||||
            logger.info("No valid Steam appid found, skipping cover download")
 | 
			
		||||
            return finalize_shortcut()
 | 
			
		||||
            return
 | 
			
		||||
        logger.info(f"Found Steam AppID {steam_appid} for cover download")
 | 
			
		||||
 | 
			
		||||
        # Обложки и имена, соответствующие bash-скрипту и твоим размерам
 | 
			
		||||
        cover_types = [
 | 
			
		||||
            (".jpg", "header.jpg"),              # базовый, сохранится как AppId.jpg
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg"), # сохранится как AppIdp.jpg
 | 
			
		||||
            ("_hero.jpg", "library_hero.jpg"),   # AppId_hero.jpg
 | 
			
		||||
            ("_logo.png", "logo.png")            # AppId_logo.png
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg"),
 | 
			
		||||
            ("_hero.jpg", "library_hero.jpg"),
 | 
			
		||||
            ("_logo.png", "logo.png"),
 | 
			
		||||
            (".jpg", "header.jpg")
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for suffix, cover_type in cover_types:
 | 
			
		||||
        def on_cover_download(result_path: str | None, steam_name: str, index: int):
 | 
			
		||||
            try:
 | 
			
		||||
                if result_path and os.path.exists(result_path):
 | 
			
		||||
                    logger.info(f"Downloaded cover {steam_name} to {result_path}")
 | 
			
		||||
                    if was_api_used:
 | 
			
		||||
                        try:
 | 
			
		||||
                            with open(result_path, 'rb') as f:
 | 
			
		||||
                                img_b64 = base64.b64encode(f.read()).decode('utf-8')
 | 
			
		||||
                            logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
 | 
			
		||||
                            ext = Path(steam_name).suffix.lstrip('.')
 | 
			
		||||
                            call_steam_api("setGrid", appid, index, ext, img_b64)
 | 
			
		||||
                        except Exception as e:
 | 
			
		||||
                            logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
 | 
			
		||||
 | 
			
		||||
        for i, (suffix, steam_name) in enumerate(cover_types):
 | 
			
		||||
            cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
 | 
			
		||||
            cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
 | 
			
		||||
            cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
 | 
			
		||||
            downloader.download_async(
 | 
			
		||||
                cover_url,
 | 
			
		||||
                cover_file,
 | 
			
		||||
                timeout=5,
 | 
			
		||||
                callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
 | 
			
		||||
                callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    get_steam_game_info_async(game_name, exec_line, on_game_info)
 | 
			
		||||
@@ -996,13 +1237,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
 | 
			
		||||
    steam_home = get_steam_home()
 | 
			
		||||
    if not steam_home:
 | 
			
		||||
        logger.error("Steam home directory not found")
 | 
			
		||||
        return (False, "Steam directory not found.")
 | 
			
		||||
        return (False, "Steam directory not found")
 | 
			
		||||
 | 
			
		||||
    # Get current Steam user ID
 | 
			
		||||
    last_user = get_last_steam_user(steam_home)
 | 
			
		||||
    if not last_user or 'SteamID' not in last_user:
 | 
			
		||||
        logger.error("Failed to retrieve Steam user ID")
 | 
			
		||||
        return (False, "Failed to get Steam user ID.")
 | 
			
		||||
        return (False, "Failed to get Steam user ID")
 | 
			
		||||
    userdata_dir = steam_home / "userdata"
 | 
			
		||||
    user_id = last_user['SteamID']
 | 
			
		||||
    unsigned_id = convert_steam_id(user_id)
 | 
			
		||||
@@ -1017,19 +1258,7 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
 | 
			
		||||
        logger.info(f"shortcuts.vdf not found at {steam_shortcuts_path}")
 | 
			
		||||
        return (False, f"Game '{game_name}' not found in Steam")
 | 
			
		||||
 | 
			
		||||
    # Generate appid for identifying cover files
 | 
			
		||||
    unique_string = f"{script_path}{game_name}"
 | 
			
		||||
    baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
    appid = baseid | 0x80000000
 | 
			
		||||
 | 
			
		||||
    # Create backup of shortcuts.vdf
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    try:
 | 
			
		||||
        shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
        logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
        return (False, f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
    appid = None
 | 
			
		||||
 | 
			
		||||
    # Load and modify shortcuts.vdf
 | 
			
		||||
    try:
 | 
			
		||||
@@ -1043,37 +1272,51 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
 | 
			
		||||
        return (False, f"Failed to load shortcuts.vdf: {load_err}")
 | 
			
		||||
 | 
			
		||||
    shortcuts = shortcuts_data.get("shortcuts", {})
 | 
			
		||||
    found = False
 | 
			
		||||
    new_shortcuts = {}
 | 
			
		||||
    index = 0
 | 
			
		||||
 | 
			
		||||
    # Filter out the matching shortcut
 | 
			
		||||
    for _key, entry in shortcuts.items():
 | 
			
		||||
        if entry.get("AppName") == game_name and entry.get("Exe") == f'"{script_path}"':
 | 
			
		||||
            found = True
 | 
			
		||||
            appid = convert_steam_id(int(entry.get("appid")))
 | 
			
		||||
            logger.info(f"Found matching shortcut for '{game_name}' to remove")
 | 
			
		||||
            continue
 | 
			
		||||
        new_shortcuts[str(index)] = entry
 | 
			
		||||
        index += 1
 | 
			
		||||
 | 
			
		||||
    if not found:
 | 
			
		||||
    if not appid:
 | 
			
		||||
        logger.info(f"Game '{game_name}' not found in Steam shortcuts")
 | 
			
		||||
        return (False, f"Game '{game_name}' not found in Steam")
 | 
			
		||||
 | 
			
		||||
    # Save updated shortcuts.vdf
 | 
			
		||||
    try:
 | 
			
		||||
        with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
            vdf.binary_dump({"shortcuts": new_shortcuts}, f)
 | 
			
		||||
        logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
        if os.path.exists(backup_path):
 | 
			
		||||
            try:
 | 
			
		||||
                shutil.copy2(backup_path, steam_shortcuts_path)
 | 
			
		||||
                logger.info("Restored shortcuts.vdf from backup due to update failure")
 | 
			
		||||
            except Exception as restore_err:
 | 
			
		||||
                logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
 | 
			
		||||
        return (False, f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
    api_response = call_steam_api("removeShortcut", appid)
 | 
			
		||||
    if api_response is not None: # API responded, even if response is empty
 | 
			
		||||
        logger.info(f"Shortcut for AppID {appid} successfully removed via API")
 | 
			
		||||
    else:
 | 
			
		||||
        logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf")
 | 
			
		||||
 | 
			
		||||
        # Create backup of shortcuts.vdf
 | 
			
		||||
        backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
            logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
            return (False, f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
 | 
			
		||||
        # Save updated shortcuts.vdf
 | 
			
		||||
        try:
 | 
			
		||||
            with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
                vdf.binary_dump({"shortcuts": new_shortcuts}, f)
 | 
			
		||||
            logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
            if os.path.exists(backup_path):
 | 
			
		||||
                try:
 | 
			
		||||
                    shutil.copy2(backup_path, steam_shortcuts_path)
 | 
			
		||||
                    logger.info("Restored shortcuts.vdf from backup due to update failure")
 | 
			
		||||
                except Exception as restore_err:
 | 
			
		||||
                    logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
 | 
			
		||||
            return (False, f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
 | 
			
		||||
    # Delete cover files
 | 
			
		||||
    cover_files = [
 | 
			
		||||
@@ -1128,5 +1371,5 @@ def is_game_in_steam(game_name: str) -> bool:
 | 
			
		||||
            if entry.get("AppName") == game_name:
 | 
			
		||||
                return True
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Error checking if game {game_name} is in Steam: {e}")
 | 
			
		||||
        logger.error(f"Failed to check if game {game_name} is in Steam: {e}")
 | 
			
		||||
    return False
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,8 @@
 | 
			
		||||
import importlib.util
 | 
			
		||||
import os
 | 
			
		||||
import ast
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from PySide6.QtSvg import QSvgRenderer
 | 
			
		||||
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
 | 
			
		||||
 | 
			
		||||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
 | 
			
		||||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -14,6 +13,59 @@ THEMES_DIRS = [
 | 
			
		||||
    os.path.join(xdg_data_home, "PortProtonQt", "themes"),
 | 
			
		||||
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
 | 
			
		||||
]
 | 
			
		||||
_loaded_theme = None
 | 
			
		||||
 | 
			
		||||
# Запрещенные модули и функции
 | 
			
		||||
FORBIDDEN_MODULES = {
 | 
			
		||||
    "os",
 | 
			
		||||
    "subprocess",
 | 
			
		||||
    "shutil",
 | 
			
		||||
    "sys",
 | 
			
		||||
    "socket",
 | 
			
		||||
    "ctypes",
 | 
			
		||||
    "pathlib",
 | 
			
		||||
    "glob",
 | 
			
		||||
}
 | 
			
		||||
FORBIDDEN_FUNCTIONS = {
 | 
			
		||||
    "exec",
 | 
			
		||||
    "eval",
 | 
			
		||||
    "open",
 | 
			
		||||
    "__import__",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
def check_theme_safety(theme_file: str) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    Проверяет файл темы на наличие запрещённых модулей и функций.
 | 
			
		||||
    Возвращает True, если файл безопасен, иначе False.
 | 
			
		||||
    """
 | 
			
		||||
    has_errors = False
 | 
			
		||||
    try:
 | 
			
		||||
        with open(theme_file) as f:
 | 
			
		||||
            content = f.read()
 | 
			
		||||
 | 
			
		||||
            # Проверка на опасные импорты и функции
 | 
			
		||||
            try:
 | 
			
		||||
                tree = ast.parse(content)
 | 
			
		||||
                for node in ast.walk(tree):
 | 
			
		||||
                    # Проверка импортов
 | 
			
		||||
                    if isinstance(node, ast.Import | ast.ImportFrom):
 | 
			
		||||
                        for name in node.names:
 | 
			
		||||
                            if name.name in FORBIDDEN_MODULES:
 | 
			
		||||
                                logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
 | 
			
		||||
                                has_errors = True
 | 
			
		||||
                    # Проверка вызовов функций
 | 
			
		||||
                    if isinstance(node, ast.Call):
 | 
			
		||||
                        if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
 | 
			
		||||
                            logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
 | 
			
		||||
                            has_errors = True
 | 
			
		||||
            except SyntaxError as e:
 | 
			
		||||
                logger.error(f"Syntax error in file {theme_file}: {e}")
 | 
			
		||||
                has_errors = True
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to check theme safety for {theme_file}: {e}")
 | 
			
		||||
        has_errors = True
 | 
			
		||||
 | 
			
		||||
    return not has_errors
 | 
			
		||||
 | 
			
		||||
def list_themes():
 | 
			
		||||
    """
 | 
			
		||||
@@ -49,9 +101,13 @@ def load_theme_screenshots(theme_name):
 | 
			
		||||
 | 
			
		||||
def load_theme_fonts(theme_name):
 | 
			
		||||
    """
 | 
			
		||||
    Загружает все шрифты выбранной темы.
 | 
			
		||||
    :param theme_name: Имя темы.
 | 
			
		||||
    Загружает все шрифты выбранной темы, если они ещё не были загружены.
 | 
			
		||||
    """
 | 
			
		||||
    global _loaded_theme
 | 
			
		||||
    if _loaded_theme == theme_name:
 | 
			
		||||
        logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    QFontDatabase.removeAllApplicationFonts()
 | 
			
		||||
    fonts_folder = None
 | 
			
		||||
    if theme_name == "standart":
 | 
			
		||||
@@ -66,7 +122,7 @@ def load_theme_fonts(theme_name):
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
    if not fonts_folder or not os.path.exists(fonts_folder):
 | 
			
		||||
        logger.error(f"Папка fonts не найдена для темы '{theme_name}'")
 | 
			
		||||
        logger.error(f"Fonts folder not found for theme '{theme_name}'")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    for filename in os.listdir(fonts_folder):
 | 
			
		||||
@@ -75,29 +131,11 @@ def load_theme_fonts(theme_name):
 | 
			
		||||
            font_id = QFontDatabase.addApplicationFont(font_path)
 | 
			
		||||
            if font_id != -1:
 | 
			
		||||
                families = QFontDatabase.applicationFontFamilies(font_id)
 | 
			
		||||
                logger.info(f"Шрифт {filename} успешно загружен: {families}")
 | 
			
		||||
                logger.info(f"Font {filename} successfully loaded: {families}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.error(f"Ошибка загрузки шрифта: {filename}")
 | 
			
		||||
                logger.error(f"Error loading font: {filename}")
 | 
			
		||||
 | 
			
		||||
def load_logo():
 | 
			
		||||
    logo_path = None
 | 
			
		||||
 | 
			
		||||
    base_dir = os.path.dirname(os.path.abspath(__file__))
 | 
			
		||||
    logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg")
 | 
			
		||||
 | 
			
		||||
    file_extension = os.path.splitext(logo_path)[1].lower()
 | 
			
		||||
 | 
			
		||||
    if file_extension == ".svg":
 | 
			
		||||
        renderer = QSvgRenderer(logo_path)
 | 
			
		||||
        if not renderer.isValid():
 | 
			
		||||
            logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}")
 | 
			
		||||
            return None
 | 
			
		||||
        pixmap = QPixmap(128, 128)
 | 
			
		||||
        pixmap.fill(QColor(0, 0, 0, 0))
 | 
			
		||||
        painter = QPainter(pixmap)
 | 
			
		||||
        renderer.render(painter)
 | 
			
		||||
        painter.end()
 | 
			
		||||
        return pixmap
 | 
			
		||||
    _loaded_theme = theme_name
 | 
			
		||||
 | 
			
		||||
class ThemeWrapper:
 | 
			
		||||
    """
 | 
			
		||||
@@ -109,69 +147,83 @@ class ThemeWrapper:
 | 
			
		||||
        self.custom_theme = custom_theme
 | 
			
		||||
        self.metainfo = metainfo or {}
 | 
			
		||||
        self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
 | 
			
		||||
        self._default_theme = None  # Lazy-loaded default theme
 | 
			
		||||
 | 
			
		||||
    def __getattr__(self, name):
 | 
			
		||||
        if hasattr(self.custom_theme, name):
 | 
			
		||||
            return getattr(self.custom_theme, name)
 | 
			
		||||
        import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
        return getattr(default_styles, name)
 | 
			
		||||
        if self._default_theme is None:
 | 
			
		||||
            self._default_theme = load_theme("standart")  # Dynamically load standard theme
 | 
			
		||||
        return getattr(self._default_theme, name)
 | 
			
		||||
 | 
			
		||||
def load_theme(theme_name):
 | 
			
		||||
    """
 | 
			
		||||
    Динамически загружает модуль стилей выбранной темы и метаинформацию.
 | 
			
		||||
    Если выбрана стандартная тема, импортируется оригинальный styles.py.
 | 
			
		||||
    Все темы, включая стандартную, проходят проверку безопасности.
 | 
			
		||||
    Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
 | 
			
		||||
    """
 | 
			
		||||
    if theme_name == "standart":
 | 
			
		||||
        import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
        return default_styles
 | 
			
		||||
 | 
			
		||||
    for themes_dir in THEMES_DIRS:
 | 
			
		||||
        theme_folder = os.path.join(themes_dir, theme_name)
 | 
			
		||||
        styles_file = os.path.join(theme_folder, "styles.py")
 | 
			
		||||
        if os.path.exists(styles_file):
 | 
			
		||||
            # Проверяем безопасность темы перед загрузкой
 | 
			
		||||
            if not check_theme_safety(styles_file):
 | 
			
		||||
                logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'")
 | 
			
		||||
                raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions")
 | 
			
		||||
 | 
			
		||||
            spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
 | 
			
		||||
            if spec is None or spec.loader is None:
 | 
			
		||||
                continue
 | 
			
		||||
            custom_theme = importlib.util.module_from_spec(spec)
 | 
			
		||||
            spec.loader.exec_module(custom_theme)
 | 
			
		||||
            if theme_name == "standart":
 | 
			
		||||
                return custom_theme
 | 
			
		||||
            meta = load_theme_metainfo(theme_name)
 | 
			
		||||
            wrapper = ThemeWrapper(custom_theme, metainfo=meta)
 | 
			
		||||
            wrapper.screenshots = load_theme_screenshots(theme_name)
 | 
			
		||||
            return wrapper
 | 
			
		||||
    raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'")
 | 
			
		||||
    raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
 | 
			
		||||
 | 
			
		||||
class ThemeManager:
 | 
			
		||||
    """
 | 
			
		||||
    Класс для управления темами приложения.
 | 
			
		||||
 | 
			
		||||
    Позволяет получить список доступных тем, загрузить и применить выбранную тему.
 | 
			
		||||
    Реализует паттерн Singleton для единого экземпляра.
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.current_theme_name = None
 | 
			
		||||
        self.current_theme_module = None
 | 
			
		||||
    _instance = None
 | 
			
		||||
 | 
			
		||||
    def get_available_themes(self):
 | 
			
		||||
    def __new__(cls):
 | 
			
		||||
        if cls._instance is None:
 | 
			
		||||
            cls._instance = super().__new__(cls)
 | 
			
		||||
            cls._instance.current_theme_name = None
 | 
			
		||||
            cls._instance.current_theme_module = None
 | 
			
		||||
        return cls._instance
 | 
			
		||||
 | 
			
		||||
    def get_available_themes(self) -> list:
 | 
			
		||||
        """Возвращает список доступных тем."""
 | 
			
		||||
        return list_themes()
 | 
			
		||||
 | 
			
		||||
    def get_theme_logo(self):
 | 
			
		||||
        """Возвращает логотип для текущей или указанной темы."""
 | 
			
		||||
        return load_logo()
 | 
			
		||||
    def apply_theme(self, theme_name: str):
 | 
			
		||||
        """
 | 
			
		||||
        Применяет указанную тему, если она ещё не применена.
 | 
			
		||||
        Возвращает модуль темы или обёртку.
 | 
			
		||||
        """
 | 
			
		||||
        if self.current_theme_name == theme_name and self.current_theme_module is not None:
 | 
			
		||||
            logger.debug(f"Theme '{theme_name}' is already applied, skipping")
 | 
			
		||||
            return self.current_theme_module
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            theme_module = load_theme(theme_name)
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
 | 
			
		||||
            theme_module = load_theme("standart")
 | 
			
		||||
            theme_name = "standart"
 | 
			
		||||
            save_theme_to_config("standart")
 | 
			
		||||
 | 
			
		||||
    def apply_theme(self, theme_name):
 | 
			
		||||
        """
 | 
			
		||||
        Применяет выбранную тему: загружает модуль стилей, шрифты и логотип.
 | 
			
		||||
        Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации.
 | 
			
		||||
        :param theme_name: Имя темы.
 | 
			
		||||
        :return: Загруженный модуль темы (или обёртка).
 | 
			
		||||
        """
 | 
			
		||||
        theme_module = load_theme(theme_name)
 | 
			
		||||
        load_theme_fonts(theme_name)
 | 
			
		||||
        self.current_theme_name = theme_name
 | 
			
		||||
        self.current_theme_module = theme_module
 | 
			
		||||
        save_theme_to_config(theme_name)
 | 
			
		||||
        logger.info(f"Тема '{theme_name}' успешно применена")
 | 
			
		||||
        logger.info(f"Theme '{theme_name}' successfully applied")
 | 
			
		||||
        return theme_module
 | 
			
		||||
 | 
			
		||||
    def get_icon(self, icon_name, theme_name=None, as_path=False):
 | 
			
		||||
@@ -226,7 +278,7 @@ class ThemeManager:
 | 
			
		||||
 | 
			
		||||
        # Если иконка всё равно не найдена
 | 
			
		||||
        if not icon_path or not os.path.exists(icon_path):
 | 
			
		||||
            logger.error(f"Предупреждение: иконка '{icon_name}' не найдена")
 | 
			
		||||
            logger.error(f"Warning: icon '{icon_name}' not found")
 | 
			
		||||
            return QIcon() if not as_path else None
 | 
			
		||||
 | 
			
		||||
        if as_path:
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 734 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 213 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 622 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 164 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 570 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 367 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 2.3 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 392 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 660 B  | 
| 
		 Before Width: | Height: | Size: 7.9 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.7 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.3 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 208 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 165 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848t-1.5848 3.8596q-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 717 B  | 
| 
		 Before Width: | Height: | Size: 1.1 KiB  | 
| 
		 Before Width: | Height: | Size: 1.6 MiB  | 
| 
		 Before Width: | Height: | Size: 475 KiB  |