Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9bb7e45b27
|
|||
|
59093f743c
|
|||
|
a7c8977dab
|
|||
|
ff744fc581
|
|||
| 05de549d07 | |||
|
3e74cbdcf5
|
|||
|
a9b97e3a4b
|
|||
|
b9fe0250ed
|
|||
| 4dcfca919f | |||
|
66c23db29c
|
|||
|
e7a7300665
|
|||
|
2521f7d2f4
|
|||
| 5df0b8783f | |||
|
044ea7d151
|
|||
|
cd93f9ebfe
|
|||
|
1b9595ca95
|
|||
|
|
4dff545c0f | ||
|
69d8e53c7b
|
|||
|
|
40769bfdf6 | ||
|
|
b3adef68d3 | ||
|
|
df707a84bc | ||
|
|
4c340c13ab | ||
| a81cef4457 | |||
|
4c537248f1
|
|||
|
55dcda738b
|
|||
|
aa0c0a5675
|
|||
|
613b28a751
|
|||
|
a9e9f4e4e3
|
|||
|
61c59814a5
|
|||
|
80d3b69311
|
|||
|
ac09ac1e36
|
|||
|
7cdc7264cd
|
|||
|
94f61b1124
|
|||
|
58bbff8e69
|
|||
|
|
6457084d56 | ||
|
|
3c83a90721 | ||
|
|
c76b80586a | ||
|
b30ade6e1e
|
|||
|
7a5b467490
|
|||
|
6f82068864
|
|||
|
d4672ecb0e
|
|||
|
|
087ac8eda2 | ||
|
|
0a9acaf5da | ||
|
d0fad6a3c9
|
|||
|
468887110c
|
|||
|
32e4950a00
|
|||
|
b16074fa5c
|
|||
|
1bd7c23419
|
|||
|
f4275dd465
|
|||
|
c8b91c4687
|
|||
|
4aaeb2e809
|
|||
|
b6ea9350fa
|
|||
|
29d25cec01
|
|||
|
a634de5462
|
|||
|
1ba1781994
|
|||
|
0aae292f61
|
|||
|
3ef433af0c
|
|||
|
|
9fe33e02d8 | ||
|
2ac91a759d
|
|||
|
2c82bff204
|
|||
|
0889aa883e
|
|||
|
7780dcfc4d
|
|||
|
9ef39ae2b6
|
|||
|
86fb2b2d7c
|
|||
| 9d469f0a12 | |||
|
665a4df322
|
|||
|
3abaccb1e0
|
|||
|
77b025f580
|
|||
|
|
42e2025e54 | ||
|
|
8f84bbce31 | ||
|
3026e7da4e
|
|||
|
3522764c3e
|
|||
|
fd456e5330
|
|||
|
99a963d60c
|
|||
|
0b36e73bce
|
|||
|
4baa2e8684
|
|||
|
4344bbca70
|
|||
|
0a8a290d2d
|
|||
|
92652e8faa
|
|||
|
4f2afaed24
|
|||
|
1751e01e47
|
|||
|
0f74a47aed
|
|||
|
666ec654a0
|
|||
|
0c25cc9fd2
|
|||
|
5de83dbf49
|
|||
| 1821faadf6 | |||
|
17f0a6b0ea
|
|||
|
e9c75b998f
|
|||
|
bbfbc00c11
|
|||
|
b7804fdd01
|
|||
|
|
043da2cf5d | ||
|
2fa10e7db3
|
|||
|
b1b9706272
|
|||
|
9c11d33c0a
|
|||
|
173e1cb88e
|
|||
|
30606c7ec1
|
|||
|
873e8b050e
|
|||
|
59dad21945
|
|||
|
b0c4e943ae
|
|||
|
19e01bba17
|
|||
|
836e6cdd36
|
|||
|
b2a1046f9d
|
|||
|
80a2c06b5a
|
|||
|
f0a4ace735
|
|||
|
7dfaee6831
|
|||
|
5481cd80d7
|
|||
|
a016cfa810
|
|||
|
8fc097ccaf
|
|||
|
ad3eeb6e06
|
|||
|
92631cd2c6
|
|||
|
4477679f2d
|
|||
|
|
b6644eeee5
|
||
|
|
2e921226c4
|
||
|
|
4fc1ea73d3
|
||
|
|
3c15cbe495
|
||
|
fed6aafed5
|
|||
|
2e8be13437
|
|||
|
ea272c29b6
|
|||
|
17262f6c9f
|
|||
|
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
|
@@ -11,40 +11,34 @@ jobs:
|
|||||||
build-appimage:
|
build-appimage:
|
||||||
name: Build AppImage
|
name: Build AppImage
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
container:
|
||||||
|
image: archlinux:base-devel@sha256:f6b259c6c0cd1bc4c86510485acb6a5f053c15789c9c68c7434b6fe99564906c
|
||||||
|
options: --privileged --device /dev/fuse
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- name: Prepare container
|
||||||
|
|
||||||
- name: Install required dependencies
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
pacman-key --init
|
||||||
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
|
pacman -Sy --noconfirm archlinux-keyring
|
||||||
|
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
|
||||||
|
|
||||||
- name: Upgrade pip toolchain
|
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
run: |
|
|
||||||
python3 -m pip install --upgrade \
|
|
||||||
pip setuptools setuptools-scm wheel packaging build
|
|
||||||
|
|
||||||
- name: Install appimage-builder
|
- name: Install appimage dependencies
|
||||||
run: |
|
run: |
|
||||||
git clone https://github.com/Boria138/appimage-builder
|
cd build-aux/AppImage
|
||||||
cd appimage-builder
|
chmod +x get-dependencies.sh portprotonqt-appimage.sh
|
||||||
pip install .
|
./get-dependencies.sh --git
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: |
|
|
||||||
pip install uv
|
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
cd build-aux
|
cd build-aux/AppImage
|
||||||
sed -i '/app_info:/,/- exec:/ s/^\(\s*version:\s*\).*/\1"0"/' AppImageBuilder.yml
|
./portprotonqt-appimage.sh
|
||||||
appimage-builder
|
|
||||||
|
|
||||||
- name: Upload AppImage
|
- name: Upload AppImage
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: PortProtonQt-AppImage
|
name: PortProtonQt-AppImage
|
||||||
path: build-aux/PortProtonQt*.AppImage
|
path: build-aux/AppImage/dist/*.AppImage
|
||||||
|
|
||||||
build-fedora:
|
build-fedora:
|
||||||
name: Build Fedora RPM
|
name: Build Fedora RPM
|
||||||
@@ -62,7 +56,7 @@ jobs:
|
|||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
|
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
|
redhat-rpm-config nodejs npm
|
||||||
|
|
||||||
- name: Setup rpmbuild environment
|
- name: Setup rpmbuild environment
|
||||||
@@ -73,7 +67,7 @@ jobs:
|
|||||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||||
|
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Copy fedora.spec
|
- name: Copy fedora.spec
|
||||||
run: |
|
run: |
|
||||||
@@ -94,16 +88,12 @@ jobs:
|
|||||||
name: Build Arch Package
|
name: Build Arch Package
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
container:
|
container:
|
||||||
image: archlinux:base-devel@sha256:5d95edcb6e10fd865e827e93749ecd425f5056880a5a1d8971f5f2a96c7b5a9a
|
image: archlinux:base-devel@sha256:f6b259c6c0cd1bc4c86510485acb6a5f053c15789c9c68c7434b6fe99564906c
|
||||||
volumes:
|
|
||||||
- /usr:/usr-host
|
|
||||||
- /opt:/opt-host
|
|
||||||
options: --privileged
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare container
|
- name: Prepare container
|
||||||
run: |
|
run: |
|
||||||
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
pacman -Syuu --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/#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
|
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||||
yes | pacman -Scc
|
yes | pacman -Scc
|
||||||
@@ -134,7 +124,7 @@ jobs:
|
|||||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Upload Arch package
|
- name: Upload Arch package
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Common version, will be used for tagging the release
|
# Common version, will be used for tagging the release
|
||||||
VERSION: 0.1.6
|
VERSION: 0.1.9
|
||||||
PKGDEST: "/tmp/portprotonqt"
|
PKGDEST: "/tmp/portprotonqt"
|
||||||
PACKAGE: "portprotonqt"
|
PACKAGE: "portprotonqt"
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -17,54 +17,45 @@ jobs:
|
|||||||
build-appimage:
|
build-appimage:
|
||||||
name: Build AppImage
|
name: Build AppImage
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
container:
|
||||||
|
image: archlinux:base-devel
|
||||||
|
options: --privileged --device /dev/fuse
|
||||||
steps:
|
steps:
|
||||||
|
- name: Prepare container
|
||||||
|
run: |
|
||||||
|
pacman-key --init
|
||||||
|
pacman -Sy --noconfirm archlinux-keyring
|
||||||
|
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
|
||||||
|
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install required dependencies
|
- name: Install appimage dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
cd build-aux/AppImage
|
||||||
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
|
chmod +x get-dependencies.sh portprotonqt-appimage.sh
|
||||||
|
./get-dependencies.sh
|
||||||
- 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
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
cd build-aux
|
cd build-aux/AppImage
|
||||||
appimage-builder
|
./portprotonqt-appimage.sh
|
||||||
|
|
||||||
- name: Upload AppImage
|
- name: Upload AppImage
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: PortProtonQt-AppImage
|
name: PortProtonQt-AppImage
|
||||||
path: build-aux/PortProtonQt*.AppImage*
|
path: build-aux/AppImage/dist/*.AppImage
|
||||||
|
|
||||||
build-arch:
|
build-arch:
|
||||||
name: Build Arch Package
|
name: Build Arch Package
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
container:
|
container:
|
||||||
image: archlinux:base-devel
|
image: archlinux:base-devel
|
||||||
volumes:
|
|
||||||
- /usr:/usr-host
|
|
||||||
- /opt:/opt-host
|
|
||||||
options: --privileged
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare container
|
- name: Prepare container
|
||||||
run: |
|
run: |
|
||||||
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
pacman -Syuu --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/#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
|
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||||
yes | pacman -Scc
|
yes | pacman -Scc
|
||||||
@@ -119,7 +110,7 @@ jobs:
|
|||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
|
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
|
redhat-rpm-config nodejs npm
|
||||||
|
|
||||||
- name: Setup rpmbuild environment
|
- name: Setup rpmbuild environment
|
||||||
@@ -180,10 +171,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
|
||||||
with:
|
with:
|
||||||
body_path: changelog.txt
|
body_path: changelog.txt
|
||||||
token: ${{ env.GITEA_TOKEN }}
|
token: ${{ env.GITEA_TOKEN }}
|
||||||
tag_name: v${{ env.VERSION }}
|
tag_name: v${{ env.VERSION }}
|
||||||
prerelease: true
|
prerelease: true
|
||||||
files: release/**/*
|
files: release/**/*
|
||||||
sha256sum: true
|
sha256sum: false
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-translations:
|
check-translations:
|
||||||
if: false
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
|
||||||
with:
|
with:
|
||||||
python-version-file: "pyproject.toml"
|
python-version-file: "pyproject.toml"
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
fedora: ${{ steps.check.outputs.fedora }}
|
fedora: ${{ steps.check.outputs.fedora }}
|
||||||
arch: ${{ steps.check.outputs.arch }}
|
arch: ${{ steps.check.outputs.arch }}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
cat changed_files.txt
|
cat changed_files.txt
|
||||||
|
|
||||||
# Check AppImage files
|
# Check AppImage files
|
||||||
if grep -q "build-aux/AppImageBuilder.yml" changed_files.txt; then
|
if grep -q "build-aux/AppImage/" changed_files.txt; then
|
||||||
echo "appimage=true" >> $GITHUB_OUTPUT
|
echo "appimage=true" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo "appimage=false" >> $GITHUB_OUTPUT
|
echo "appimage=false" >> $GITHUB_OUTPUT
|
||||||
@@ -62,29 +62,34 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
|
container:
|
||||||
|
image: archlinux:base-devel@sha256:f6b259c6c0cd1bc4c86510485acb6a5f053c15789c9c68c7434b6fe99564906c
|
||||||
|
options: --privileged --device /dev/fuse
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- name: Prepare container
|
||||||
|
|
||||||
- name: Install required dependencies
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
pacman-key --init
|
||||||
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
|
pacman -Sy --noconfirm --disable-download-timeout --needed archlinux-keyring
|
||||||
|
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
|
||||||
|
|
||||||
- name: Install tools
|
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
|
- name: Install appimage dependencies
|
||||||
run: |
|
run: |
|
||||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
cd build-aux/AppImage
|
||||||
pip3 install uv
|
chmod +x get-dependencies.sh portprotonqt-appimage.sh
|
||||||
|
./get-dependencies.sh
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
cd build-aux
|
cd build-aux/AppImage
|
||||||
appimage-builder
|
./portprotonqt-appimage.sh
|
||||||
|
|
||||||
- name: Upload AppImage
|
- name: Upload AppImage
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: PortProtonQt-AppImage
|
name: PortProtonQt-AppImage
|
||||||
path: build-aux/PortProtonQt*.AppImage
|
path: build-aux/AppImage/dist/*.AppImage
|
||||||
|
|
||||||
build-fedora:
|
build-fedora:
|
||||||
name: Build Fedora RPM
|
name: Build Fedora RPM
|
||||||
@@ -115,7 +120,7 @@ jobs:
|
|||||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||||
|
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Copy fedora-git.spec
|
- name: Copy fedora-git.spec
|
||||||
run: |
|
run: |
|
||||||
@@ -138,11 +143,7 @@ jobs:
|
|||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
container:
|
container:
|
||||||
image: archlinux:base-devel@sha256:5d95edcb6e10fd865e827e93749ecd425f5056880a5a1d8971f5f2a96c7b5a9a
|
image: archlinux:base-devel@sha256:f6b259c6c0cd1bc4c86510485acb6a5f053c15789c9c68c7434b6fe99564906c
|
||||||
volumes:
|
|
||||||
- /usr:/usr-host
|
|
||||||
- /opt:/opt-host
|
|
||||||
options: --privileged
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare container
|
- name: Prepare container
|
||||||
@@ -178,7 +179,7 @@ jobs:
|
|||||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Upload Arch package
|
- name: Upload Arch package
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ jobs:
|
|||||||
name: Check code
|
name: Check code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: https://gitea.com/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
|
||||||
with:
|
with:
|
||||||
python-version-file: "pyproject.toml"
|
python-version-file: "pyproject.toml"
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
renovate:
|
renovate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: ghcr.io/renovatebot/renovate:latest@sha256:e459af116e0cb6c7d5094c0dd4c999d4335d948324192902125b7aff91601a00
|
container: ghcr.io/renovatebot/renovate:latest@sha256:eec497df1ca6ebe8bccf577c5dab8825ab5f3673a42a58f066e31dbf070664e6
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: https://gitea.com/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ repos:
|
|||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||||
rev: 0.8.22
|
rev: 0.9.5
|
||||||
hooks:
|
hooks:
|
||||||
- id: uv-lock
|
- id: uv-lock
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.13.2
|
rev: v0.14.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
|
|
||||||
|
|||||||
73
CHANGELOG.md
@@ -3,21 +3,91 @@
|
|||||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.1.9] - 2025-12-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Добавлены основные и расширенные настройки для `.exe`-файлов
|
||||||
|
- Добавлена кнопка обновления сетки без необходимости перезапуска PortProtonQt (F5 на клавиатуре, GUIDE + Select на геймпаде)
|
||||||
|
- Добавлена эмуляция мыши по GUIDE (Xbox или PS) + Start для установки приложений или взаимодействия с инструментами Wine не адаптированные под геймпад (работает только если PortProtonQt вне фокуса)
|
||||||
|
- При сворачивании приложения в трей оно теперь корректно восстанавливается, вместо запуска нового экземпляра
|
||||||
|
- Добавлена поддержка SteamGridDB в качестве дополнительного источника обложек
|
||||||
|
- При добавлении карточки в избранное она автоматически становится первой без необходимости перезапуска
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Изменено оформление виртуальной клавиатуры для лучшего соответствия общей теме
|
||||||
|
- Ускорено чтение конфигов за счёт уменьшения количества обращений к файловой системе.
|
||||||
|
- Из стандартной темы удалены неиспользуемые шрифты
|
||||||
|
- Улучшена совместимость с Qt 6.10
|
||||||
|
- Ускорен запуск программы
|
||||||
|
- В диалог редактирования ярылыка добавлен placeholder с уточнением того что в качевстве обложки можно использовать и ссылку, а не только файл
|
||||||
|
- Ссылку на обложку в диалоге редактирования ярлыка теперь можно указывать без протокола вроде http или https
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Добавлено больше проверок на None для избежания вылетов
|
||||||
|
- Улучшена работа с потоками для избежания вылетов
|
||||||
|
- Исправлен запуск PortProton из Flatpak: теперь используется `flatpak run`, а не `start.sh`
|
||||||
|
- Исправлено применение обложки по ссылке например со steamgriddb.com/
|
||||||
|
- Исправлено множественное открытие окон в X11
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
- @Vector_null
|
||||||
|
- @Dervart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.8] - 2025-10-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
|
||||||
|
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
|
||||||
|
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
|
||||||
|
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- При завершении автоустановки приложение больше не перезапускается
|
||||||
|
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
|
||||||
|
- Обновлены и дополнены скриншоты темы
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Исправлено наложение карточек при смене фильтра игр
|
||||||
|
- Исправлена невозможность запуска приложения без подключёного геймпада
|
||||||
|
- Исправлена невозможность установки компонентов Winetricks через геймпад
|
||||||
|
- Ресиверы и виртуальные устройства больше не считаются за геймпад
|
||||||
|
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
- @Vector_null
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.7] - 2025-10-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Возможность скроллинга библиотеки мышью или пальцем
|
- Возможность скроллинга библиотеки мышью или пальцем
|
||||||
|
- Импорт и экспорт бекапа префикса
|
||||||
|
- Диалог для управление Winetricks
|
||||||
|
- Кнопки для удаления префикса, wine или proton
|
||||||
|
- Все настройки Wine с оригинального PortProton
|
||||||
|
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
|
||||||
|
- Вкладка автоустановок
|
||||||
|
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
|
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
|
||||||
|
- В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
|
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
|
||||||
- Исправлено зависание при добавлении или удалении игры в Wayland
|
- Исправлено зависание при добавлении или удалении игры в Wayland
|
||||||
- Исправлено зависание при поиске игр
|
- Исправлено зависание при поиске игр
|
||||||
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
|
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
|
||||||
|
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
|
||||||
|
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
|
||||||
|
- При сохранении настроек теперь не меняется размер окна
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
|
- @wmigor (Igor Akulov)
|
||||||
|
- @Vector_null
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,6 +110,7 @@
|
|||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
- @wmigor (Igor Akulov)
|
- @wmigor (Igor Akulov)
|
||||||
|
- @Vector_null
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
15
TODO.md
@@ -1,6 +1,6 @@
|
|||||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
|
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
|
||||||
- [X] Добавить возможность управления с геймпада
|
- [X] Добавить возможность управления с геймпада
|
||||||
- [ ] Добавить возможность управления с тачскрина
|
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
|
||||||
- [X] Добавить возможность управления с мыши и клавиатуры
|
- [X] Добавить возможность управления с мыши и клавиатуры
|
||||||
- [X] Добавить систему тем [Документация](documentation/theme_guide)
|
- [X] Добавить систему тем [Документация](documentation/theme_guide)
|
||||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
|
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
|
||||||
@@ -11,18 +11,18 @@
|
|||||||
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
|
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
|
||||||
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
|
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
|
||||||
- [X] Получать описания и названия игр из базы данных Steam
|
- [X] Получать описания и названия игр из базы данных Steam
|
||||||
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
|
- [X] Получать обложки для игр из CDN Steam
|
||||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
|
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
|
||||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
|
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
|
||||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
||||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
||||||
- [X] Избавиться от вызовов yad
|
- [X] Избавиться от вызовов yad
|
||||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
||||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
|
- [X] Добавить экранную клавиатуру в поиск
|
||||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
||||||
- [X] Добавить индикацию запуска приложения
|
- [X] Добавить индикацию запуска приложения
|
||||||
- [X] Достигнуть паритета функциональности с Ingame
|
- [X] Достигнуть паритета функциональности с Ingame
|
||||||
- [ ] Достигнуть паритета функциональности с PortProton
|
- [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов)
|
||||||
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
|
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
|
||||||
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
|
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
|
||||||
- [X] Добавить переводы в переопределения
|
- [X] Добавить переводы в переопределения
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
|
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
|
||||||
- [X] Добавить систему избранного для карточек
|
- [X] Добавить систему избранного для карточек
|
||||||
- [X] Заменить все `print` на `logging`
|
- [X] Заменить все `print` на `logging`
|
||||||
- [ ] Привести все логи к единому языку
|
- [X] Привести все логи к единому языку
|
||||||
- [X] Уменьшить количество подстановок в переводах
|
- [X] Уменьшить количество подстановок в переводах
|
||||||
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
|
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
|
||||||
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
|
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
|
||||||
@@ -62,7 +62,6 @@
|
|||||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
||||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
||||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||||
- [ ] Доделать светлую тему
|
- [X] Добавить подсказки к управлению с геймпада
|
||||||
- [ ] Добавить подсказки к управлению с геймпада
|
|
||||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
||||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
||||||
|
|||||||
55
build-aux/AppImage/get-dependencies.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Determine if git mode is enabled based on the first argument
|
||||||
|
if [ "${1:-}" = "--git" ] || [ "${1:-}" = "-g" ]; then
|
||||||
|
GIT_MODE=true
|
||||||
|
else
|
||||||
|
GIT_MODE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
PACKAGE_BUILDER="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/make-aur-package.sh"
|
||||||
|
EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/get-debloated-pkgs.sh"
|
||||||
|
|
||||||
|
if [ "$GIT_MODE" = true ]; then
|
||||||
|
echo "Using git version of PortProtonQt..."
|
||||||
|
PPQT_PKGBUILD="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/build-aux/PKGBUILD-git"
|
||||||
|
else
|
||||||
|
echo "Using stable version of PortProtonQt..."
|
||||||
|
PPQT_PKGBUILD="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/build-aux/PKGBUILD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
echo "---------------------------------------------------------------"
|
||||||
|
pacman-key --init
|
||||||
|
pacman -Syy --needed --noconfirm archlinux-keyring
|
||||||
|
|
||||||
|
echo "Installing AUR packages..."
|
||||||
|
echo "---------------------------------------------------------------"
|
||||||
|
wget --retry-connrefused --tries=30 "$PACKAGE_BUILDER" -O ./make-aur-package.sh
|
||||||
|
chmod +x ./make-aur-package.sh
|
||||||
|
|
||||||
|
./make-aur-package.sh --chaotic-aur icoextract
|
||||||
|
./make-aur-package.sh --chaotic-aur python-vdf
|
||||||
|
|
||||||
|
echo "Building PortProtonQt from PKGBUILD..."
|
||||||
|
echo "---------------------------------------------------------------"
|
||||||
|
wget --retry-connrefused --tries=30 "$PPQT_PKGBUILD" -O ./PKGBUILD
|
||||||
|
makepkg -si --noconfirm
|
||||||
|
|
||||||
|
echo "Installing debloated packages..."
|
||||||
|
echo "---------------------------------------------------------------"
|
||||||
|
wget --retry-connrefused --tries=30 "$EXTRA_PACKAGES" -O ./get-debloated-pkgs.sh
|
||||||
|
chmod +x ./get-debloated-pkgs.sh
|
||||||
|
./get-debloated-pkgs.sh --add-common --prefer-nano
|
||||||
|
|
||||||
|
if [ "$GIT_MODE" = true ]; then
|
||||||
|
# For git version, we use portprotonqt-git
|
||||||
|
pacman -Q portprotonqt-git | awk '{print $2}' | cut -d- -f1 > ~/version
|
||||||
|
else
|
||||||
|
# For stable version, we use portprotonqt
|
||||||
|
pacman -Q portprotonqt | awk '{print $2}' | cut -d- -f1 > ~/version
|
||||||
|
fi
|
||||||
42
build-aux/AppImage/portprotonqt-appimage.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SHARUN="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/quick-sharun.sh"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
VERSION="$(cat ~/version)"
|
||||||
|
export ARCH VERSION
|
||||||
|
export OUTPATH=./dist
|
||||||
|
export DESKTOP=/usr/share/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||||
|
export ICON=/usr/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||||
|
export OUTNAME=PortProtonQt-"$VERSION"-anylinux-"$ARCH".AppImage
|
||||||
|
export DEPLOY_OPENGL=1
|
||||||
|
export DEPLOY_SYS_PYTHON=1
|
||||||
|
export OPTIMIZE_LAUNCH=1
|
||||||
|
|
||||||
|
# Adjust comp settings to bypass oom-killer
|
||||||
|
export DWARFS_COMP="zstd:level=15 -S22 -B5"
|
||||||
|
|
||||||
|
# DEPLOY ALL LIBS
|
||||||
|
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
|
||||||
|
chmod +x ./quick-sharun
|
||||||
|
|
||||||
|
# Add udev rules
|
||||||
|
mkdir -p ./AppDir/etc/udev/rules.d
|
||||||
|
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
|
||||||
|
|
||||||
|
# Deploy Qt translations
|
||||||
|
mkdir -p ./AppDir/usr/share/qt6/translations
|
||||||
|
cp -r /usr/share/qt6/translations/* ./AppDir/usr/share/qt6/translations/
|
||||||
|
|
||||||
|
# Deploy dependencies
|
||||||
|
# Qt libs have to be passed manually due to the app being a python script
|
||||||
|
./quick-sharun \
|
||||||
|
/usr/bin/portprotonqt* \
|
||||||
|
/usr/lib/libQt6Core.so* \
|
||||||
|
/usr/lib/libQt6Gui.so* \
|
||||||
|
/usr/lib/libQt6Network.so* \
|
||||||
|
/usr/lib/libudev.so*
|
||||||
|
|
||||||
|
# Turn AppDir into AppImage
|
||||||
|
./quick-sharun --make-appimage
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
version: 1
|
|
||||||
script:
|
|
||||||
- rm -rf AppDir || true
|
|
||||||
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
|
|
||||||
- uv venv
|
|
||||||
- uv pip install --no-cache-dir ../
|
|
||||||
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
|
|
||||||
- cp -r share 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/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,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*|libQt6DBus*|libQt6Egl*|libQt6Gui*|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.6
|
|
||||||
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-minimal
|
|
||||||
- python3-pkg-resources
|
|
||||||
- libopengl0
|
|
||||||
- libk5crypto3
|
|
||||||
- libkrb5-3
|
|
||||||
- libgssapi-krb5-2
|
|
||||||
- libxcb-cursor0
|
|
||||||
- libimage-exiftool-perl
|
|
||||||
- xdg-utils
|
|
||||||
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
|
pkgname=portprotonqt
|
||||||
pkgver=0.1.6
|
pkgver=0.1.9
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||||
license=('GPL-3.0')
|
license=('GPL-3.0')
|
||||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
depends=('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-websocket-client')
|
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
@@ -20,4 +20,5 @@ package() {
|
|||||||
cd "$srcdir/PortProtonQt"
|
cd "$srcdir/PortProtonQt"
|
||||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||||
cp -r build-aux/share "$pkgdir/usr/"
|
cp -r build-aux/share "$pkgdir/usr/"
|
||||||
|
cp -r build-aux/lib "$pkgdir/usr/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and
|
|||||||
arch=('any')
|
arch=('any')
|
||||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||||
license=('GPL-3.0')
|
license=('GPL-3.0')
|
||||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
depends=('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-websocket-client')
|
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'icoextract' 'python-pillow' 'python-rapidfuzz' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
@@ -25,4 +25,5 @@ package() {
|
|||||||
cd "$srcdir/PortProtonQt"
|
cd "$srcdir/PortProtonQt"
|
||||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||||
cp -r build-aux/share "$pkgdir/usr/"
|
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: pyproject-rpm-macros
|
||||||
BuildRequires: python3dist(setuptools)
|
BuildRequires: python3dist(setuptools)
|
||||||
BuildRequires: git
|
BuildRequires: git
|
||||||
|
BuildRequires: systemd-rpm-macros
|
||||||
|
|
||||||
%description
|
%description
|
||||||
%{summary}
|
%{summary}
|
||||||
@@ -32,7 +33,6 @@ Summary: %{summary}
|
|||||||
Requires: python3-babel
|
Requires: python3-babel
|
||||||
Requires: python3-evdev
|
Requires: python3-evdev
|
||||||
Requires: python3-icoextract
|
Requires: python3-icoextract
|
||||||
Requires: python3-numpy
|
|
||||||
Requires: python3-websocket-client
|
Requires: python3-websocket-client
|
||||||
Requires: python3-orjson
|
Requires: python3-orjson
|
||||||
Requires: python3-psutil
|
Requires: python3-psutil
|
||||||
@@ -43,9 +43,16 @@ Requires: python3-tqdm
|
|||||||
Requires: python3-vdf
|
Requires: python3-vdf
|
||||||
Requires: python3-pefile
|
Requires: python3-pefile
|
||||||
Requires: python3-pillow
|
Requires: python3-pillow
|
||||||
|
Requires: python3-beautifulsoup4
|
||||||
|
Requires: python3-rapidfuzz
|
||||||
|
Requires: python3-libarchive-c
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
Requires: python3-beautifulsoup4
|
Requires: cabextract
|
||||||
|
Requires: gzip
|
||||||
|
Requires: unzip
|
||||||
|
Requires: curl
|
||||||
|
Requires: unrar
|
||||||
|
|
||||||
%description -n python3-%{pypi_name}-git
|
%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.
|
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.
|
||||||
@@ -64,11 +71,13 @@ cd %{oname}
|
|||||||
%pyproject_install
|
%pyproject_install
|
||||||
%pyproject_save_files %{pypi_name}
|
%pyproject_save_files %{pypi_name}
|
||||||
cp -r build-aux/share %{buildroot}/usr/
|
cp -r build-aux/share %{buildroot}/usr/
|
||||||
|
cp -r build-aux/lib %{buildroot}/usr/
|
||||||
|
|
||||||
%files -n python3-%{pypi_name}-git -f %{pyproject_files}
|
%files -n python3-%{pypi_name}-git -f %{pyproject_files}
|
||||||
%{_bindir}/%{pypi_name}
|
%{_bindir}/%{pypi_name}
|
||||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
|
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
|
||||||
|
%{_udevrulesdir}/60-portprotonqt.rules
|
||||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||||
%{bash_completions_dir}/portprotonqt
|
%{bash_completions_dir}/portprotonqt
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
%global pypi_name portprotonqt
|
%global pypi_name portprotonqt
|
||||||
%global pypi_version 0.1.6
|
%global pypi_version 0.1.9
|
||||||
%global oname PortProtonQt
|
%global oname PortProtonQt
|
||||||
%global _python_no_extras_requires 1
|
%global _python_no_extras_requires 1
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ BuildRequires: python3-build
|
|||||||
BuildRequires: pyproject-rpm-macros
|
BuildRequires: pyproject-rpm-macros
|
||||||
BuildRequires: python3dist(setuptools)
|
BuildRequires: python3dist(setuptools)
|
||||||
BuildRequires: git
|
BuildRequires: git
|
||||||
|
BuildRequires: systemd-rpm-macros
|
||||||
|
|
||||||
%description
|
%description
|
||||||
%{summary}
|
%{summary}
|
||||||
@@ -29,7 +30,6 @@ Summary: %{summary}
|
|||||||
Requires: python3-babel
|
Requires: python3-babel
|
||||||
Requires: python3-evdev
|
Requires: python3-evdev
|
||||||
Requires: python3-icoextract
|
Requires: python3-icoextract
|
||||||
Requires: python3-numpy
|
|
||||||
Requires: python3-websocket-client
|
Requires: python3-websocket-client
|
||||||
Requires: python3-orjson
|
Requires: python3-orjson
|
||||||
Requires: python3-psutil
|
Requires: python3-psutil
|
||||||
@@ -40,9 +40,16 @@ Requires: python3-tqdm
|
|||||||
Requires: python3-vdf
|
Requires: python3-vdf
|
||||||
Requires: python3-pefile
|
Requires: python3-pefile
|
||||||
Requires: python3-pillow
|
Requires: python3-pillow
|
||||||
|
Requires: python3-beautifulsoup4
|
||||||
|
Requires: python3-rapidfuzz
|
||||||
|
Requires: python3-libarchive-c
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
Requires: python3-beautifulsoup4
|
Requires: cabextract
|
||||||
|
Requires: gzip
|
||||||
|
Requires: unzip
|
||||||
|
Requires: curl
|
||||||
|
Requires: unrar
|
||||||
|
|
||||||
%description -n python3-%{pypi_name}
|
%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.
|
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_install
|
||||||
%pyproject_save_files %{pypi_name}
|
%pyproject_save_files %{pypi_name}
|
||||||
cp -r build-aux/share %{buildroot}/usr/
|
cp -r build-aux/share %{buildroot}/usr/
|
||||||
|
cp -r build-aux/lib %{buildroot}/usr/
|
||||||
|
|
||||||
%files -n python3-%{pypi_name} -f %{pyproject_files}
|
%files -n python3-%{pypi_name} -f %{pyproject_files}
|
||||||
%{_bindir}/%{pypi_name}
|
%{_bindir}/%{pypi_name}
|
||||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
|
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
|
||||||
|
%{_udevrulesdir}/60-portprotonqt.rules
|
||||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||||
%{bash_completions_dir}/portprotonqt
|
%{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"
|
||||||
@@ -1021,7 +1021,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "farlight 84",
|
"normalized_name": "farlight 84",
|
||||||
"status": "Supported"
|
"status": "Denied"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "riders republic",
|
"normalized_name": "riders republic",
|
||||||
@@ -1373,7 +1373,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "arena breakout infinite",
|
"normalized_name": "arena breakout infinite",
|
||||||
"status": "Broken"
|
"status": "Denied"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "pixel gun 3d pc",
|
"normalized_name": "pixel gun 3d pc",
|
||||||
@@ -1436,8 +1436,8 @@
|
|||||||
"status": "Broken"
|
"status": "Broken"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "blue protocol",
|
"normalized_name": "blue protocol star resonance",
|
||||||
"status": "Broken"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "dark and darker",
|
"normalized_name": "dark and darker",
|
||||||
@@ -2097,7 +2097,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "breachers",
|
"normalized_name": "breachers",
|
||||||
"status": "Running"
|
"status": "Denied"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "line of sight",
|
"normalized_name": "line of sight",
|
||||||
@@ -2153,7 +2153,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "ghosts of tabor",
|
"normalized_name": "ghosts of tabor",
|
||||||
"status": "Broken"
|
"status": "Denied"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "undawn",
|
"normalized_name": "undawn",
|
||||||
@@ -3801,7 +3801,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "phantasy star online 2 new genesis",
|
"normalized_name": "phantasy star online 2 new genesis",
|
||||||
"status": "Broken"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "fortress forever",
|
"normalized_name": "fortress forever",
|
||||||
@@ -4316,7 +4316,7 @@
|
|||||||
"status": "Broken"
|
"status": "Broken"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "solo leveling arise",
|
"normalized_name": "solo leveling arise overdrive",
|
||||||
"status": "Running"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4425,7 +4425,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "carx street",
|
"normalized_name": "carx street",
|
||||||
"status": "Broken"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "warcos 2",
|
"normalized_name": "warcos 2",
|
||||||
@@ -4505,7 +4505,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "redmatch 2",
|
"normalized_name": "redmatch 2",
|
||||||
"status": "Broken"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "blade & soul heroes",
|
"normalized_name": "blade & soul heroes",
|
||||||
@@ -4527,10 +4527,6 @@
|
|||||||
"normalized_name": "project wraith",
|
"normalized_name": "project wraith",
|
||||||
"status": "Broken"
|
"status": "Broken"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_name": "solo leveling arise",
|
|
||||||
"status": "Broken"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_name": "freedom wars",
|
"normalized_name": "freedom wars",
|
||||||
"status": "Running"
|
"status": "Running"
|
||||||
@@ -4542,5 +4538,129 @@
|
|||||||
{
|
{
|
||||||
"normalized_name": "no more room in hell 2",
|
"normalized_name": "no more room in hell 2",
|
||||||
"status": "Running"
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "call of duty black ops 7",
|
||||||
|
"status": "Denied"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "skate.",
|
||||||
|
"status": "Denied"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "wildgate",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "fellowship",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "dragon ball xenoverse",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "king of meat",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "last flag",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "skidrush",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "nosgoth",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "counter strike online 2",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "game of thrones kingsroad",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "vindictus defying fate",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "gears of war reloaded",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "swords & soldiers",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "super people (2025)",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "afk journey",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "the midnight walkers",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "異世界∞異世界 ~次はどの作品を、集めよう~",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "chrono odyssey",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "madoka magica magia exedra",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "pubg black budget",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "sniper elite resistance",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "gigantic",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "team fortress 2 classified",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "panzer arena coop",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "girls' frontline",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "battlefield redsec",
|
||||||
|
"status": "Denied"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "evolve stage 2",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "aura kingdom impact",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "risk your life",
|
||||||
|
"status": "Broken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "forefront",
|
||||||
|
"status": "Denied"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
36482
data/games_appid.json
@@ -1,4 +1,356 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"normalized_title": "back to the future the game",
|
||||||
|
"slug": "back-to-the-future-the-game"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "resident evil revelations 2",
|
||||||
|
"slug": "resident-evil-revelations-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "hi fi rush",
|
||||||
|
"slug": "hi-fi-rush"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "medal of honor warfighter",
|
||||||
|
"slug": "medal-of-honor-warfighter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "medal of honor",
|
||||||
|
"slug": "medal-of-honor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "will rock",
|
||||||
|
"slug": "will-rock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "beyond good & evil",
|
||||||
|
"slug": "beyond-good-evil"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "industry giant 2",
|
||||||
|
"slug": "industry-giant-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "rise of the tomb raider 20 year celebration",
|
||||||
|
"slug": "rise-of-the-tomb-raider-20-year-celebration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "need for speed underground",
|
||||||
|
"slug": "need-for-speed-underground"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "deus ex 2 invisible war",
|
||||||
|
"slug": "deus-ex-2-invisible-war"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "lords of the fallen game of the year 2014",
|
||||||
|
"slug": "lords-of-the-fallen-game-of-the-year-edition-2014"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "crysis 3",
|
||||||
|
"slug": "crysis-3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "south park the fractured but whole",
|
||||||
|
"slug": "south-park-the-fractured-but-whole"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "mount & blade ii bannerlord",
|
||||||
|
"slug": "mount-blade-ii-bannerlord"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "need for speed rivals",
|
||||||
|
"slug": "need-for-speed-rivals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "just cause 3",
|
||||||
|
"slug": "just-cause-3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "warhammer 40 000 boltgun",
|
||||||
|
"slug": "warhammer-40-000-boltgun"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "metal eden",
|
||||||
|
"slug": "metal-eden"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "dead cells",
|
||||||
|
"slug": "dead-cells"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "teardown",
|
||||||
|
"slug": "teardown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "hell is us",
|
||||||
|
"slug": "hell-is-us"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "alien breed impact",
|
||||||
|
"slug": "alien-breed-impact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "indiana jones and the great circle",
|
||||||
|
"slug": "indiana-jones-and-the-great-circle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "myst",
|
||||||
|
"slug": "myst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "warhammer 40 000 dawn of war",
|
||||||
|
"slug": "warhammer-40-000-dawn-of-war-definitive-edition"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "lego star wars iii the clone wars",
|
||||||
|
"slug": "lego-star-wars-iii-the-clone-wars"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "battlefield 4",
|
||||||
|
"slug": "battlefield-4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "bulletstorm full clip",
|
||||||
|
"slug": "bulletstorm-full-clip-edition"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "call of duty black ops ii",
|
||||||
|
"slug": "call-of-duty-black-ops-ii"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "battlefield 3",
|
||||||
|
"slug": "battlefield-3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "call of duty modern warfare 3 (2011)",
|
||||||
|
"slug": "call-of-duty-modern-warfare-3-2011"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "metal gear solid v the phantom pain",
|
||||||
|
"slug": "metal-gear-solid-v-the-phantom-pain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "battlefield bad company 2",
|
||||||
|
"slug": "battlefield-bad-company-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "call of duty black ops",
|
||||||
|
"slug": "call-of-duty-black-ops"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "call of duty modern warfare 2 (2009)",
|
||||||
|
"slug": "call-of-duty-modern-warfare-2-2009"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "call of duty black ops cold war",
|
||||||
|
"slug": "call-of-duty-black-ops-cold-war"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "call of duty infinite warfare",
|
||||||
|
"slug": "call-of-duty-infinite-warfare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "lost planet 2",
|
||||||
|
"slug": "lost-planet-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "lost planet extreme condition colonies",
|
||||||
|
"slug": "lost-planet-extreme-condition-colonies-edition"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "starcraft",
|
||||||
|
"slug": "starcraft-remastered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "the entropy centre",
|
||||||
|
"slug": "the-entropy-centre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "metal gear solid v ground zeroes",
|
||||||
|
"slug": "metal-gear-solid-v-ground-zeroes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "escape from tarkov",
|
||||||
|
"slug": "escape-from-tarkov"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "command & conquer generals",
|
||||||
|
"slug": "command-conquer-generals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "command & conquer generals zero hour",
|
||||||
|
"slug": "command-conquer-generals-zero-hour"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "absolum",
|
||||||
|
"slug": "absolum"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "tom clancy's splinter cell chaos theory",
|
||||||
|
"slug": "tom-clancys-splinter-cell-chaos-theory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "winter burrow",
|
||||||
|
"slug": "winter-burrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "forager",
|
||||||
|
"slug": "forager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "wall world",
|
||||||
|
"slug": "wall-world"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "grand theft auto iv the",
|
||||||
|
"slug": "grand-theft-auto-iv-the-complete-edition"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "voidtrain",
|
||||||
|
"slug": "voidtrain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "jdm japanese drift master",
|
||||||
|
"slug": "jdm-japanese-drift-master"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "lego harry potter collection",
|
||||||
|
"slug": "lego-harry-potter-collection"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "life is strange season",
|
||||||
|
"slug": "life-is-strange-complete-season"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "земский собор [демо]",
|
||||||
|
"slug": "zemskij-sobor-demo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "syberia",
|
||||||
|
"slug": "syberia-remastered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "europa universalis v",
|
||||||
|
"slug": "europa-universalis-v"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "no i'm not a human",
|
||||||
|
"slug": "no-im-not-a-human"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "dispatch digital deluxe",
|
||||||
|
"slug": "dispatch-digital-deluxe-edition"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "cossacks 3 digital deluxe",
|
||||||
|
"slug": "cossacks-3-digital-deluxe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "battlefield 2",
|
||||||
|
"slug": "battlefield-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "split/second",
|
||||||
|
"slug": "split-second"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "warzone 2100",
|
||||||
|
"slug": "warzone-2100"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "foundation",
|
||||||
|
"slug": "foundation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"normalized_title": "dirt rally 2.0 game of the year",
|
||||||
"slug": "dirt-rally-2-0-game-of-the-year-edition"
|
"slug": "dirt-rally-2-0-game-of-the-year-edition"
|
||||||
@@ -39,14 +391,6 @@
|
|||||||
"normalized_title": "far cry 5",
|
"normalized_title": "far cry 5",
|
||||||
"slug": "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",
|
"normalized_title": "old world",
|
||||||
"slug": "old-world"
|
"slug": "old-world"
|
||||||
@@ -271,10 +615,6 @@
|
|||||||
"normalized_title": "steins;gate the distant valhalla",
|
"normalized_title": "steins;gate the distant valhalla",
|
||||||
"slug": "steins-gate-the-distant-valhalla"
|
"slug": "steins-gate-the-distant-valhalla"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_title": "hogwarts legacy",
|
|
||||||
"slug": "hogwarts-legacy"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_title": "osu!",
|
"normalized_title": "osu!",
|
||||||
"slug": "osu"
|
"slug": "osu"
|
||||||
@@ -1059,10 +1399,6 @@
|
|||||||
"normalized_title": "mafia",
|
"normalized_title": "mafia",
|
||||||
"slug": "mafia-definitive-edition"
|
"slug": "mafia-definitive-edition"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_title": "teardown",
|
|
||||||
"slug": "teardown"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_title": "spellforce conquest of eo",
|
"normalized_title": "spellforce conquest of eo",
|
||||||
"slug": "spellforce-conquest-of-eo"
|
"slug": "spellforce-conquest-of-eo"
|
||||||
@@ -1311,10 +1647,6 @@
|
|||||||
"normalized_title": "world of sea battle",
|
"normalized_title": "world of sea battle",
|
||||||
"slug": "world-of-sea-battle"
|
"slug": "world-of-sea-battle"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_title": "escape from tarkov",
|
|
||||||
"slug": "escape-from-tarkov"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_title": "bayonetta",
|
"normalized_title": "bayonetta",
|
||||||
"slug": "bayonetta"
|
"slug": "bayonetta"
|
||||||
@@ -1439,10 +1771,6 @@
|
|||||||
"normalized_title": "call of duty 2",
|
"normalized_title": "call of duty 2",
|
||||||
"slug": "call-of-duty-2"
|
"slug": "call-of-duty-2"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_title": "call of duty infinite warfare",
|
|
||||||
"slug": "call-of-duty-infinite-warfare"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_title": "call of duty world at war",
|
"normalized_title": "call of duty world at war",
|
||||||
"slug": "call-of-duty-world-at-war"
|
"slug": "call-of-duty-world-at-war"
|
||||||
@@ -1635,10 +1963,6 @@
|
|||||||
"normalized_title": "elden ring",
|
"normalized_title": "elden ring",
|
||||||
"slug": "elden-ring"
|
"slug": "elden-ring"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_title": "starcraft",
|
|
||||||
"slug": "starcraft-remastered"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_title": "cataclismo",
|
"normalized_title": "cataclismo",
|
||||||
"slug": "cataclismo"
|
"slug": "cataclismo"
|
||||||
|
|||||||
@@ -20,3 +20,33 @@ Stop Game
|
|||||||
Fullscreen
|
Fullscreen
|
||||||
Fulscreen
|
Fulscreen
|
||||||
\t
|
\t
|
||||||
|
Горячая
|
||||||
|
vkbasalt
|
||||||
|
dgVoodoo2
|
||||||
|
Zink
|
||||||
|
Vulkan
|
||||||
|
VKD3D
|
||||||
|
DirectX12
|
||||||
|
Prev Dir
|
||||||
|
Forced
|
||||||
|
GOverlay
|
||||||
|
Glide
|
||||||
|
all
|
||||||
|
futex
|
||||||
|
DLSS
|
||||||
|
fullscreen
|
||||||
|
ProtonGE
|
||||||
|
window
|
||||||
|
compositing
|
||||||
|
Zink
|
||||||
|
Use
|
||||||
|
bundled
|
||||||
|
dxvk
|
||||||
|
older games
|
||||||
|
versions
|
||||||
|
DLL Overrides
|
||||||
|
COMP
|
||||||
|
VKD3D
|
||||||
|
Select needed
|
||||||
|
CPUs
|
||||||
|
cores
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
#!/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):
|
|
||||||
# Системные библиотеки, которые нужно всегда оставлять
|
|
||||||
self.system_libs = {
|
|
||||||
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
|
|
||||||
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus'
|
|
||||||
}
|
|
||||||
|
|
||||||
self.real_dependencies = {}
|
|
||||||
self.used_modules_code = set()
|
|
||||||
self.used_modules_ldd = set()
|
|
||||||
self.all_required_modules = set()
|
|
||||||
|
|
||||||
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 = {}
|
|
||||||
|
|
||||||
# Поиск в единственной локации
|
|
||||||
search_path = Path("../.venv/lib/python3.10/site-packages/PySide6")
|
|
||||||
print(f"Поиск PySide6 библиотек в: {search_path}")
|
|
||||||
|
|
||||||
if search_path.exists():
|
|
||||||
# Ищем .so файлы модулей
|
|
||||||
for so_file in search_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 search_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:
|
|
||||||
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)])
|
|
||||||
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
|
|
||||||
|
|
||||||
# Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir:
|
|
||||||
pattern = r'( # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n # [0-9]+\)|$)'
|
|
||||||
|
|
||||||
new_cleanup_block = " # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines)
|
|
||||||
|
|
||||||
updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
return updated_recipe
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
|
|
||||||
parser.add_argument('project_path', help='Путь к проекту для анализа')
|
|
||||||
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
|
|
||||||
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
|
|
||||||
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
project_path = Path(args.project_path)
|
|
||||||
if not project_path.exists():
|
|
||||||
print(f"Ошибка: путь {project_path} не существует")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
appdir_path = Path(args.appdir) if args.appdir else None
|
|
||||||
if appdir_path and not appdir_path.exists():
|
|
||||||
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
|
|
||||||
appdir_path = None
|
|
||||||
|
|
||||||
analyzer = PySide6DependencyAnalyzer()
|
|
||||||
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']:
|
|
||||||
Devlin(f"\nРеальные зависимости (ldd):")
|
|
||||||
for module, deps in results['real_dependencies'].items():
|
|
||||||
if deps:
|
|
||||||
print(f" {module} → {', '.join(deps)}")
|
|
||||||
|
|
||||||
# Обновляем AppImage рецепт
|
|
||||||
recipe_path = Path("../build-aux/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()
|
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import ast
|
|
||||||
|
# Import the security checker from the main module
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent)) # Add project root to path
|
||||||
|
from portprotonqt.theme_security import ThemeSecurityChecker
|
||||||
|
|
||||||
# Запрещенные QSS-свойства
|
# Запрещенные QSS-свойства
|
||||||
FORBIDDEN_PROPERTIES = {
|
FORBIDDEN_PROPERTIES = {
|
||||||
@@ -13,53 +16,25 @@ FORBIDDEN_PROPERTIES = {
|
|||||||
"text-shadow",
|
"text-shadow",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Запрещенные модули и функции
|
|
||||||
FORBIDDEN_MODULES = {
|
|
||||||
"os",
|
|
||||||
"subprocess",
|
|
||||||
"shutil",
|
|
||||||
"sys",
|
|
||||||
"socket",
|
|
||||||
"ctypes",
|
|
||||||
"pathlib",
|
|
||||||
"glob",
|
|
||||||
}
|
|
||||||
FORBIDDEN_FUNCTIONS = {
|
|
||||||
"exec",
|
|
||||||
"eval",
|
|
||||||
"open",
|
|
||||||
"__import__",
|
|
||||||
}
|
|
||||||
|
|
||||||
def check_qss_files():
|
def check_qss_files():
|
||||||
has_errors = False
|
has_errors = False
|
||||||
for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
|
for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
|
||||||
with open(qss_file, "r") as f:
|
# Check for forbidden QSS properties first
|
||||||
|
with open(qss_file, "r", encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
# Проверка на запрещённые QSS-свойства
|
|
||||||
for prop in FORBIDDEN_PROPERTIES:
|
for prop in FORBIDDEN_PROPERTIES:
|
||||||
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
|
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
|
||||||
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
|
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
|
||||||
has_errors = True
|
has_errors = True
|
||||||
|
|
||||||
# Проверка на опасные импорты и функции
|
# Use the imported ThemeSecurityChecker to check for dangerous imports and functions
|
||||||
try:
|
checker = ThemeSecurityChecker()
|
||||||
tree = ast.parse(content)
|
is_safe, errors = checker.check_theme_safety(str(qss_file))
|
||||||
for node in ast.walk(tree):
|
|
||||||
# Проверка импортов
|
if not is_safe:
|
||||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
for error in errors:
|
||||||
for name in node.names:
|
print(error)
|
||||||
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
|
has_errors = True
|
||||||
|
|
||||||
return has_errors
|
return has_errors
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ Current translation status:
|
|||||||
|
|
||||||
| Locale | Progress | Translated |
|
| Locale | Progress | Translated |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 375 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 375 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 of 193 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 375 of 375 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@
|
|||||||
|
|
||||||
| Локаль | Прогресс | Переведено |
|
| Локаль | Прогресс | Переведено |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 375 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 375 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 из 193 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 375 из 375 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,17 +31,49 @@ mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
|
|||||||
|
|
||||||
## 🎨 Style File (`styles.py`)
|
## 🎨 Style File (`styles.py`)
|
||||||
|
|
||||||
Create a `styles.py` in the theme root. It should define variables or functions that return CSS.
|
Create a `styles.py` in the theme root. It should define variables or functions that return QSS (Qt Style Sheets). For better organization, you can split your theme into multiple submodules by creating a subdirectory (e.g., `styles`, `components`, etc.) with separate Python files for different components, and import them in `styles.py`.
|
||||||
|
|
||||||
**Example:**
|
**Example of modular structure:**
|
||||||
|
```
|
||||||
|
my_custom_theme/
|
||||||
|
├── styles.py
|
||||||
|
├── metainfo.ini
|
||||||
|
├── fonts/
|
||||||
|
├── images/
|
||||||
|
└── styles/ # This can be named anything (e.g., components, modules, etc.)
|
||||||
|
├── __init__.py # This empty file makes the directory a Python package
|
||||||
|
├── constants.py
|
||||||
|
├── base.py
|
||||||
|
├── game_card.py
|
||||||
|
├── detail_page.py
|
||||||
|
├── settings.py
|
||||||
|
├── winetricks.py
|
||||||
|
└── theme_utils.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Main styles.py file:**
|
||||||
```python
|
```python
|
||||||
def custom_button_style(color1, color2):
|
# Import from the theme's submodules using absolute paths relative to the package
|
||||||
return f"""
|
# Replace 'my_custom_theme' with your actual theme folder name and 'styles' with your subdirectory name
|
||||||
QPushButton {{
|
from portprotonqt.themes.my_custom_theme.styles.constants import *
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
from portprotonqt.themes.my_custom_theme.styles.base import *
|
||||||
stop:0 {color1}, stop:1 {color2});
|
from portprotonqt.themes.my_custom_theme.styles.game_card import *
|
||||||
}}
|
from portprotonqt.themes.my_custom_theme.styles.detail_page import *
|
||||||
"""
|
from portprotonqt.themes.my_custom_theme.styles.settings import *
|
||||||
|
from portprotonqt.themes.my_custom_theme.styles.winetricks import *
|
||||||
|
from portprotonqt.themes.my_custom_theme.styles.theme_utils import *
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example submodule (styles/constants.py):**
|
||||||
|
```python
|
||||||
|
# Theme constants
|
||||||
|
font_family = "Play"
|
||||||
|
font_size_a = "16px"
|
||||||
|
font_size_b = "24px"
|
||||||
|
border_radius_a = "10px"
|
||||||
|
color_a = "#409EFF"
|
||||||
|
color_b = "#282a33"
|
||||||
|
# ... other constants
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -207,18 +239,52 @@ GAME_CARD_ANIMATION = {
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Metainfo]
|
[Metainfo]
|
||||||
name = My Custom Theme
|
name_en = My Custom Theme
|
||||||
|
name_ru = Моя пользовательская тема
|
||||||
author = Your Name
|
author = Your Name
|
||||||
author_link = https://example.com
|
author_link = https://example.com
|
||||||
description = Description of your theme.
|
description_en = Description of your theme.
|
||||||
|
description_ru = Описание вашей темы.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Translation Support
|
||||||
|
|
||||||
|
You must provide translations for your theme's name and description by adding language-specific fields:
|
||||||
|
- `name_en`, `name_ru`, etc. for theme names
|
||||||
|
- `description_en`, `description_ru`, etc. for theme descriptions
|
||||||
|
|
||||||
|
The application will automatically select the appropriate translation based on the user's system language, falling back to English if translations are not available for the user's language.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🖼 Screenshots
|
## 🖼 Screenshots
|
||||||
|
|
||||||
Folder: `images/screenshots/` — place UI screenshots there.
|
Folder: `images/screenshots/` — place UI screenshots there.
|
||||||
|
|
||||||
|
### Screenshot Translation Support
|
||||||
|
|
||||||
|
You can provide translations for screenshot captions by adding entries to the `[Screenshots]` section in your `metainfo.ini` file:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Screenshots]
|
||||||
|
auto_installs_en = Auto-installs
|
||||||
|
auto_installs_ru = Автоустановки
|
||||||
|
library_en = Library
|
||||||
|
library_ru = Библиотека
|
||||||
|
game_card_en = Game Card
|
||||||
|
game_card_ru = Карточка
|
||||||
|
context_menu_en = Context Menu
|
||||||
|
context_menu_ru = Контекстное меню
|
||||||
|
portproton_settings_en = PortProton Settings
|
||||||
|
portproton_settings_ru = Настройки PortProton
|
||||||
|
wine_settings_en = Wine Settings
|
||||||
|
wine_settings_ru = Настройки Wine
|
||||||
|
themes_en = Themes
|
||||||
|
themes_ru = Темы
|
||||||
|
```
|
||||||
|
|
||||||
|
Screenshot files should be named in English (without spaces), and the application will display the appropriate translated caption based on the user's system language, falling back to English if translations are not available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔡 Fonts and Icons (optional)
|
## 🔡 Fonts and Icons (optional)
|
||||||
|
|||||||
@@ -31,17 +31,49 @@ mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
|
|||||||
|
|
||||||
## 🎨 Файл стилей (`styles.py`)
|
## 🎨 Файл стилей (`styles.py`)
|
||||||
|
|
||||||
Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие CSS-оформление.
|
Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие QSS-оформление (Qt Style Sheets). Для лучшей организации кода, вы можете разделить тему на несколько подмодулей, создав поддиректорию (например, `styles`, `components` и т.д.) с отдельными Python-файлами для разных компонентов, и импортировать их в `styles.py`.
|
||||||
|
|
||||||
**Пример функции:**
|
**Пример модульной структуры:**
|
||||||
|
```
|
||||||
|
my_custom_theme/
|
||||||
|
├── styles.py
|
||||||
|
├── metainfo.ini
|
||||||
|
├── fonts/
|
||||||
|
├── images/
|
||||||
|
└── styles/ # Это может быть названо как угодно (например, components, modules и т.д.)
|
||||||
|
├── __init__.py # Этот пустой файл делает директорию Python-пакетом
|
||||||
|
├── constants.py
|
||||||
|
├── base.py
|
||||||
|
├── game_card.py
|
||||||
|
├── detail_page.py
|
||||||
|
├── settings.py
|
||||||
|
├── winetricks.py
|
||||||
|
└── theme_utils.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Основной файл styles.py:**
|
||||||
```python
|
```python
|
||||||
def custom_button_style(color1, color2):
|
# Импорт из подмодулей темы с использованием абсолютных путей относительно пакета
|
||||||
return f"""
|
# Замените 'my_custom_theme' на фактическое имя папки вашей темы и 'styles' на имя вашей поддиректории
|
||||||
QPushButton {{
|
from portprotonqt.themes.my_custom_theme.styles.constants import *
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
from portprotonqt.themes.my_custom_theme.styles.base import *
|
||||||
stop:0 {color1}, stop:1 {color2});
|
from portprotonqt.themes.my_custom_theme.styles.game_card import *
|
||||||
}}
|
from portprotonqt.themes.my_custom_theme.styles.detail_page import *
|
||||||
"""
|
from portprotonqt.themes.my_custom_theme.styles.settings import *
|
||||||
|
from portprotonqt.themes.my_custom_theme.styles.winetricks import *
|
||||||
|
from portprotonqt.themes.my_custom_theme.styles.theme_utils import *
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример подмодуля (styles/constants.py):**
|
||||||
|
```python
|
||||||
|
# Константы темы
|
||||||
|
font_family = "Play"
|
||||||
|
font_size_a = "16px"
|
||||||
|
font_size_b = "24px"
|
||||||
|
border_radius_a = "10px"
|
||||||
|
color_a = "#409EFF"
|
||||||
|
color_b = "#282a33"
|
||||||
|
# ... другие константы
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -207,18 +239,52 @@ GAME_CARD_ANIMATION = {
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Metainfo]
|
[Metainfo]
|
||||||
name = My Custom Theme
|
name_en = My Custom Theme
|
||||||
|
name_ru = Моя пользовательская тема
|
||||||
author = Ваше имя
|
author = Ваше имя
|
||||||
author_link = https://example.com
|
author_link = https://example.com
|
||||||
description = Описание вашей темы.
|
description_en = Description of your theme.
|
||||||
|
description_ru = Описание вашей темы.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Поддержка переводов
|
||||||
|
|
||||||
|
Вы должны предоставить переводы для названия и описания вашей темы, добавив поля с указанием языка:
|
||||||
|
- `name_en`, `name_ru` и т.д. для названий тем
|
||||||
|
- `description_en`, `description_ru` и т.д. для описаний тем
|
||||||
|
|
||||||
|
Приложение автоматически выберет соответствующий перевод на основе языка системы пользователя, с откатом к английскому языку, если переводы недоступны для языка пользователя.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🖼 Скриншоты
|
## 🖼 Скриншоты
|
||||||
|
|
||||||
Папка: `images/screenshots/` — любые изображения оформления темы.
|
Папка: `images/screenshots/` — любые изображения оформления темы.
|
||||||
|
|
||||||
|
### Поддержка перевода скриншотов
|
||||||
|
|
||||||
|
Вы можете предоставить переводы для подписей к скриншотам, добавив записи в секцию `[Screenshots]` в файле `metainfo.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Screenshots]
|
||||||
|
auto_installs_en = Auto-installs
|
||||||
|
auto_installs_ru = Автоустановки
|
||||||
|
library_en = Library
|
||||||
|
library_ru = Библиотека
|
||||||
|
game_card_en = Game Card
|
||||||
|
game_card_ru = Карточка
|
||||||
|
context_menu_en = Context Menu
|
||||||
|
context_menu_ru = Контекстное меню
|
||||||
|
portproton_settings_en = PortProton Settings
|
||||||
|
portproton_settings_ru = Настройки PortProton
|
||||||
|
wine_settings_en = Wine Settings
|
||||||
|
wine_settings_ru = Настройки Wine
|
||||||
|
themes_en = Themes
|
||||||
|
themes_ru = Темы
|
||||||
|
```
|
||||||
|
|
||||||
|
Файлы скриншотов должны быть названы на английском языке (без пробелов), и приложение будет отображать соответствующую переведенную подпись в зависимости от языка системы пользователя, с откатом к английскому языку, если переводы недоступны.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔡 Шрифты и иконки (опционально)
|
## 🔡 Шрифты и иконки (опционально)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from typing import Any, cast
|
||||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
|
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
|
||||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
||||||
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
|
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
|
||||||
@@ -32,6 +33,47 @@ class GameCardAnimations:
|
|||||||
self.pulse_anim: QPropertyAnimation | None = None
|
self.pulse_anim: QPropertyAnimation | None = None
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Clean up all animation objects to prevent memory leaks."""
|
||||||
|
if self.thickness_anim:
|
||||||
|
try:
|
||||||
|
self.thickness_anim.stop()
|
||||||
|
if self._isPulseAnimationConnected:
|
||||||
|
try:
|
||||||
|
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Signal was already disconnected
|
||||||
|
self.thickness_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.thickness_anim = None
|
||||||
|
|
||||||
|
if self.gradient_anim:
|
||||||
|
try:
|
||||||
|
self.gradient_anim.stop()
|
||||||
|
self.gradient_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.gradient_anim = None
|
||||||
|
|
||||||
|
if self.scale_anim:
|
||||||
|
try:
|
||||||
|
self.scale_anim.stop()
|
||||||
|
self.scale_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.scale_anim = None
|
||||||
|
|
||||||
|
if self.pulse_anim:
|
||||||
|
try:
|
||||||
|
self.pulse_anim.stop()
|
||||||
|
self.pulse_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.pulse_anim = None
|
||||||
|
|
||||||
|
self._isPulseAnimationConnected = False
|
||||||
|
|
||||||
def setup_animations(self):
|
def setup_animations(self):
|
||||||
"""Initialize animation properties based on theme."""
|
"""Initialize animation properties based on theme."""
|
||||||
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||||
@@ -49,8 +91,16 @@ class GameCardAnimations:
|
|||||||
"""Start pulse animation for border width when hovered or focused."""
|
"""Start pulse animation for border width when hovered or focused."""
|
||||||
if not (self.game_card._hovered or self.game_card._focused):
|
if not (self.game_card._hovered or self.game_card._focused):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Clean up existing pulse animation to prevent memory leaks
|
||||||
if self.pulse_anim:
|
if self.pulse_anim:
|
||||||
|
try:
|
||||||
self.pulse_anim.stop()
|
self.pulse_anim.stop()
|
||||||
|
self.pulse_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.pulse_anim = None
|
||||||
|
|
||||||
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||||
self.pulse_anim.setLoopCount(0)
|
self.pulse_anim.setLoopCount(0)
|
||||||
@@ -73,7 +123,10 @@ class GameCardAnimations:
|
|||||||
if self.thickness_anim:
|
if self.thickness_anim:
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
|
try:
|
||||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Signal was already disconnected
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
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.setStartValue(self.game_card._borderWidth)
|
||||||
@@ -83,8 +136,15 @@ class GameCardAnimations:
|
|||||||
self.thickness_anim.start()
|
self.thickness_anim.start()
|
||||||
|
|
||||||
if animation_type == "gradient":
|
if animation_type == "gradient":
|
||||||
|
# Clean up existing gradient animation to prevent memory leaks
|
||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
|
try:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
|
self.gradient_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.gradient_anim = None
|
||||||
|
|
||||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||||
@@ -92,8 +152,15 @@ class GameCardAnimations:
|
|||||||
self.gradient_anim.setLoopCount(-1)
|
self.gradient_anim.setLoopCount(-1)
|
||||||
self.gradient_anim.start()
|
self.gradient_anim.start()
|
||||||
elif animation_type == "scale":
|
elif animation_type == "scale":
|
||||||
|
# Clean up existing scale animation to prevent memory leaks
|
||||||
if self.scale_anim:
|
if self.scale_anim:
|
||||||
|
try:
|
||||||
self.scale_anim.stop()
|
self.scale_anim.stop()
|
||||||
|
self.scale_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.scale_anim = None
|
||||||
|
|
||||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
self.scale_anim.setDuration(self.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.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
|
||||||
@@ -109,11 +176,21 @@ class GameCardAnimations:
|
|||||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||||
if animation_type == "gradient":
|
if animation_type == "gradient":
|
||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
|
try:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
|
self.gradient_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
self.gradient_anim = None
|
self.gradient_anim = None
|
||||||
elif animation_type == "scale":
|
elif animation_type == "scale":
|
||||||
if self.scale_anim:
|
if self.scale_anim:
|
||||||
|
try:
|
||||||
self.scale_anim.stop()
|
self.scale_anim.stop()
|
||||||
|
self.scale_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.scale_anim = None
|
||||||
|
|
||||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
self.scale_anim.setDuration(self.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.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
|
||||||
@@ -121,12 +198,19 @@ class GameCardAnimations:
|
|||||||
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
|
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
|
||||||
self.scale_anim.start()
|
self.scale_anim.start()
|
||||||
if self.pulse_anim:
|
if self.pulse_anim:
|
||||||
|
try:
|
||||||
self.pulse_anim.stop()
|
self.pulse_anim.stop()
|
||||||
|
self.pulse_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
self.pulse_anim = None
|
self.pulse_anim = None
|
||||||
if self.thickness_anim:
|
if self.thickness_anim:
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
|
try:
|
||||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Signal was already disconnected
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
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.setStartValue(self.game_card._borderWidth)
|
||||||
@@ -147,7 +231,10 @@ class GameCardAnimations:
|
|||||||
if self.thickness_anim:
|
if self.thickness_anim:
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
|
try:
|
||||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Signal was already disconnected
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
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.setStartValue(self.game_card._borderWidth)
|
||||||
@@ -157,8 +244,15 @@ class GameCardAnimations:
|
|||||||
self.thickness_anim.start()
|
self.thickness_anim.start()
|
||||||
|
|
||||||
if animation_type == "gradient":
|
if animation_type == "gradient":
|
||||||
|
# Clean up existing gradient animation to prevent memory leaks
|
||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
|
try:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
|
self.gradient_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.gradient_anim = None
|
||||||
|
|
||||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||||
@@ -166,8 +260,15 @@ class GameCardAnimations:
|
|||||||
self.gradient_anim.setLoopCount(-1)
|
self.gradient_anim.setLoopCount(-1)
|
||||||
self.gradient_anim.start()
|
self.gradient_anim.start()
|
||||||
elif animation_type == "scale":
|
elif animation_type == "scale":
|
||||||
|
# Clean up existing scale animation to prevent memory leaks
|
||||||
if self.scale_anim:
|
if self.scale_anim:
|
||||||
|
try:
|
||||||
self.scale_anim.stop()
|
self.scale_anim.stop()
|
||||||
|
self.scale_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.scale_anim = None
|
||||||
|
|
||||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
self.scale_anim.setDuration(self.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.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
|
||||||
@@ -183,11 +284,21 @@ class GameCardAnimations:
|
|||||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||||
if animation_type == "gradient":
|
if animation_type == "gradient":
|
||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
|
try:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
|
self.gradient_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
self.gradient_anim = None
|
self.gradient_anim = None
|
||||||
elif animation_type == "scale":
|
elif animation_type == "scale":
|
||||||
if self.scale_anim:
|
if self.scale_anim:
|
||||||
|
try:
|
||||||
self.scale_anim.stop()
|
self.scale_anim.stop()
|
||||||
|
self.scale_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.scale_anim = None
|
||||||
|
|
||||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
self.scale_anim.setDuration(self.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.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
|
||||||
@@ -195,12 +306,19 @@ class GameCardAnimations:
|
|||||||
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
|
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
|
||||||
self.scale_anim.start()
|
self.scale_anim.start()
|
||||||
if self.pulse_anim:
|
if self.pulse_anim:
|
||||||
|
try:
|
||||||
self.pulse_anim.stop()
|
self.pulse_anim.stop()
|
||||||
|
self.pulse_anim.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
self.pulse_anim = None
|
self.pulse_anim = None
|
||||||
if self.thickness_anim:
|
if self.thickness_anim:
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
|
try:
|
||||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Signal was already disconnected
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
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.setStartValue(self.game_card._borderWidth)
|
||||||
@@ -236,14 +354,57 @@ class DetailPageAnimations:
|
|||||||
self.main_window = main_window
|
self.main_window = main_window
|
||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
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 {}
|
# Ensure the main window has an animations dict
|
||||||
|
if not hasattr(main_window, '_animations'):
|
||||||
|
main_window._animations = {}
|
||||||
|
self.animations = main_window._animations
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Clean up all animations to prevent memory leaks."""
|
||||||
|
# Stop and clean up all animations in the dict
|
||||||
|
for _detail_page, animation in list(self.animations.items()):
|
||||||
|
try:
|
||||||
|
if isinstance(animation, QAbstractAnimation):
|
||||||
|
animation.stop()
|
||||||
|
animation.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Object already deleted
|
||||||
|
self.animations.clear()
|
||||||
|
|
||||||
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
|
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."""
|
"""Animate the detail page based on theme settings."""
|
||||||
|
# Check if the detail page is still valid before proceeding
|
||||||
|
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
|
||||||
|
logger.warning("Detail page is not valid, skipping enter animation")
|
||||||
|
load_image_and_restore_effect()
|
||||||
|
cleanup_animation()
|
||||||
|
return
|
||||||
|
|
||||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
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)
|
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
||||||
|
|
||||||
|
# Safely stop and remove any existing animation for this detail page
|
||||||
|
if detail_page in self.animations:
|
||||||
|
try:
|
||||||
|
existing_animation = self.animations[detail_page]
|
||||||
|
if isinstance(existing_animation, QAbstractAnimation) and existing_animation.state() == QAbstractAnimation.State.Running:
|
||||||
|
existing_animation.stop()
|
||||||
|
existing_animation.deleteLater()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.debug("Existing animation already deleted")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping existing animation: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self.animations.pop(detail_page, None)
|
||||||
|
|
||||||
if animation_type == "fade":
|
if animation_type == "fade":
|
||||||
|
# Check again if page is still valid before starting animation
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.warning("Detail page became invalid during fade setup, skipping animation")
|
||||||
|
load_image_and_restore_effect()
|
||||||
|
cleanup_animation()
|
||||||
|
return
|
||||||
|
|
||||||
original_effect = detail_page.graphicsEffect()
|
original_effect = detail_page.graphicsEffect()
|
||||||
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
|
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
|
||||||
opacity_effect.setOpacity(0.0)
|
opacity_effect.setOpacity(0.0)
|
||||||
@@ -252,17 +413,36 @@ class DetailPageAnimations:
|
|||||||
animation.setDuration(duration)
|
animation.setDuration(duration)
|
||||||
animation.setStartValue(0.0)
|
animation.setStartValue(0.0)
|
||||||
animation.setEndValue(0.999)
|
animation.setEndValue(0.999)
|
||||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
|
||||||
self.animations[detail_page] = animation
|
|
||||||
def restore_effect():
|
def restore_effect():
|
||||||
try:
|
try:
|
||||||
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
# Check if page is still valid before restoring effect
|
||||||
|
if detail_page and not detail_page.isHidden():
|
||||||
|
detail_page.setGraphicsEffect(cast(Any, original_effect))
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
logger.warning("Original effect already deleted")
|
logger.warning("Original effect already deleted")
|
||||||
|
|
||||||
|
# Only start animation if page is still valid
|
||||||
|
if detail_page and not detail_page.isHidden():
|
||||||
|
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||||
|
self.animations[detail_page] = animation
|
||||||
animation.finished.connect(restore_effect)
|
animation.finished.connect(restore_effect)
|
||||||
animation.finished.connect(load_image_and_restore_effect)
|
animation.finished.connect(load_image_and_restore_effect)
|
||||||
animation.finished.connect(opacity_effect.deleteLater)
|
animation.finished.connect(opacity_effect.deleteLater)
|
||||||
|
else:
|
||||||
|
logger.warning("Detail page invalid when starting fade, cleaning up")
|
||||||
|
restore_effect()
|
||||||
|
load_image_and_restore_effect()
|
||||||
|
opacity_effect.deleteLater()
|
||||||
|
cleanup_animation()
|
||||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||||
|
# Check again if page is still valid before starting animation
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.warning("Detail page became invalid during slide setup, skipping animation")
|
||||||
|
load_image_and_restore_effect()
|
||||||
|
cleanup_animation()
|
||||||
|
return
|
||||||
|
|
||||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
|
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")])
|
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||||
start_pos = {
|
start_pos = {
|
||||||
@@ -277,11 +457,25 @@ class DetailPageAnimations:
|
|||||||
animation.setStartValue(start_pos)
|
animation.setStartValue(start_pos)
|
||||||
animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
|
animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
|
||||||
animation.setEasingCurve(easing_curve)
|
animation.setEasingCurve(easing_curve)
|
||||||
|
|
||||||
|
# Only start animation if page is still valid
|
||||||
|
if detail_page and not detail_page.isHidden():
|
||||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||||
self.animations[detail_page] = animation
|
self.animations[detail_page] = animation
|
||||||
animation.finished.connect(cleanup_animation)
|
animation.finished.connect(cleanup_animation)
|
||||||
animation.finished.connect(load_image_and_restore_effect)
|
animation.finished.connect(load_image_and_restore_effect)
|
||||||
|
else:
|
||||||
|
logger.warning("Detail page invalid when starting slide, cleaning up")
|
||||||
|
load_image_and_restore_effect()
|
||||||
|
cleanup_animation()
|
||||||
elif animation_type == "bounce":
|
elif animation_type == "bounce":
|
||||||
|
# Check again if page is still valid before starting animation
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.warning("Detail page became invalid during bounce setup, skipping animation")
|
||||||
|
load_image_and_restore_effect()
|
||||||
|
cleanup_animation()
|
||||||
|
return
|
||||||
|
|
||||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
|
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")])
|
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||||
detail_page.setWindowOpacity(0.0)
|
detail_page.setWindowOpacity(0.0)
|
||||||
@@ -300,14 +494,27 @@ class DetailPageAnimations:
|
|||||||
group_anim = QParallelAnimationGroup()
|
group_anim = QParallelAnimationGroup()
|
||||||
group_anim.addAnimation(opacity_anim)
|
group_anim.addAnimation(opacity_anim)
|
||||||
group_anim.addAnimation(geometry_anim)
|
group_anim.addAnimation(geometry_anim)
|
||||||
group_anim.finished.connect(load_image_and_restore_effect)
|
|
||||||
group_anim.finished.connect(cleanup_animation)
|
# Only start animation if page is still valid
|
||||||
|
if detail_page and not detail_page.isHidden():
|
||||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||||
self.animations[detail_page] = group_anim
|
self.animations[detail_page] = group_anim
|
||||||
|
group_anim.finished.connect(load_image_and_restore_effect)
|
||||||
|
group_anim.finished.connect(cleanup_animation)
|
||||||
|
else:
|
||||||
|
logger.warning("Detail page invalid when starting bounce, cleaning up")
|
||||||
|
load_image_and_restore_effect()
|
||||||
|
cleanup_animation()
|
||||||
|
|
||||||
def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
|
def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
|
||||||
"""Animate the detail page exit based on theme settings."""
|
"""Animate the detail page exit based on theme settings."""
|
||||||
try:
|
try:
|
||||||
|
# Check if the detail page is still valid before proceeding
|
||||||
|
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
|
||||||
|
logger.warning("Detail page is not valid, skipping exit animation")
|
||||||
|
cleanup_callback()
|
||||||
|
return
|
||||||
|
|
||||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||||
|
|
||||||
# Safely stop and remove any existing animation
|
# Safely stop and remove any existing animation
|
||||||
@@ -316,6 +523,7 @@ class DetailPageAnimations:
|
|||||||
animation = self.animations[detail_page]
|
animation = self.animations[detail_page]
|
||||||
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
|
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
|
||||||
animation.stop()
|
animation.stop()
|
||||||
|
animation.deleteLater()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
logger.warning("Animation already deleted for page")
|
logger.warning("Animation already deleted for page")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -326,6 +534,13 @@ class DetailPageAnimations:
|
|||||||
# Define animation based on type
|
# Define animation based on type
|
||||||
if animation_type == "fade":
|
if animation_type == "fade":
|
||||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
|
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
|
||||||
|
|
||||||
|
# Check if page is still valid before accessing properties
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.warning("Detail page became invalid during fade exit setup, skipping animation")
|
||||||
|
cleanup_callback()
|
||||||
|
return
|
||||||
|
|
||||||
original_effect = detail_page.graphicsEffect()
|
original_effect = detail_page.graphicsEffect()
|
||||||
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
|
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
|
||||||
opacity_effect.setOpacity(0.999)
|
opacity_effect.setOpacity(0.999)
|
||||||
@@ -334,18 +549,36 @@ class DetailPageAnimations:
|
|||||||
animation.setDuration(duration)
|
animation.setDuration(duration)
|
||||||
animation.setStartValue(0.999)
|
animation.setStartValue(0.999)
|
||||||
animation.setEndValue(0.0)
|
animation.setEndValue(0.0)
|
||||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
|
||||||
self.animations[detail_page] = animation
|
|
||||||
def restore_and_cleanup():
|
def restore_and_cleanup():
|
||||||
try:
|
try:
|
||||||
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
# Check if page is still valid before restoring effect
|
||||||
|
if detail_page and not detail_page.isHidden():
|
||||||
|
detail_page.setGraphicsEffect(cast(Any, original_effect))
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
logger.debug("Original effect already deleted")
|
logger.debug("Original effect already deleted")
|
||||||
cleanup_callback()
|
cleanup_callback()
|
||||||
|
|
||||||
|
# Check if animation is still valid before starting
|
||||||
|
if animation and not detail_page.isHidden():
|
||||||
|
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||||
|
self.animations[detail_page] = animation
|
||||||
animation.finished.connect(restore_and_cleanup)
|
animation.finished.connect(restore_and_cleanup)
|
||||||
animation.finished.connect(opacity_effect.deleteLater)
|
animation.finished.connect(opacity_effect.deleteLater)
|
||||||
|
else:
|
||||||
|
logger.warning("Animation or detail page invalid when starting fade exit, cleaning up")
|
||||||
|
restore_and_cleanup()
|
||||||
|
opacity_effect.deleteLater()
|
||||||
|
|
||||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
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)
|
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
|
||||||
|
|
||||||
|
# Check if page is still valid before accessing properties
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.warning("Detail page became invalid during slide exit setup, skipping animation")
|
||||||
|
cleanup_callback()
|
||||||
|
return
|
||||||
|
|
||||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
||||||
end_pos = {
|
end_pos = {
|
||||||
"slide_left": QPoint(-self.main_window.width(), 0),
|
"slide_left": QPoint(-self.main_window.width(), 0),
|
||||||
@@ -353,16 +586,37 @@ class DetailPageAnimations:
|
|||||||
"slide_up": QPoint(0, self.main_window.height()),
|
"slide_up": QPoint(0, self.main_window.height()),
|
||||||
"slide_down": QPoint(0, -self.main_window.height())
|
"slide_down": QPoint(0, -self.main_window.height())
|
||||||
}[animation_type]
|
}[animation_type]
|
||||||
|
|
||||||
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
|
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
|
||||||
animation.setDuration(duration)
|
animation.setDuration(duration)
|
||||||
animation.setStartValue(detail_page.pos())
|
animation.setStartValue(detail_page.pos())
|
||||||
animation.setEndValue(end_pos)
|
animation.setEndValue(end_pos)
|
||||||
animation.setEasingCurve(easing_curve)
|
animation.setEasingCurve(easing_curve)
|
||||||
|
|
||||||
|
def slide_cleanup():
|
||||||
|
# Check if page is still valid before cleanup
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.debug("Detail page already cleaned up")
|
||||||
|
cleanup_callback()
|
||||||
|
|
||||||
|
# Check if animation is still valid before starting
|
||||||
|
if animation and not detail_page.isHidden():
|
||||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||||
self.animations[detail_page] = animation
|
self.animations[detail_page] = animation
|
||||||
animation.finished.connect(cleanup_callback)
|
animation.finished.connect(slide_cleanup)
|
||||||
|
else:
|
||||||
|
logger.warning("Animation or detail page invalid when starting slide exit, cleaning up")
|
||||||
|
slide_cleanup()
|
||||||
|
|
||||||
elif animation_type == "bounce":
|
elif animation_type == "bounce":
|
||||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
|
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
|
||||||
|
|
||||||
|
# Check if page is still valid before accessing properties
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.warning("Detail page became invalid during bounce exit setup, skipping animation")
|
||||||
|
cleanup_callback()
|
||||||
|
return
|
||||||
|
|
||||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
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 = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
|
||||||
opacity_anim.setDuration(duration)
|
opacity_anim.setDuration(duration)
|
||||||
@@ -375,13 +629,38 @@ class DetailPageAnimations:
|
|||||||
geometry_anim.setStartValue(detail_page.geometry())
|
geometry_anim.setStartValue(detail_page.geometry())
|
||||||
geometry_anim.setEndValue(final_rect)
|
geometry_anim.setEndValue(final_rect)
|
||||||
geometry_anim.setEasingCurve(easing_curve)
|
geometry_anim.setEasingCurve(easing_curve)
|
||||||
|
|
||||||
|
# Check if animations are still valid before creating group
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.warning("Detail page became invalid during bounce exit setup, cleaning up")
|
||||||
|
cleanup_callback()
|
||||||
|
return
|
||||||
|
|
||||||
group_anim = QParallelAnimationGroup()
|
group_anim = QParallelAnimationGroup()
|
||||||
group_anim.addAnimation(opacity_anim)
|
group_anim.addAnimation(opacity_anim)
|
||||||
group_anim.addAnimation(geometry_anim)
|
group_anim.addAnimation(geometry_anim)
|
||||||
group_anim.finished.connect(cleanup_callback)
|
|
||||||
|
# Check if group animation is still valid before connecting
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.warning("Detail page became invalid during group animation setup, cleaning up")
|
||||||
|
cleanup_callback()
|
||||||
|
return
|
||||||
|
|
||||||
|
def bounce_cleanup():
|
||||||
|
# Check if page is still valid before cleanup
|
||||||
|
if not detail_page or detail_page.isHidden():
|
||||||
|
logger.debug("Detail page already cleaned up")
|
||||||
|
cleanup_callback()
|
||||||
|
|
||||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||||
self.animations[detail_page] = group_anim
|
self.animations[detail_page] = group_anim
|
||||||
|
group_anim.finished.connect(bounce_cleanup)
|
||||||
|
except RuntimeError:
|
||||||
|
# Widget was already deleted, which is expected after deleteLater()
|
||||||
|
logger.debug("Detail page already deleted during animation setup")
|
||||||
|
cleanup_callback()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
|
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
|
||||||
|
if detail_page in self.animations:
|
||||||
self.animations.pop(detail_page, None)
|
self.animations.pop(detail_page, None)
|
||||||
cleanup_callback()
|
cleanup_callback()
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
import sys
|
import sys
|
||||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
import os
|
||||||
|
import subprocess
|
||||||
|
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
from PySide6.QtGui import QIcon
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||||
|
|
||||||
from portprotonqt.main_window import MainWindow
|
from portprotonqt.main_window import MainWindow
|
||||||
from portprotonqt.config_utils import save_fullscreen_config
|
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.logger import get_logger, setup_logger
|
||||||
from portprotonqt.cli import parse_args
|
from portprotonqt.cli import parse_args
|
||||||
|
|
||||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||||
__app_name__ = "PortProtonQt"
|
__app_name__ = "PortProtonQt"
|
||||||
__app_version__ = "0.1.6"
|
__app_version__ = "0.1.9"
|
||||||
|
|
||||||
|
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():
|
def main():
|
||||||
|
os.environ["PW_CLI"] = "1"
|
||||||
|
os.environ["PROCESS_LOG"] = "1"
|
||||||
|
os.environ["START_FROM_STEAM"] = "1"
|
||||||
|
|
||||||
|
# Get the PortProton start command
|
||||||
|
start_sh = get_portproton_start_command()
|
||||||
|
|
||||||
|
if start_sh is None:
|
||||||
|
return
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
||||||
app.setDesktopFileName(__app_id__)
|
app.setDesktopFileName(__app_id__)
|
||||||
@@ -19,40 +47,131 @@ def main():
|
|||||||
app.setApplicationVersion(__app_version__)
|
app.setApplicationVersion(__app_version__)
|
||||||
|
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
# Setup logger with specified debug level
|
|
||||||
setup_logger(args.debug_level)
|
setup_logger(args.debug_level)
|
||||||
|
|
||||||
# Reinitialize logger after setup to ensure it uses the new configuration
|
|
||||||
logger = get_logger(__name__)
|
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()
|
system_locale = QLocale.system()
|
||||||
qt_translator = QTranslator()
|
qt_translator = QTranslator()
|
||||||
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
|
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
|
||||||
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
|
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
|
||||||
app.installTranslator(qt_translator)
|
app.installTranslator(qt_translator)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
|
logger.warning(
|
||||||
|
f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
|
||||||
|
)
|
||||||
|
|
||||||
window = MainWindow(app_name=__app_name__)
|
# --- Main Window ---
|
||||||
|
version = get_version()
|
||||||
|
window = MainWindow(app_name=__app_name__, version=version)
|
||||||
|
|
||||||
if args.fullscreen:
|
# --- Handle incoming connections ---
|
||||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
def handle_new_connection():
|
||||||
|
conn = local_server.nextPendingConnection()
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
|
||||||
|
if conn.waitForReadyRead(1000):
|
||||||
|
data = conn.readAll().data()
|
||||||
|
msg = bytes(data).decode("utf-8", errors="ignore")
|
||||||
|
logger.info(f"IPC message received: {msg}")
|
||||||
|
|
||||||
|
def restore_window():
|
||||||
|
try:
|
||||||
|
if msg.startswith("show"):
|
||||||
|
# Ensure the window is visible and not minimized
|
||||||
|
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||||
|
window.show()
|
||||||
|
window.raise_()
|
||||||
|
window.activateWindow()
|
||||||
|
|
||||||
|
# Ensure window is in active state for systems with strict focus policies
|
||||||
|
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
|
||||||
|
|
||||||
|
if ":fullscreen" in msg:
|
||||||
|
logger.info("Switching to fullscreen via IPC")
|
||||||
save_fullscreen_config(True)
|
save_fullscreen_config(True)
|
||||||
window.showFullScreen()
|
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()
|
||||||
|
|
||||||
|
# Execute the initial PortProton command after the UI is set up
|
||||||
|
def run_initial_command():
|
||||||
|
nonlocal start_sh
|
||||||
|
if start_sh:
|
||||||
|
try:
|
||||||
|
subprocess.run(start_sh + ["cli", "--initial"], timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("Initial PortProton command timed out")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error running initial PortProton command: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning("PortProton start command not available, skipping initial command")
|
||||||
|
|
||||||
|
# Run the initial command after the UI is displayed
|
||||||
|
QTimer.singleShot(100, run_initial_command)
|
||||||
|
|
||||||
|
# --- Cleanup ---
|
||||||
def cleanup_on_exit():
|
def cleanup_on_exit():
|
||||||
nonlocal window
|
try:
|
||||||
app.aboutToQuit.disconnect()
|
local_server.close()
|
||||||
|
QLocalServer.removeServer(server_name)
|
||||||
if window:
|
if window:
|
||||||
window.close()
|
window.close()
|
||||||
app.quit()
|
except Exception as e:
|
||||||
|
logger.warning(f"Cleanup error: {e}")
|
||||||
|
|
||||||
app.aboutToQuit.connect(cleanup_on_exit)
|
app.aboutToQuit.connect(cleanup_on_exit)
|
||||||
|
|
||||||
window.show()
|
|
||||||
|
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import os
|
import os
|
||||||
import configparser
|
import configparser
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
|
from portprotonqt.localization import get_theme_translations
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
_portproton_location = None
|
_portproton_location = None
|
||||||
|
_portproton_start_sh = None
|
||||||
|
|
||||||
|
# Configuration cache for performance optimization
|
||||||
|
_config_cache = {}
|
||||||
|
_config_last_modified = {}
|
||||||
|
|
||||||
# Paths to configuration files
|
# Paths to configuration files
|
||||||
CONFIG_FILE = os.path.join(
|
CONFIG_FILE = os.path.join(
|
||||||
@@ -26,13 +33,35 @@ THEMES_DIRS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
|
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."""
|
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails.
|
||||||
cp = configparser.ConfigParser()
|
Uses caching to avoid repeated file reads for better performance.
|
||||||
|
"""
|
||||||
|
# Check if file exists
|
||||||
if not os.path.exists(config_file):
|
if not os.path.exists(config_file):
|
||||||
logger.debug(f"Configuration file {config_file} not found")
|
logger.debug(f"Configuration file {config_file} not found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Get file modification time
|
||||||
|
try:
|
||||||
|
current_mtime = os.path.getmtime(config_file)
|
||||||
|
except OSError:
|
||||||
|
logger.warning(f"Failed to get modification time for {config_file}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if we have a cached version that's still valid
|
||||||
|
if config_file in _config_cache and config_file in _config_last_modified:
|
||||||
|
if _config_last_modified[config_file] == current_mtime:
|
||||||
|
logger.debug(f"Using cached config for {config_file}")
|
||||||
|
return _config_cache[config_file]
|
||||||
|
|
||||||
|
# Read and parse the config file
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
try:
|
try:
|
||||||
cp.read(config_file, encoding="utf-8")
|
cp.read(config_file, encoding="utf-8")
|
||||||
|
# Update cache
|
||||||
|
_config_cache[config_file] = cp
|
||||||
|
_config_last_modified[config_file] = current_mtime
|
||||||
|
logger.debug(f"Config file {config_file} loaded and cached")
|
||||||
return cp
|
return cp
|
||||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||||
logger.warning(f"Invalid configuration file format: {e}")
|
logger.warning(f"Invalid configuration file format: {e}")
|
||||||
@@ -41,22 +70,14 @@ def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
|
|||||||
logger.warning(f"Failed to read configuration file: {e}")
|
logger.warning(f"Failed to read configuration file: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def read_config():
|
def invalidate_config_cache(config_file: str = CONFIG_FILE):
|
||||||
"""Reads the configuration file and returns a dictionary of parameters.
|
"""Invalidates the cached configuration for the specified file."""
|
||||||
Example line in config (no sections):
|
if config_file in _config_cache:
|
||||||
detail_level = detailed
|
del _config_cache[config_file]
|
||||||
"""
|
if config_file in _config_last_modified:
|
||||||
config_dict = {}
|
del _config_last_modified[config_file]
|
||||||
if os.path.exists(CONFIG_FILE):
|
logger.debug(f"Config cache invalidated for {config_file}")
|
||||||
with open(CONFIG_FILE, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
key, sep, value = line.partition("=")
|
|
||||||
if sep:
|
|
||||||
config_dict[key.strip()] = value.strip()
|
|
||||||
return config_dict
|
|
||||||
|
|
||||||
def read_theme_from_config():
|
def read_theme_from_config():
|
||||||
"""Reads the theme from the [Appearance] section of the configuration file.
|
"""Reads the theme from the [Appearance] section of the configuration file.
|
||||||
@@ -75,6 +96,8 @@ def save_theme_to_config(theme_name):
|
|||||||
cp["Appearance"]["theme"] = theme_name
|
cp["Appearance"]["theme"] = theme_name
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_time_config():
|
def read_time_config():
|
||||||
"""Reads time settings from the [Time] section of the configuration file.
|
"""Reads time settings from the [Time] section of the configuration file.
|
||||||
@@ -94,21 +117,29 @@ def save_time_config(detail_level):
|
|||||||
cp["Time"]["detail_level"] = detail_level
|
cp["Time"]["detail_level"] = detail_level
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_file_content(file_path):
|
def read_file_content(file_path):
|
||||||
"""Reads the content of a file and returns it as a string."""
|
"""Reads the content of a file and returns it as a string."""
|
||||||
|
try:
|
||||||
|
# Add timeout protection for file operations using a simple approach
|
||||||
with open(file_path, encoding="utf-8") as f:
|
with open(file_path, encoding="utf-8") as f:
|
||||||
return f.read().strip()
|
content = f.read().strip()
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading file {file_path}: {e}")
|
||||||
|
raise # Re-raise the exception to be handled by the caller
|
||||||
|
|
||||||
def get_portproton_location():
|
def get_portproton_location():
|
||||||
"""Returns the path to the PortProton directory.
|
"""Возвращает путь к PortProton каталогу (строку) или None."""
|
||||||
Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
|
|
||||||
If the path is invalid, uses the default directory.
|
|
||||||
"""
|
|
||||||
global _portproton_location
|
global _portproton_location
|
||||||
|
|
||||||
if _portproton_location is not None:
|
if _portproton_location is not None:
|
||||||
return _portproton_location
|
return _portproton_location
|
||||||
|
|
||||||
|
location = None
|
||||||
|
|
||||||
if os.path.isfile(PORTPROTON_CONFIG_FILE):
|
if os.path.isfile(PORTPROTON_CONFIG_FILE):
|
||||||
try:
|
try:
|
||||||
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
|
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
|
||||||
@@ -116,19 +147,69 @@ def get_portproton_location():
|
|||||||
_portproton_location = location
|
_portproton_location = location
|
||||||
logger.info(f"PortProton path from configuration: {location}")
|
logger.info(f"PortProton path from configuration: {location}")
|
||||||
return _portproton_location
|
return _portproton_location
|
||||||
logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
|
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
|
||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
|
logger.warning(f"Failed to read PortProton configuration file: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unexpected error reading PortProton configuration file: {e}")
|
||||||
|
|
||||||
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||||
if os.path.isdir(default_dir):
|
if os.path.isdir(default_flatpak_dir):
|
||||||
_portproton_location = default_dir
|
_portproton_location = default_flatpak_dir
|
||||||
logger.info(f"Using flatpak PortProton directory: {default_dir}")
|
logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
|
||||||
return _portproton_location
|
return _portproton_location
|
||||||
|
|
||||||
logger.warning("PortProton configuration and flatpak directory not found")
|
logger.warning("PortProton configuration and Flatpak directory not found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_portproton_start_command():
|
||||||
|
"""Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
|
||||||
|
portproton_path = get_portproton_location()
|
||||||
|
if not portproton_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if flatpak command exists before trying to run it
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["flatpak", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
flatpak_available = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
flatpak_available = False
|
||||||
|
except Exception:
|
||||||
|
flatpak_available = False
|
||||||
|
|
||||||
|
if flatpak_available:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["flatpak", "list"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if "ru.linux_gaming.PortProton" in result.stdout:
|
||||||
|
logger.info("Detected Flatpak installation")
|
||||||
|
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("Flatpak list command timed out")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking flatpak list: {e}")
|
||||||
|
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):
|
def parse_desktop_entry(file_path):
|
||||||
"""Reads and parses a .desktop file using configparser.
|
"""Reads and parses a .desktop file using configparser.
|
||||||
Returns None if the [Desktop Entry] section is missing.
|
Returns None if the [Desktop Entry] section is missing.
|
||||||
@@ -148,13 +229,17 @@ def load_theme_metainfo(theme_name):
|
|||||||
theme_folder = os.path.join(themes_dir, theme_name)
|
theme_folder = os.path.join(themes_dir, theme_name)
|
||||||
metainfo_file = os.path.join(theme_folder, "metainfo.ini")
|
metainfo_file = os.path.join(theme_folder, "metainfo.ini")
|
||||||
if os.path.exists(metainfo_file):
|
if os.path.exists(metainfo_file):
|
||||||
|
# Load translated theme name and description
|
||||||
|
theme_translations = get_theme_translations(metainfo_file)
|
||||||
|
|
||||||
cp = configparser.ConfigParser()
|
cp = configparser.ConfigParser()
|
||||||
cp.read(metainfo_file, encoding="utf-8")
|
cp.read(metainfo_file, encoding="utf-8")
|
||||||
if "Metainfo" in cp:
|
if "Metainfo" in cp:
|
||||||
meta["author"] = cp.get("Metainfo", "author", fallback="Unknown")
|
meta["author"] = cp.get("Metainfo", "author", fallback="Unknown")
|
||||||
meta["author_link"] = cp.get("Metainfo", "author_link", fallback="")
|
meta["author_link"] = cp.get("Metainfo", "author_link", fallback="")
|
||||||
meta["description"] = cp.get("Metainfo", "description", fallback="")
|
# Use translated name and description
|
||||||
meta["name"] = cp.get("Metainfo", "name", fallback=theme_name)
|
meta["name"] = theme_translations.get("name", theme_name)
|
||||||
|
meta["description"] = theme_translations.get("description", "")
|
||||||
break
|
break
|
||||||
return meta
|
return meta
|
||||||
|
|
||||||
@@ -176,6 +261,30 @@ def save_card_size(card_width):
|
|||||||
cp["Cards"]["card_width"] = str(card_width)
|
cp["Cards"]["card_width"] = str(card_width)
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
|
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)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
|
|
||||||
def read_sort_method():
|
def read_sort_method():
|
||||||
"""Reads the sort method from the [Games] section.
|
"""Reads the sort method from the [Games] section.
|
||||||
@@ -195,6 +304,8 @@ def save_sort_method(sort_method):
|
|||||||
cp["Games"]["sort_method"] = sort_method
|
cp["Games"]["sort_method"] = sort_method
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_display_filter():
|
def read_display_filter():
|
||||||
"""Reads the display_filter parameter from the [Games] section.
|
"""Reads the display_filter parameter from the [Games] section.
|
||||||
@@ -214,6 +325,8 @@ def save_display_filter(filter_value):
|
|||||||
cp["Games"]["display_filter"] = filter_value
|
cp["Games"]["display_filter"] = filter_value
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_favorites():
|
def read_favorites():
|
||||||
"""Reads the list of favorite games from the [Favorites] section.
|
"""Reads the list of favorite games from the [Favorites] section.
|
||||||
@@ -239,6 +352,8 @@ def save_favorites(favorites):
|
|||||||
cp["Favorites"]["games"] = f'"{fav_str}"'
|
cp["Favorites"]["games"] = f'"{fav_str}"'
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_rumble_config():
|
def read_rumble_config():
|
||||||
"""Reads the gamepad rumble setting from the [Gamepad] section.
|
"""Reads the gamepad rumble setting from the [Gamepad] section.
|
||||||
@@ -258,6 +373,29 @@ def save_rumble_config(rumble_enabled):
|
|||||||
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
|
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
|
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)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def ensure_default_proxy_config():
|
def ensure_default_proxy_config():
|
||||||
"""Ensures the [Proxy] section exists in the configuration file.
|
"""Ensures the [Proxy] section exists in the configuration file.
|
||||||
@@ -302,6 +440,8 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
|
|||||||
cp["Proxy"]["proxy_password"] = proxy_password
|
cp["Proxy"]["proxy_password"] = proxy_password
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_fullscreen_config():
|
def read_fullscreen_config():
|
||||||
"""Reads the fullscreen mode setting from the [Display] section.
|
"""Reads the fullscreen mode setting from the [Display] section.
|
||||||
@@ -321,6 +461,8 @@ def save_fullscreen_config(fullscreen):
|
|||||||
cp["Display"]["fullscreen"] = str(fullscreen)
|
cp["Display"]["fullscreen"] = str(fullscreen)
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_window_geometry() -> tuple[int, int]:
|
def read_window_geometry() -> tuple[int, int]:
|
||||||
"""Reads the window width and height from the [MainWindow] section.
|
"""Reads the window width and height from the [MainWindow] section.
|
||||||
@@ -342,6 +484,8 @@ def save_window_geometry(width: int, height: int):
|
|||||||
cp["MainWindow"]["height"] = str(height)
|
cp["MainWindow"]["height"] = str(height)
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def reset_config():
|
def reset_config():
|
||||||
"""Resets the configuration file by deleting it.
|
"""Resets the configuration file by deleting it.
|
||||||
@@ -351,6 +495,8 @@ def reset_config():
|
|||||||
try:
|
try:
|
||||||
os.remove(CONFIG_FILE)
|
os.remove(CONFIG_FILE)
|
||||||
logger.info("Configuration file %s deleted", CONFIG_FILE)
|
logger.info("Configuration file %s deleted", CONFIG_FILE)
|
||||||
|
# Invalidate cache after deletion
|
||||||
|
invalidate_config_cache()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to delete configuration file: {e}")
|
logger.warning(f"Failed to delete configuration file: {e}")
|
||||||
|
|
||||||
@@ -365,6 +511,9 @@ def clear_cache():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to delete cache: {e}")
|
logger.warning(f"Failed to delete cache: {e}")
|
||||||
|
|
||||||
|
# Also clear our internal config cache
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_auto_fullscreen_gamepad():
|
def read_auto_fullscreen_gamepad():
|
||||||
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
|
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
|
||||||
Returns False if the parameter is missing.
|
Returns False if the parameter is missing.
|
||||||
@@ -383,6 +532,8 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
|
|||||||
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
|
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
def read_favorite_folders():
|
def read_favorite_folders():
|
||||||
"""Reads the list of favorite folders from the [FavoritesFolders] section.
|
"""Reads the list of favorite folders from the [FavoritesFolders] section.
|
||||||
@@ -408,3 +559,26 @@ def save_favorite_folders(folders):
|
|||||||
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
|
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|
||||||
|
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)
|
||||||
|
# Invalidate cache after saving
|
||||||
|
invalidate_config_cache()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
|
|||||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
||||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||||
from portprotonqt.localization import _
|
from portprotonqt.localization import _
|
||||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
|
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.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.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
|
||||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
|
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
|
||||||
@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
|
|||||||
class ContextMenuManager:
|
class ContextMenuManager:
|
||||||
"""Manages context menu actions for game management in PortProtonQt."""
|
"""Manages context menu actions for game management in PortProtonQt."""
|
||||||
|
|
||||||
def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
|
def __init__(self, parent, portproton_location, theme, game_library_manager):
|
||||||
"""
|
"""
|
||||||
Initialize the ContextMenuManager.
|
Initialize the ContextMenuManager.
|
||||||
|
|
||||||
@@ -44,7 +44,6 @@ class ContextMenuManager:
|
|||||||
self.portproton_location = portproton_location
|
self.portproton_location = portproton_location
|
||||||
self.theme = theme
|
self.theme = theme
|
||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
self.load_games = load_games_callback
|
|
||||||
self.game_library_manager = game_library_manager
|
self.game_library_manager = game_library_manager
|
||||||
self.update_game_grid = game_library_manager.update_game_grid
|
self.update_game_grid = game_library_manager.update_game_grid
|
||||||
self.legendary_path = os.path.join(
|
self.legendary_path = os.path.join(
|
||||||
@@ -406,16 +405,7 @@ class ContextMenuManager:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
# Construct EGS launch command
|
# Construct EGS launch command
|
||||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
wrapper = get_portproton_start_command()
|
||||||
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
|
|
||||||
exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
|
exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
|
||||||
else:
|
else:
|
||||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||||
@@ -1044,7 +1034,15 @@ Icon={icon_path}
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if os.path.isfile(new_cover_path):
|
# Check if new_cover_path is a URL by checking for common image extensions
|
||||||
|
image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
|
||||||
|
has_image_extension = any(new_cover_path.lower().endswith(ext) for ext in image_extensions)
|
||||||
|
|
||||||
|
# Consider it a URL if it has image extension and is not a local file
|
||||||
|
is_url = has_image_extension and not os.path.isfile(new_cover_path)
|
||||||
|
|
||||||
|
# Use the downloaded file path if we have a URL and the file was downloaded, otherwise use the local file
|
||||||
|
if os.path.isfile(new_cover_path) or (is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path)):
|
||||||
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
||||||
xdg_data_home = os.getenv(
|
xdg_data_home = os.getenv(
|
||||||
"XDG_DATA_HOME",
|
"XDG_DATA_HOME",
|
||||||
@@ -1052,16 +1050,25 @@ Icon={icon_path}
|
|||||||
)
|
)
|
||||||
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
|
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
|
||||||
os.makedirs(custom_folder, exist_ok=True)
|
os.makedirs(custom_folder, exist_ok=True)
|
||||||
ext = os.path.splitext(new_cover_path)[1].lower()
|
|
||||||
|
# Use the actual cover file path (either from URL download or local file)
|
||||||
|
cover_to_copy = dialog.last_cover_path if is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path) else new_cover_path
|
||||||
|
ext = os.path.splitext(cover_to_copy)[1].lower()
|
||||||
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
|
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
|
||||||
try:
|
try:
|
||||||
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
|
shutil.copyfile(cover_to_copy, os.path.join(custom_folder, f"cover{ext}"))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.signals.show_warning_dialog.emit(
|
self.signals.show_warning_dialog.emit(
|
||||||
_("Error"),
|
_("Error"),
|
||||||
_("Failed to copy cover image: {error}").format(error=str(e))
|
_("Failed to copy cover image: {error}").format(error=str(e))
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
self.signals.show_warning_dialog.emit(
|
||||||
|
_("Error"),
|
||||||
|
_("Unsupported image format: {extension}").format(extension=ext)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
def add_to_steam(self, game_name, exec_line, cover_path):
|
def add_to_steam(self, game_name, exec_line, cover_path):
|
||||||
"""
|
"""
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB |
|
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 |
@@ -1,116 +1,161 @@
|
|||||||
import numpy as np
|
|
||||||
from PySide6.QtWidgets import QLabel, QPushButton, QWidget, QLayout, QLayoutItem
|
from PySide6.QtWidgets import QLabel, QPushButton, QWidget, QLayout, QLayoutItem
|
||||||
from PySide6.QtCore import Qt, Signal, QRect, QPoint, QSize
|
from PySide6.QtCore import Qt, Signal, QRect, QSize
|
||||||
from PySide6.QtGui import QFont, QFontMetrics, QPainter
|
from PySide6.QtGui import QFont, QFontMetrics, QPainter
|
||||||
|
|
||||||
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||||
"""
|
"""
|
||||||
Computes the layout of elements considering spacing and potential scaling of cards.
|
Оптимизированная версия на чистом Python без numpy.
|
||||||
nat_sizes: Array (N, 2) with natural sizes of elements (width, height).
|
nat_sizes: list of tuples [(width, height), ...]
|
||||||
rect_width: Available container width.
|
|
||||||
spacing: Spacing between elements (horizontal and vertical).
|
|
||||||
max_scale: Maximum scaling factor (e.g., 1.0).
|
|
||||||
|
|
||||||
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]
|
N = len(nat_sizes)
|
||||||
result = np.zeros((N, 4), dtype=np.int32)
|
if N == 0:
|
||||||
y = 0
|
return [], 0
|
||||||
i = 0
|
|
||||||
min_margin = 20 # Minimum margin on edges
|
|
||||||
|
|
||||||
# Determine the maximum number of items per row and overall scale
|
result = [[0, 0, 0, 0] for _ in range(N)]
|
||||||
max_items_per_row = 0
|
min_margin = 20
|
||||||
|
available_width = rect_width - 2 * min_margin
|
||||||
|
|
||||||
|
# Быстрый поиск максимального количества элементов в строке
|
||||||
|
max_items_per_row = 1
|
||||||
global_scale = 1.0
|
global_scale = 1.0
|
||||||
max_row_x_start = min_margin # Starting x position of the widest row
|
max_row_x_start = min_margin
|
||||||
temp_i = 0
|
|
||||||
|
|
||||||
# First pass: Find the maximum number of items in a row
|
i = 0
|
||||||
while temp_i < N:
|
while i < N:
|
||||||
sum_width = 0
|
# Бинарный поиск максимального количества элементов
|
||||||
count = 0
|
left, right = 1, N - i
|
||||||
temp_j = temp_i
|
best_count = 1
|
||||||
while temp_j < N:
|
|
||||||
w = nat_sizes[temp_j, 0]
|
while left <= right:
|
||||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
mid = (left + right) // 2
|
||||||
break
|
end_idx = min(i + mid, N)
|
||||||
sum_width += w
|
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
|
||||||
count += 1
|
needed_width = sum_w + spacing * (mid - 1)
|
||||||
temp_j += 1
|
|
||||||
|
if needed_width <= available_width:
|
||||||
|
best_count = mid
|
||||||
|
left = mid + 1
|
||||||
|
else:
|
||||||
|
right = mid - 1
|
||||||
|
|
||||||
|
count = best_count
|
||||||
|
sum_width = sum(nat_sizes[j][0] for j in range(i, i + count))
|
||||||
|
|
||||||
if count > max_items_per_row:
|
if count > max_items_per_row:
|
||||||
max_items_per_row = count
|
max_items_per_row = count
|
||||||
# Calculate scale for the most populated row
|
desired_scale = available_width / (sum_width + spacing * (count - 1)) if sum_width > 0 else 1.0
|
||||||
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
|
global_scale = min(desired_scale, max_scale)
|
||||||
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)
|
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
|
||||||
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
|
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
|
||||||
temp_i = temp_j
|
|
||||||
|
|
||||||
# Second pass: Place elements
|
i += count
|
||||||
|
|
||||||
|
# Второй проход: размещение элементов
|
||||||
|
y = 0
|
||||||
|
i = 0
|
||||||
|
|
||||||
while i < N:
|
while i < N:
|
||||||
|
# Бинарный поиск для текущей строки
|
||||||
|
left, right = 1, N - i
|
||||||
|
best_count = 1
|
||||||
|
|
||||||
|
while left <= right:
|
||||||
|
mid = (left + right) // 2
|
||||||
|
end_idx = min(i + mid, N)
|
||||||
|
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
|
||||||
|
needed_width = sum_w + spacing * (mid - 1)
|
||||||
|
|
||||||
|
if needed_width <= available_width:
|
||||||
|
best_count = mid
|
||||||
|
left = mid + 1
|
||||||
|
else:
|
||||||
|
right = mid - 1
|
||||||
|
|
||||||
|
count = best_count
|
||||||
|
j = i + count
|
||||||
|
|
||||||
|
# Расчёт размеров для строки
|
||||||
sum_width = 0
|
sum_width = 0
|
||||||
row_max_height = 0
|
row_max_height = 0
|
||||||
count = 0
|
for k in range(i, j):
|
||||||
j = i
|
w, h = nat_sizes[k]
|
||||||
|
|
||||||
# Determine the number of items for the current row
|
|
||||||
while j < N:
|
|
||||||
w = nat_sizes[j, 0]
|
|
||||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
|
||||||
break
|
|
||||||
sum_width += w
|
sum_width += w
|
||||||
count += 1
|
|
||||||
h = nat_sizes[j, 1]
|
|
||||||
if h > row_max_height:
|
if h > row_max_height:
|
||||||
row_max_height = h
|
row_max_height = h
|
||||||
j += 1
|
|
||||||
|
|
||||||
# Use global scale for all rows
|
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
|
||||||
scale = global_scale
|
|
||||||
scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
|
|
||||||
|
|
||||||
# Determine starting x coordinate
|
# Определение начальной позиции
|
||||||
if count == max_items_per_row:
|
if count == max_items_per_row:
|
||||||
# Center the full row
|
|
||||||
x = max(min_margin, (rect_width - scaled_row_width) // 2)
|
x = max(min_margin, (rect_width - scaled_row_width) // 2)
|
||||||
else:
|
else:
|
||||||
# Align incomplete row to the left, matching the widest row's start
|
|
||||||
x = max_row_x_start
|
x = max_row_x_start
|
||||||
|
|
||||||
|
# Размещение элементов в строке
|
||||||
for k in range(i, j):
|
for k in range(i, j):
|
||||||
new_w = int(nat_sizes[k, 0] * scale)
|
w, h = nat_sizes[k]
|
||||||
new_h = int(nat_sizes[k, 1] * scale)
|
new_w = int(w * global_scale)
|
||||||
result[k, 0] = x
|
new_h = int(h * global_scale)
|
||||||
result[k, 1] = y
|
result[k][0] = x
|
||||||
result[k, 2] = new_w
|
result[k][1] = y
|
||||||
result[k, 3] = new_h
|
result[k][2] = new_w
|
||||||
|
result[k][3] = new_h
|
||||||
x += new_w + spacing
|
x += new_w + spacing
|
||||||
|
|
||||||
y += int(row_max_height * scale) + spacing
|
y += int(row_max_height * global_scale) + spacing
|
||||||
i = j
|
i = j
|
||||||
|
|
||||||
return result, y
|
return result, y
|
||||||
|
|
||||||
|
|
||||||
class FlowLayout(QLayout):
|
class FlowLayout(QLayout):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.itemList = []
|
self.itemList = []
|
||||||
self.setContentsMargins(20, 20, 20, 20) # Margins around the layout
|
self.setContentsMargins(20, 20, 20, 20)
|
||||||
self._spacing = 20 # Spacing for animation and overlap prevention
|
self._spacing = 20
|
||||||
self._max_scale = 1.0 # Scaling disabled in layout
|
self._max_scale = 1.0
|
||||||
|
|
||||||
|
# Простой кеш
|
||||||
|
self._cache_width = None
|
||||||
|
self._cache_visible_hash = None
|
||||||
|
self._cache_result = None
|
||||||
|
|
||||||
|
def _get_visible_data(self):
|
||||||
|
"""Возвращает список видимых элементов и их размеры"""
|
||||||
|
visible_items = []
|
||||||
|
visible_indices = []
|
||||||
|
visible_sizes = []
|
||||||
|
|
||||||
|
for i, item in enumerate(self.itemList):
|
||||||
|
widget = item.widget()
|
||||||
|
if widget and widget.isVisible():
|
||||||
|
visible_items.append(item)
|
||||||
|
visible_indices.append(i)
|
||||||
|
s = item.sizeHint()
|
||||||
|
visible_sizes.append((s.width(), s.height()))
|
||||||
|
|
||||||
|
return visible_items, visible_indices, visible_sizes
|
||||||
|
|
||||||
|
def _make_visible_hash(self, visible_sizes):
|
||||||
|
"""Создаёт хеш для проверки изменений"""
|
||||||
|
return hash(tuple(visible_sizes))
|
||||||
|
|
||||||
def addItem(self, item: QLayoutItem) -> None:
|
def addItem(self, item: QLayoutItem) -> None:
|
||||||
self.itemList.append(item)
|
self.itemList.append(item)
|
||||||
|
self._invalidate_cache()
|
||||||
|
|
||||||
def takeAt(self, index: int) -> QLayoutItem:
|
def takeAt(self, index: int) -> QLayoutItem:
|
||||||
if 0 <= index < len(self.itemList):
|
if 0 <= index < len(self.itemList):
|
||||||
|
self._invalidate_cache()
|
||||||
return self.itemList.pop(index)
|
return self.itemList.pop(index)
|
||||||
raise IndexError("Index out of range")
|
raise IndexError("Index out of range")
|
||||||
|
|
||||||
|
def _invalidate_cache(self):
|
||||||
|
self._cache_width = None
|
||||||
|
self._cache_visible_hash = None
|
||||||
|
self._cache_result = None
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
return len(self.itemList)
|
return len(self.itemList)
|
||||||
|
|
||||||
@@ -126,7 +171,28 @@ class FlowLayout(QLayout):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def heightForWidth(self, width):
|
def heightForWidth(self, width):
|
||||||
return self.doLayout(QRect(0, 0, width, 0), True)
|
_, _, visible_sizes = self._get_visible_data()
|
||||||
|
|
||||||
|
if not visible_sizes:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Проверка кеша
|
||||||
|
visible_hash = self._make_visible_hash(visible_sizes)
|
||||||
|
if (self._cache_width == width and
|
||||||
|
self._cache_visible_hash == visible_hash and
|
||||||
|
self._cache_result is not None):
|
||||||
|
return self._cache_result[1]
|
||||||
|
|
||||||
|
# Вычисление
|
||||||
|
geom_array, total_height = compute_layout(visible_sizes, width,
|
||||||
|
self._spacing, self._max_scale)
|
||||||
|
|
||||||
|
# Сохранение в кеш
|
||||||
|
self._cache_width = width
|
||||||
|
self._cache_visible_hash = visible_hash
|
||||||
|
self._cache_result = (geom_array, total_height)
|
||||||
|
|
||||||
|
return total_height
|
||||||
|
|
||||||
def setGeometry(self, rect):
|
def setGeometry(self, rect):
|
||||||
super().setGeometry(rect)
|
super().setGeometry(rect)
|
||||||
@@ -145,25 +211,47 @@ class FlowLayout(QLayout):
|
|||||||
return size
|
return size
|
||||||
|
|
||||||
def doLayout(self, rect, testOnly):
|
def doLayout(self, rect, testOnly):
|
||||||
N = len(self.itemList)
|
N_total = len(self.itemList)
|
||||||
if N == 0:
|
if N_total == 0:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
nat_sizes = np.empty((N, 2), dtype=np.int32)
|
visible_items, visible_indices, visible_sizes = self._get_visible_data()
|
||||||
for i, item in enumerate(self.itemList):
|
|
||||||
s = item.sizeHint()
|
|
||||||
nat_sizes[i, 0] = s.width()
|
|
||||||
nat_sizes[i, 1] = s.height()
|
|
||||||
|
|
||||||
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
|
if not visible_sizes:
|
||||||
|
if not testOnly:
|
||||||
|
for item in self.itemList:
|
||||||
|
item.setGeometry(QRect())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Проверка кеша
|
||||||
|
visible_hash = self._make_visible_hash(visible_sizes)
|
||||||
|
if (self._cache_width == rect.width() and
|
||||||
|
self._cache_visible_hash == visible_hash and
|
||||||
|
self._cache_result is not None):
|
||||||
|
geom_array, total_height = self._cache_result
|
||||||
|
else:
|
||||||
|
# Вычисление layout
|
||||||
|
geom_array, total_height = compute_layout(visible_sizes, rect.width(),
|
||||||
|
self._spacing, self._max_scale)
|
||||||
|
|
||||||
|
# Сохранение в кеш
|
||||||
|
self._cache_width = rect.width()
|
||||||
|
self._cache_visible_hash = visible_hash
|
||||||
|
self._cache_result = (geom_array, total_height)
|
||||||
|
|
||||||
if not testOnly:
|
if not testOnly:
|
||||||
for i, item in enumerate(self.itemList):
|
rx, ry = rect.x(), rect.y()
|
||||||
x = geom_array[i, 0] + rect.x()
|
|
||||||
y = geom_array[i, 1] + rect.y()
|
# Установка геометрии для видимых элементов
|
||||||
w = geom_array[i, 2]
|
for idx, item in enumerate(visible_items):
|
||||||
h = geom_array[i, 3]
|
x, y, w, h = geom_array[idx]
|
||||||
item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
|
item.setGeometry(QRect(x + rx, y + ry, w, h))
|
||||||
|
|
||||||
|
# Скрытие невидимых элементов
|
||||||
|
visible_set = set(visible_indices)
|
||||||
|
for i in range(N_total):
|
||||||
|
if i not in visible_set:
|
||||||
|
self.itemList[i].setGeometry(QRect())
|
||||||
|
|
||||||
return total_height
|
return total_height
|
||||||
|
|
||||||
|
|||||||
223
portprotonqt/delete_wine_module.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QWidget, QCheckBox,
|
||||||
|
QPushButton, QMessageBox,
|
||||||
|
QLabel, QTextEdit, QHBoxLayout,
|
||||||
|
QListWidget, QListWidgetItem)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from portprotonqt.localization import _
|
||||||
|
from portprotonqt.version_utils import version_sort_key
|
||||||
|
|
||||||
|
|
||||||
|
class WineDeleteManager(QDialog):
|
||||||
|
def __init__(self, parent=None, portproton_location=None, selected_wine=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.selected_wines = set() # Use set to store selected wine names
|
||||||
|
self.portproton_location = portproton_location
|
||||||
|
self.selected_wine = selected_wine # The wine that should be pre-selected
|
||||||
|
self.initUI()
|
||||||
|
self.load_wine_data()
|
||||||
|
|
||||||
|
def initUI(self):
|
||||||
|
self.setWindowTitle(_('Delete Wine'))
|
||||||
|
self.resize(800, 600)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(5, 5, 5, 5)
|
||||||
|
layout.setSpacing(5)
|
||||||
|
|
||||||
|
# Wine list widget - основной растягивающийся элемент
|
||||||
|
self.list_widget = QListWidget()
|
||||||
|
self.list_widget.setSelectionMode(QListWidget.SelectionMode.NoSelection) # Disable default selection to use checkboxes
|
||||||
|
layout.addWidget(self.list_widget, 1)
|
||||||
|
|
||||||
|
# Инфо-блок для показа выбранного (компактный для информации по выбранным закачкам)
|
||||||
|
selection_widget = QWidget()
|
||||||
|
selection_layout = QVBoxLayout(selection_widget)
|
||||||
|
selection_layout.setContentsMargins(0, 2, 0, 2)
|
||||||
|
selection_layout.setSpacing(2)
|
||||||
|
|
||||||
|
selection_label = QLabel(_("Selected WINE:"))
|
||||||
|
selection_layout.addWidget(selection_label)
|
||||||
|
|
||||||
|
self.selection_text = QTextEdit()
|
||||||
|
self.selection_text.setMaximumHeight(80)
|
||||||
|
self.selection_text.setReadOnly(True)
|
||||||
|
self.selection_text.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
||||||
|
self.selection_text.setPlainText(_("No WINE selected"))
|
||||||
|
selection_layout.addWidget(self.selection_text)
|
||||||
|
|
||||||
|
layout.addWidget(selection_widget)
|
||||||
|
|
||||||
|
# Кнопки управления
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
self.delete_btn = QPushButton(_('Delete Selected'))
|
||||||
|
self.delete_btn.clicked.connect(self.delete_selected)
|
||||||
|
self.delete_btn.setEnabled(False)
|
||||||
|
self.delete_btn.setMinimumHeight(40)
|
||||||
|
self.clear_btn = QPushButton(_('Clear All'))
|
||||||
|
self.clear_btn.clicked.connect(self.clear_selection)
|
||||||
|
self.clear_btn.setMinimumHeight(40)
|
||||||
|
button_layout.addWidget(self.delete_btn)
|
||||||
|
button_layout.addWidget(self.clear_btn)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
def load_wine_data(self):
|
||||||
|
"""Load wine data from dist directory"""
|
||||||
|
if not self.portproton_location:
|
||||||
|
return
|
||||||
|
|
||||||
|
dist_path = os.path.join(self.portproton_location, "data", "dist")
|
||||||
|
if not os.path.exists(dist_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get all wine directories and sort them by version
|
||||||
|
wine_dirs = sorted([d for d in os.listdir(dist_path) if os.path.isdir(os.path.join(dist_path, d))], key=version_sort_key)
|
||||||
|
|
||||||
|
# Add each wine to the list
|
||||||
|
for wine_name in wine_dirs:
|
||||||
|
self.add_wine_to_list(wine_name)
|
||||||
|
|
||||||
|
def add_wine_to_list(self, wine_name):
|
||||||
|
"""Add a wine to the list with checkbox"""
|
||||||
|
# Create a widget with checkbox and wine name
|
||||||
|
item_widget = QWidget()
|
||||||
|
item_layout = QHBoxLayout(item_widget)
|
||||||
|
item_layout.setContentsMargins(5, 2, 5, 2)
|
||||||
|
item_layout.setSpacing(5)
|
||||||
|
|
||||||
|
checkbox = QCheckBox(wine_name)
|
||||||
|
|
||||||
|
|
||||||
|
checkbox.stateChanged.connect(lambda state, name=wine_name: self.on_wine_toggled(state, name))
|
||||||
|
item_layout.addWidget(checkbox)
|
||||||
|
item_layout.addStretch() # Add stretch to align checkbox to the left
|
||||||
|
|
||||||
|
# Create list item and set the widget
|
||||||
|
list_item = QListWidgetItem(self.list_widget)
|
||||||
|
list_item.setSizeHint(item_widget.sizeHint())
|
||||||
|
self.list_widget.addItem(list_item)
|
||||||
|
self.list_widget.setItemWidget(list_item, item_widget)
|
||||||
|
|
||||||
|
|
||||||
|
def on_wine_toggled(self, state, wine_name):
|
||||||
|
"""Handle wine selection/deselection"""
|
||||||
|
if state == Qt.CheckState.Checked.value:
|
||||||
|
self.selected_wines.add(wine_name)
|
||||||
|
else:
|
||||||
|
self.selected_wines.discard(wine_name)
|
||||||
|
|
||||||
|
self.update_selection_display()
|
||||||
|
|
||||||
|
def update_selection_display(self):
|
||||||
|
"""Update the selection display"""
|
||||||
|
# Get currently selected wines from the list
|
||||||
|
currently_selected = set()
|
||||||
|
for i in range(self.list_widget.count()):
|
||||||
|
item = self.list_widget.item(i)
|
||||||
|
item_widget = self.list_widget.itemWidget(item)
|
||||||
|
if item_widget:
|
||||||
|
checkbox = item_widget.findChild(QCheckBox)
|
||||||
|
if checkbox and checkbox.isChecked():
|
||||||
|
currently_selected.add(checkbox.text())
|
||||||
|
|
||||||
|
# Update the internal set to match the current state
|
||||||
|
self.selected_wines = currently_selected
|
||||||
|
|
||||||
|
if self.selected_wines:
|
||||||
|
selection_text = _('Selected {} WINE:\n').format(len(self.selected_wines))
|
||||||
|
|
||||||
|
for i, wine_name in enumerate(sorted(self.selected_wines), 1):
|
||||||
|
selection_text += f"{i}. {wine_name}\n"
|
||||||
|
|
||||||
|
self.selection_text.setPlainText(selection_text)
|
||||||
|
self.delete_btn.setEnabled(True)
|
||||||
|
else:
|
||||||
|
self.selection_text.setPlainText(_("No WINE selected"))
|
||||||
|
self.delete_btn.setEnabled(False)
|
||||||
|
|
||||||
|
def clear_selection(self):
|
||||||
|
"""Clear all selections"""
|
||||||
|
self.selected_wines.clear()
|
||||||
|
|
||||||
|
# Uncheck all checkboxes in the list
|
||||||
|
for i in range(self.list_widget.count()):
|
||||||
|
item = self.list_widget.item(i)
|
||||||
|
item_widget = self.list_widget.itemWidget(item)
|
||||||
|
if item_widget:
|
||||||
|
checkbox = item_widget.findChild(QCheckBox)
|
||||||
|
if checkbox:
|
||||||
|
checkbox.setChecked(False)
|
||||||
|
|
||||||
|
self.update_selection_display()
|
||||||
|
|
||||||
|
def delete_selected(self):
|
||||||
|
"""Delete all selected wines"""
|
||||||
|
# Get currently selected wines from the list
|
||||||
|
currently_selected = set()
|
||||||
|
for i in range(self.list_widget.count()):
|
||||||
|
item = self.list_widget.item(i)
|
||||||
|
item_widget = self.list_widget.itemWidget(item)
|
||||||
|
if item_widget:
|
||||||
|
checkbox = item_widget.findChild(QCheckBox)
|
||||||
|
if checkbox and checkbox.isChecked():
|
||||||
|
currently_selected.add(checkbox.text())
|
||||||
|
|
||||||
|
if not currently_selected:
|
||||||
|
QMessageBox.warning(self, _("No Selection"), _("Please select at least one WINE to delete."))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Confirm deletion
|
||||||
|
wine_list = "\n".join(sorted(currently_selected))
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
_("Confirm Deletion"),
|
||||||
|
_("Are you sure you want to delete the following WINE versions?\n\n{}").format(wine_list),
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.portproton_location:
|
||||||
|
return
|
||||||
|
|
||||||
|
dist_path = os.path.join(self.portproton_location, "data", "dist")
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for wine_name in currently_selected:
|
||||||
|
wine_path = os.path.join(dist_path, wine_name)
|
||||||
|
try:
|
||||||
|
if os.path.exists(wine_path):
|
||||||
|
shutil.rmtree(wine_path)
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _("Failed to delete WINE '{}': {}").format(wine_name, str(e))
|
||||||
|
errors.append(error_msg)
|
||||||
|
QMessageBox.warning(self, _("Error"), error_msg)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
QMessageBox.warning(self, _("Some Deletions Failed"),
|
||||||
|
_("Some WINE versions could not be deleted:\n\n{}").format("\n".join(errors)))
|
||||||
|
else:
|
||||||
|
QMessageBox.information(self, _("Success"), _("Selected WINE versions deleted successfully."))
|
||||||
|
|
||||||
|
# Close the dialog after deletion
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
|
def show_wine_delete_manager(parent=None, portproton_location=None, selected_wine=None):
|
||||||
|
"""
|
||||||
|
Shows the WINE deletion dialog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent: Parent widget for the dialog
|
||||||
|
portproton_location: Location of PortProton installation
|
||||||
|
selected_wine: Wine that should be pre-selected
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WineDeleteManager dialog instance
|
||||||
|
"""
|
||||||
|
dialog = WineDeleteManager(parent, portproton_location, selected_wine)
|
||||||
|
dialog.exec() # Use exec() for modal dialog
|
||||||
|
return dialog
|
||||||
878
portprotonqt/detail_pages.py
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from PySide6.QtWidgets import (QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QLabel, QHBoxLayout, QWidget, QApplication)
|
||||||
|
from PySide6.QtCore import Qt, QUrl, QTimer, QAbstractAnimation
|
||||||
|
from PySide6.QtGui import QColor, QDesktopServices
|
||||||
|
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||||
|
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton
|
||||||
|
from portprotonqt.game_card import GameCard
|
||||||
|
from portprotonqt.howlongtobeat_api import HowLongToBeat
|
||||||
|
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
|
||||||
|
from portprotonqt.localization import _
|
||||||
|
from portprotonqt.logger import get_logger
|
||||||
|
from portprotonqt.portproton_api import PortProtonAPI
|
||||||
|
from portprotonqt.downloader import Downloader
|
||||||
|
from portprotonqt.animations import DetailPageAnimations
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DetailPageManager:
|
||||||
|
"""Manages detail pages for games."""
|
||||||
|
|
||||||
|
def __init__(self, main_window):
|
||||||
|
self.main_window = main_window
|
||||||
|
self._detail_page_active = False
|
||||||
|
self._current_detail_page = None
|
||||||
|
self._exit_animation_in_progress = False
|
||||||
|
self._animations = {}
|
||||||
|
self.portproton_api = PortProtonAPI(Downloader(max_workers=4))
|
||||||
|
|
||||||
|
def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="",
|
||||||
|
last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
|
||||||
|
"""Open detailed game information page showing all game stats, playtime and settings."""
|
||||||
|
detailPage = QWidget()
|
||||||
|
imageLabel = QLabel()
|
||||||
|
imageLabel.setFixedSize(300, 450)
|
||||||
|
self._detail_page_active = True
|
||||||
|
self._current_detail_page = detailPage
|
||||||
|
# Store the source tab index (Library is typically index 0)
|
||||||
|
self._return_to_tab_index = 0 # Library tab
|
||||||
|
|
||||||
|
# Function to load image and restore effect
|
||||||
|
def load_image_and_restore_effect():
|
||||||
|
# Check if detail page still exists and is valid
|
||||||
|
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||||
|
logger.warning("Detail page is None, hidden, or no longer valid, skipping image load")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
detailPage.setWindowOpacity(1.0)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page is None, hidden, or no longer valid, skipping opacity set")
|
||||||
|
return
|
||||||
|
|
||||||
|
if cover_path:
|
||||||
|
def on_pixmap_ready(pixmap):
|
||||||
|
# Check if detail page still exists and is valid
|
||||||
|
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||||
|
logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
rounded = round_corners(pixmap, 10)
|
||||||
|
imageLabel.setPixmap(rounded)
|
||||||
|
logger.debug("Pixmap set for imageLabel")
|
||||||
|
|
||||||
|
def on_palette_ready(palette):
|
||||||
|
# Check if detail page still exists and is valid
|
||||||
|
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||||
|
logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
dark_palette = [self.main_window.darkenColor(color, factor=200) for color in palette]
|
||||||
|
stops = ",\n".join(
|
||||||
|
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
|
||||||
|
)
|
||||||
|
detailPage.setStyleSheet(self.main_window.theme.detail_page_style(stops))
|
||||||
|
detailPage.update()
|
||||||
|
logger.debug("Stylesheet updated with palette")
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping palette stylesheet update")
|
||||||
|
self.main_window.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping pixmap update")
|
||||||
|
load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
detailPage.setStyleSheet(self.main_window.theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||||
|
detailPage.update()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
|
||||||
|
|
||||||
|
def cleanup_animation():
|
||||||
|
if detailPage in self._animations:
|
||||||
|
del self._animations[detailPage]
|
||||||
|
|
||||||
|
mainLayout = QVBoxLayout(detailPage)
|
||||||
|
mainLayout.setContentsMargins(30, 30, 30, 30)
|
||||||
|
mainLayout.setSpacing(20)
|
||||||
|
|
||||||
|
backButton = AutoSizeButton(_("Back"), icon=self.main_window.theme_manager.get_icon("back"))
|
||||||
|
backButton.setFixedWidth(100)
|
||||||
|
backButton.setStyleSheet(self.main_window.theme.ADDGAME_BACK_BUTTON_STYLE)
|
||||||
|
backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage))
|
||||||
|
mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||||
|
|
||||||
|
contentFrame = QFrame()
|
||||||
|
contentFrame.setStyleSheet(self.main_window.theme.DETAIL_CONTENT_FRAME_STYLE)
|
||||||
|
contentFrameLayout = QHBoxLayout(contentFrame)
|
||||||
|
contentFrameLayout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
contentFrameLayout.setSpacing(40)
|
||||||
|
mainLayout.addWidget(contentFrame)
|
||||||
|
|
||||||
|
# Cover (at left)
|
||||||
|
coverFrame = QFrame()
|
||||||
|
coverFrame.setFixedSize(300, 450)
|
||||||
|
coverFrame.setStyleSheet(self.main_window.theme.COVER_FRAME_STYLE)
|
||||||
|
shadow = QGraphicsDropShadowEffect(coverFrame)
|
||||||
|
shadow.setBlurRadius(20)
|
||||||
|
shadow.setColor(QColor(0, 0, 0, 200))
|
||||||
|
shadow.setOffset(0, 0)
|
||||||
|
coverFrame.setGraphicsEffect(shadow)
|
||||||
|
coverLayout = QVBoxLayout(coverFrame)
|
||||||
|
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
coverLayout.addWidget(imageLabel)
|
||||||
|
|
||||||
|
# Favorite icon
|
||||||
|
favoriteLabelCover = ClickableLabel(coverFrame)
|
||||||
|
favoriteLabelCover.setFixedSize(*self.main_window.theme.favoriteLabelSize)
|
||||||
|
favoriteLabelCover.setStyleSheet(self.main_window.theme.FAVORITE_LABEL_STYLE)
|
||||||
|
favorites = read_favorites()
|
||||||
|
if name in favorites:
|
||||||
|
favoriteLabelCover.setText("★")
|
||||||
|
else:
|
||||||
|
favoriteLabelCover.setText("☆")
|
||||||
|
favoriteLabelCover.clicked.connect(lambda: self.toggleFavoriteInDetailPage(name, favoriteLabelCover))
|
||||||
|
favoriteLabelCover.move(8, 8)
|
||||||
|
favoriteLabelCover.raise_()
|
||||||
|
|
||||||
|
# Add badges (ProtonDB, Steam, PortProton, WeAntiCheatYet)
|
||||||
|
display_filter = read_display_filter()
|
||||||
|
steam_visible = (str(game_source).lower() == "steam" and display_filter in ("all", "favorites"))
|
||||||
|
egs_visible = (str(game_source).lower() == "epic" and display_filter in ("all", "favorites"))
|
||||||
|
portproton_visible = (str(game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
|
||||||
|
right_margin = 8
|
||||||
|
badge_spacing = 5
|
||||||
|
top_y = 10
|
||||||
|
badge_y_positions = []
|
||||||
|
badge_width = int(300 * 2/3)
|
||||||
|
|
||||||
|
# ProtonDB badge
|
||||||
|
protondb_text = GameCard.getProtonDBText(protondb_tier)
|
||||||
|
if protondb_text:
|
||||||
|
icon_filename = GameCard.getProtonDBIconFilename(protondb_tier)
|
||||||
|
icon = self.main_window.theme_manager.get_icon(icon_filename, self.main_window.current_theme_name)
|
||||||
|
protondbLabel = ClickableLabel(
|
||||||
|
protondb_text,
|
||||||
|
icon=icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=3,
|
||||||
|
)
|
||||||
|
protondbLabel.setStyleSheet(self.main_window.theme.get_protondb_badge_style(protondb_tier))
|
||||||
|
protondbLabel.setFixedWidth(badge_width)
|
||||||
|
protondbLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://www.protondb.com/app/{appid}")))
|
||||||
|
protondb_visible = True
|
||||||
|
else:
|
||||||
|
protondbLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3)
|
||||||
|
protondbLabel.setFixedWidth(badge_width)
|
||||||
|
protondbLabel.setVisible(False)
|
||||||
|
protondb_visible = False
|
||||||
|
|
||||||
|
# Steam badge
|
||||||
|
steam_icon = self.main_window.theme_manager.get_icon("steam")
|
||||||
|
steamLabel = ClickableLabel(
|
||||||
|
"Steam",
|
||||||
|
icon=steam_icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=5,
|
||||||
|
)
|
||||||
|
steamLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
|
||||||
|
steamLabel.setFixedWidth(badge_width)
|
||||||
|
steamLabel.setVisible(steam_visible)
|
||||||
|
steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}")))
|
||||||
|
|
||||||
|
# Epic Games Store badge
|
||||||
|
egs_icon = self.main_window.theme_manager.get_icon("epic_games")
|
||||||
|
egsLabel = ClickableLabel(
|
||||||
|
"Epic Games",
|
||||||
|
icon=egs_icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=5,
|
||||||
|
change_cursor=False
|
||||||
|
)
|
||||||
|
egsLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
|
||||||
|
egsLabel.setFixedWidth(badge_width)
|
||||||
|
egsLabel.setVisible(egs_visible)
|
||||||
|
|
||||||
|
# PortProton badge
|
||||||
|
portproton_icon = self.main_window.theme_manager.get_icon("portproton")
|
||||||
|
portprotonLabel = ClickableLabel(
|
||||||
|
"PortProton",
|
||||||
|
icon=portproton_icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=5,
|
||||||
|
)
|
||||||
|
portprotonLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
|
||||||
|
portprotonLabel.setFixedWidth(badge_width)
|
||||||
|
portprotonLabel.setVisible(portproton_visible)
|
||||||
|
portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name))
|
||||||
|
|
||||||
|
# WeAntiCheatYet badge
|
||||||
|
anticheat_text = GameCard.getAntiCheatText(anticheat_status)
|
||||||
|
if anticheat_text:
|
||||||
|
icon_filename = GameCard.getAntiCheatIconFilename(anticheat_status)
|
||||||
|
icon = self.main_window.theme_manager.get_icon(icon_filename, self.main_window.current_theme_name)
|
||||||
|
anticheatLabel = ClickableLabel(
|
||||||
|
anticheat_text,
|
||||||
|
icon=icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=3,
|
||||||
|
)
|
||||||
|
anticheatLabel.setStyleSheet(self.main_window.theme.get_anticheat_badge_style(anticheat_status))
|
||||||
|
anticheatLabel.setFixedWidth(badge_width)
|
||||||
|
anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}")))
|
||||||
|
anticheat_visible = True
|
||||||
|
else:
|
||||||
|
anticheatLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3)
|
||||||
|
anticheatLabel.setFixedWidth(badge_width)
|
||||||
|
anticheatLabel.setVisible(False)
|
||||||
|
anticheat_visible = False
|
||||||
|
|
||||||
|
# Position badges
|
||||||
|
if steam_visible:
|
||||||
|
steam_x = 300 - badge_width - right_margin
|
||||||
|
steamLabel.move(steam_x, top_y)
|
||||||
|
badge_y_positions.append(top_y + steamLabel.height())
|
||||||
|
if egs_visible:
|
||||||
|
egs_x = 300 - badge_width - right_margin
|
||||||
|
egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
egsLabel.move(egs_x, egs_y)
|
||||||
|
badge_y_positions.append(egs_y + egsLabel.height())
|
||||||
|
if portproton_visible:
|
||||||
|
portproton_x = 300 - badge_width - right_margin
|
||||||
|
portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
portprotonLabel.move(portproton_x, portproton_y)
|
||||||
|
badge_y_positions.append(portproton_y + portprotonLabel.height())
|
||||||
|
if protondb_visible:
|
||||||
|
protondb_x = 300 - badge_width - right_margin
|
||||||
|
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
protondbLabel.move(protondb_x, protondb_y)
|
||||||
|
badge_y_positions.append(protondb_y + protondbLabel.height())
|
||||||
|
if anticheat_visible:
|
||||||
|
anticheat_x = 300 - badge_width - right_margin
|
||||||
|
anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
anticheatLabel.move(anticheat_x, anticheat_y)
|
||||||
|
|
||||||
|
anticheatLabel.raise_()
|
||||||
|
protondbLabel.raise_()
|
||||||
|
portprotonLabel.raise_()
|
||||||
|
egsLabel.raise_()
|
||||||
|
steamLabel.raise_()
|
||||||
|
|
||||||
|
contentFrameLayout.addWidget(coverFrame)
|
||||||
|
|
||||||
|
# Game details (at right)
|
||||||
|
detailsWidget = QWidget()
|
||||||
|
detailsWidget.setStyleSheet(self.main_window.theme.DETAILS_WIDGET_STYLE)
|
||||||
|
detailsLayout = QVBoxLayout(detailsWidget)
|
||||||
|
detailsLayout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
detailsLayout.setSpacing(15)
|
||||||
|
|
||||||
|
titleLabel = QLabel(name)
|
||||||
|
titleLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_TITLE_STYLE)
|
||||||
|
detailsLayout.addWidget(titleLabel)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line.setStyleSheet(self.main_window.theme.DETAIL_PAGE_LINE_STYLE)
|
||||||
|
detailsLayout.addWidget(line)
|
||||||
|
|
||||||
|
descLabel = QLabel(description)
|
||||||
|
descLabel.setWordWrap(True)
|
||||||
|
descLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_DESC_STYLE)
|
||||||
|
detailsLayout.addWidget(descLabel)
|
||||||
|
|
||||||
|
# Initialize HowLongToBeat
|
||||||
|
hltb = HowLongToBeat(parent=self.main_window)
|
||||||
|
|
||||||
|
# Create layout for all game info
|
||||||
|
gameInfoLayout = QVBoxLayout()
|
||||||
|
gameInfoLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# First row: Last Launch and Play Time
|
||||||
|
firstRowLayout = QHBoxLayout()
|
||||||
|
firstRowLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# Last Launch
|
||||||
|
lastLaunchTitle = QLabel(_("LAST LAUNCH"))
|
||||||
|
lastLaunchTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
|
lastLaunchValue = QLabel(last_launch)
|
||||||
|
lastLaunchValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
firstRowLayout.addWidget(lastLaunchTitle)
|
||||||
|
firstRowLayout.addWidget(lastLaunchValue)
|
||||||
|
firstRowLayout.addSpacing(30)
|
||||||
|
|
||||||
|
# Play Time
|
||||||
|
playTimeTitle = QLabel(_("PLAY TIME"))
|
||||||
|
playTimeTitle.setStyleSheet(self.main_window.theme.PLAY_TIME_TITLE_STYLE)
|
||||||
|
playTimeValue = QLabel(formatted_playtime)
|
||||||
|
playTimeValue.setStyleSheet(self.main_window.theme.PLAY_TIME_VALUE_STYLE)
|
||||||
|
firstRowLayout.addWidget(playTimeTitle)
|
||||||
|
firstRowLayout.addWidget(playTimeValue)
|
||||||
|
|
||||||
|
gameInfoLayout.addLayout(firstRowLayout)
|
||||||
|
|
||||||
|
# Create placeholder for second row (HLTB data)
|
||||||
|
hltbLayout = QHBoxLayout()
|
||||||
|
hltbLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# Completion time (Main Story, Main + Sides, Completionist)
|
||||||
|
def on_hltb_results(results):
|
||||||
|
if not hasattr(self, '_detail_page_active') or not self._detail_page_active:
|
||||||
|
return
|
||||||
|
if not self._current_detail_page or self._current_detail_page.isHidden() or not self._current_detail_page.parent():
|
||||||
|
return
|
||||||
|
# Additional check: make sure the detail page in the stacked widget is still our current detail page
|
||||||
|
if self.main_window.stackedWidget.currentWidget() != self._current_detail_page and self._current_detail_page not in [self.main_window.stackedWidget.widget(i) for i in range(self.main_window.stackedWidget.count())]:
|
||||||
|
return
|
||||||
|
|
||||||
|
if results:
|
||||||
|
game = results[0] # Take first result
|
||||||
|
main_story_time = hltb.format_game_time(game, "main_story")
|
||||||
|
main_extra_time = hltb.format_game_time(game, "main_extra")
|
||||||
|
completionist_time = hltb.format_game_time(game, "completionist")
|
||||||
|
|
||||||
|
# Clear layout before adding new elements
|
||||||
|
def clear_layout(layout):
|
||||||
|
while layout.count():
|
||||||
|
item = layout.takeAt(0)
|
||||||
|
widget = item.widget()
|
||||||
|
sublayout = item.layout()
|
||||||
|
if widget:
|
||||||
|
widget.deleteLater()
|
||||||
|
elif sublayout:
|
||||||
|
clear_layout(sublayout)
|
||||||
|
|
||||||
|
clear_layout(hltbLayout)
|
||||||
|
|
||||||
|
has_data = False
|
||||||
|
|
||||||
|
if main_story_time is not None:
|
||||||
|
try:
|
||||||
|
mainStoryTitle = QLabel(_("MAIN STORY"))
|
||||||
|
mainStoryTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
|
mainStoryValue = QLabel(main_story_time)
|
||||||
|
mainStoryValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(mainStoryTitle)
|
||||||
|
hltbLayout.addWidget(mainStoryValue)
|
||||||
|
hltbLayout.addSpacing(30)
|
||||||
|
has_data = True
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping main story time update")
|
||||||
|
|
||||||
|
if main_extra_time is not None:
|
||||||
|
try:
|
||||||
|
mainExtraTitle = QLabel(_("MAIN + SIDES"))
|
||||||
|
mainExtraTitle.setStyleSheet(self.main_window.theme.PLAY_TIME_TITLE_STYLE)
|
||||||
|
mainExtraValue = QLabel(main_extra_time)
|
||||||
|
mainExtraValue.setStyleSheet(self.main_window.theme.PLAY_TIME_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(mainExtraTitle)
|
||||||
|
hltbLayout.addWidget(mainExtraValue)
|
||||||
|
hltbLayout.addSpacing(30)
|
||||||
|
has_data = True
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping main extra time update")
|
||||||
|
|
||||||
|
if completionist_time is not None:
|
||||||
|
try:
|
||||||
|
completionistTitle = QLabel(_("COMPLETIONIST"))
|
||||||
|
completionistTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
|
completionistValue = QLabel(completionist_time)
|
||||||
|
completionistValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(completionistTitle)
|
||||||
|
hltbLayout.addWidget(completionistValue)
|
||||||
|
has_data = True
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping completionist time update")
|
||||||
|
|
||||||
|
# If there's data, add the layout to the second row
|
||||||
|
if has_data:
|
||||||
|
gameInfoLayout.addLayout(hltbLayout)
|
||||||
|
|
||||||
|
# Connect searchCompleted signal to on_hltb_results
|
||||||
|
hltb.searchCompleted.connect(on_hltb_results)
|
||||||
|
|
||||||
|
# Start search in background thread
|
||||||
|
hltb.search_with_callback(name, case_sensitive=False)
|
||||||
|
|
||||||
|
# Add the game info layout
|
||||||
|
detailsLayout.addLayout(gameInfoLayout)
|
||||||
|
|
||||||
|
if controller_support:
|
||||||
|
cs = controller_support.lower()
|
||||||
|
translated_cs = ""
|
||||||
|
if cs == "full":
|
||||||
|
translated_cs = _("full")
|
||||||
|
elif cs == "partial":
|
||||||
|
translated_cs = _("partial")
|
||||||
|
elif cs == "none":
|
||||||
|
translated_cs = _("none")
|
||||||
|
gamepadSupportLabel = QLabel(_("Gamepad Support: {0}").format(translated_cs))
|
||||||
|
gamepadSupportLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
gamepadSupportLabel.setStyleSheet(self.main_window.theme.GAMEPAD_SUPPORT_VALUE_STYLE)
|
||||||
|
detailsLayout.addWidget(gamepadSupportLabel, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
detailsLayout.addStretch(1)
|
||||||
|
|
||||||
|
# Determine current game ID from exec_line
|
||||||
|
entry_exec_split = shlex.split(exec_line)
|
||||||
|
if not entry_exec_split:
|
||||||
|
return
|
||||||
|
|
||||||
|
if entry_exec_split[0] == "env":
|
||||||
|
file_to_check = entry_exec_split[2] if len(entry_exec_split) >= 3 else None
|
||||||
|
elif entry_exec_split[0] == "flatpak":
|
||||||
|
file_to_check = entry_exec_split[3] if len(entry_exec_split) >= 4 else None
|
||||||
|
else:
|
||||||
|
file_to_check = entry_exec_split[0]
|
||||||
|
current_exe = os.path.basename(file_to_check) if file_to_check else None
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
if self.main_window.target_exe is not None and current_exe == self.main_window.target_exe:
|
||||||
|
playButton = AutoSizeButton(_("Stop"), icon=self.main_window.theme_manager.get_icon("stop"))
|
||||||
|
else:
|
||||||
|
playButton = AutoSizeButton(_("Play"), icon=self.main_window.theme_manager.get_icon("play"))
|
||||||
|
|
||||||
|
playButton.setFixedSize(120, 40)
|
||||||
|
playButton.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
|
||||||
|
playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
playButton.clicked.connect(lambda: self.main_window.toggleGame(exec_line, playButton))
|
||||||
|
buttons_layout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||||
|
|
||||||
|
# Settings button
|
||||||
|
settings_icon = self.main_window.theme_manager.get_icon("settings")
|
||||||
|
settings_button = AutoSizeButton(_("Settings"), icon=settings_icon)
|
||||||
|
settings_button.setFixedSize(120, 40)
|
||||||
|
settings_button.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
|
||||||
|
settings_button.clicked.connect(lambda: self.main_window.open_exe_settings(file_to_check))
|
||||||
|
buttons_layout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
detailsLayout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
contentFrameLayout.addWidget(detailsWidget)
|
||||||
|
mainLayout.addStretch()
|
||||||
|
|
||||||
|
self.main_window.stackedWidget.addWidget(detailPage)
|
||||||
|
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||||
|
self.main_window.currentDetailPage = detailPage
|
||||||
|
self.main_window.current_exec_line = exec_line
|
||||||
|
self.main_window.current_play_button = playButton
|
||||||
|
|
||||||
|
# Animation
|
||||||
|
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
|
||||||
|
detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
|
||||||
|
|
||||||
|
# Update page reference
|
||||||
|
self.main_window.currentDetailPage = detailPage
|
||||||
|
|
||||||
|
original_load = load_image_and_restore_effect
|
||||||
|
|
||||||
|
def enhanced_load():
|
||||||
|
original_load()
|
||||||
|
QTimer.singleShot(50, try_set_focus)
|
||||||
|
|
||||||
|
def try_set_focus():
|
||||||
|
if not (playButton and not playButton.isHidden()):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure page is active
|
||||||
|
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||||
|
detailPage.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
playButton.update()
|
||||||
|
detailPage.raise_()
|
||||||
|
self.main_window.activateWindow()
|
||||||
|
|
||||||
|
if playButton.hasFocus():
|
||||||
|
logger.debug("Play button successfully received focus")
|
||||||
|
else:
|
||||||
|
logger.debug("Retrying focus...")
|
||||||
|
QTimer.singleShot(20, retry_focus)
|
||||||
|
|
||||||
|
def retry_focus():
|
||||||
|
if not (playButton and not playButton.isHidden() and not playButton.hasFocus()):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process events to ensure UI state is updated
|
||||||
|
QApplication.processEvents()
|
||||||
|
self.main_window.activateWindow()
|
||||||
|
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||||
|
detailPage.raise_()
|
||||||
|
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
playButton.update()
|
||||||
|
|
||||||
|
if not playButton.hasFocus():
|
||||||
|
logger.debug("Final retry...")
|
||||||
|
playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
QApplication.processEvents()
|
||||||
|
|
||||||
|
if playButton.hasFocus():
|
||||||
|
logger.debug("Play button received focus after final retry")
|
||||||
|
else:
|
||||||
|
logger.debug("Play button still doesn't have focus")
|
||||||
|
|
||||||
|
detail_animations.animate_detail_page(
|
||||||
|
detailPage,
|
||||||
|
enhanced_load,
|
||||||
|
cleanup_animation
|
||||||
|
)
|
||||||
|
|
||||||
|
def openAutoInstallDetailPage(self, name, description, cover_path=None, exec_line="", game_source=""):
|
||||||
|
"""Open minimal detail page for auto-install games with name, description, cover, and install button."""
|
||||||
|
detailPage = QWidget()
|
||||||
|
imageLabel = QLabel()
|
||||||
|
imageLabel.setFixedSize(300, 450)
|
||||||
|
self._detail_page_active = True
|
||||||
|
self._current_detail_page = detailPage
|
||||||
|
# Store the source tab index (Auto Install is typically index 1)
|
||||||
|
self._return_to_tab_index = 1 # Auto Install tab
|
||||||
|
|
||||||
|
# Try to get the description from downloaded metadata for richer content
|
||||||
|
script_name = ""
|
||||||
|
if exec_line and exec_line.startswith("autoinstall:"):
|
||||||
|
script_name = exec_line[11:].lstrip(':').strip()
|
||||||
|
|
||||||
|
if script_name:
|
||||||
|
# Get localized description based on current UI language
|
||||||
|
# Import locale module to detect current locale
|
||||||
|
import locale
|
||||||
|
try:
|
||||||
|
current_locale = locale.getlocale()[0] or 'en'
|
||||||
|
except Exception:
|
||||||
|
current_locale = 'en'
|
||||||
|
lang_code = 'ru' if current_locale and 'ru' in current_locale.lower() else 'en'
|
||||||
|
|
||||||
|
metadata_description = self.portproton_api.get_autoinstall_description(script_name, lang_code)
|
||||||
|
if metadata_description:
|
||||||
|
description = metadata_description
|
||||||
|
|
||||||
|
# Function to load image and restore effect
|
||||||
|
def load_image_and_restore_effect():
|
||||||
|
# Check if detail page still exists and is valid
|
||||||
|
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||||
|
logger.warning("Detail page is None, hidden, or no longer valid, skipping image load")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
detailPage.setWindowOpacity(1.0)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page is None, hidden, or no longer valid, skipping opacity set")
|
||||||
|
return
|
||||||
|
|
||||||
|
if cover_path:
|
||||||
|
def on_pixmap_ready(pixmap):
|
||||||
|
# Check if detail page still exists and is valid
|
||||||
|
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||||
|
logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
rounded = round_corners(pixmap, 10)
|
||||||
|
imageLabel.setPixmap(rounded)
|
||||||
|
logger.debug("Pixmap set for imageLabel")
|
||||||
|
|
||||||
|
def on_palette_ready(palette):
|
||||||
|
# Check if detail page still exists and is valid
|
||||||
|
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||||
|
logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
dark_palette = [self.main_window.darkenColor(color, factor=200) for color in palette]
|
||||||
|
stops = ",\n".join(
|
||||||
|
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
|
||||||
|
)
|
||||||
|
detailPage.setStyleSheet(self.main_window.theme.detail_page_style(stops))
|
||||||
|
detailPage.update()
|
||||||
|
logger.debug("Stylesheet updated with palette")
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping palette stylesheet update")
|
||||||
|
self.main_window.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping pixmap update")
|
||||||
|
load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
detailPage.setStyleSheet(self.main_window.theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||||
|
detailPage.update()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
|
||||||
|
|
||||||
|
def cleanup_animation():
|
||||||
|
if detailPage in self._animations:
|
||||||
|
del self._animations[detailPage]
|
||||||
|
|
||||||
|
mainLayout = QVBoxLayout(detailPage)
|
||||||
|
mainLayout.setContentsMargins(30, 30, 30, 30)
|
||||||
|
mainLayout.setSpacing(20)
|
||||||
|
|
||||||
|
backButton = AutoSizeButton(_("Back"), icon=self.main_window.theme_manager.get_icon("back"))
|
||||||
|
backButton.setFixedWidth(100)
|
||||||
|
backButton.setStyleSheet(self.main_window.theme.ADDGAME_BACK_BUTTON_STYLE)
|
||||||
|
backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage))
|
||||||
|
mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||||
|
|
||||||
|
contentFrame = QFrame()
|
||||||
|
contentFrame.setStyleSheet(self.main_window.theme.DETAIL_CONTENT_FRAME_STYLE)
|
||||||
|
contentFrameLayout = QHBoxLayout(contentFrame)
|
||||||
|
contentFrameLayout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
contentFrameLayout.setSpacing(40)
|
||||||
|
mainLayout.addWidget(contentFrame)
|
||||||
|
|
||||||
|
# Cover (at left)
|
||||||
|
coverFrame = QFrame()
|
||||||
|
coverFrame.setFixedSize(300, 450)
|
||||||
|
coverFrame.setStyleSheet(self.main_window.theme.COVER_FRAME_STYLE)
|
||||||
|
shadow = QGraphicsDropShadowEffect(coverFrame)
|
||||||
|
shadow.setBlurRadius(20)
|
||||||
|
shadow.setColor(QColor(0, 0, 0, 200))
|
||||||
|
shadow.setOffset(0, 0)
|
||||||
|
coverFrame.setGraphicsEffect(shadow)
|
||||||
|
coverLayout = QVBoxLayout(coverFrame)
|
||||||
|
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
coverLayout.addWidget(imageLabel)
|
||||||
|
|
||||||
|
# No favorite icon for auto-install games
|
||||||
|
|
||||||
|
# No badges for auto-install detail page
|
||||||
|
contentFrameLayout.addWidget(coverFrame)
|
||||||
|
|
||||||
|
# Game details (at right) - minimal version without time info
|
||||||
|
detailsWidget = QWidget()
|
||||||
|
detailsWidget.setStyleSheet(self.main_window.theme.DETAILS_WIDGET_STYLE)
|
||||||
|
detailsLayout = QVBoxLayout(detailsWidget)
|
||||||
|
detailsLayout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
detailsLayout.setSpacing(15)
|
||||||
|
|
||||||
|
titleLabel = QLabel(name)
|
||||||
|
titleLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_TITLE_STYLE)
|
||||||
|
detailsLayout.addWidget(titleLabel)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line.setStyleSheet(self.main_window.theme.DETAIL_PAGE_LINE_STYLE)
|
||||||
|
detailsLayout.addWidget(line)
|
||||||
|
|
||||||
|
descLabel = QLabel(description)
|
||||||
|
descLabel.setWordWrap(True)
|
||||||
|
descLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_DESC_STYLE)
|
||||||
|
detailsLayout.addWidget(descLabel)
|
||||||
|
|
||||||
|
# No HLTB data, playtime, or launch info for auto install
|
||||||
|
detailsLayout.addStretch(1)
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# The script_name was already extracted at the beginning of the function
|
||||||
|
# Determine if game is already installed based on whether .desktop files exist for the script
|
||||||
|
game_installed = self.is_autoinstall_game_installed(script_name, name) if script_name else False
|
||||||
|
|
||||||
|
install_button_text = _("Reinstall") if game_installed else _("Install")
|
||||||
|
# Use update icon for reinstall, save icon for initial install
|
||||||
|
install_button_icon = self.main_window.theme_manager.get_icon("update" if game_installed else "save")
|
||||||
|
|
||||||
|
installButton = AutoSizeButton(install_button_text, icon=install_button_icon)
|
||||||
|
installButton.setFixedSize(120, 40)
|
||||||
|
installButton.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
|
||||||
|
installButton.clicked.connect(lambda: self.main_window.launch_autoinstall(script_name))
|
||||||
|
buttons_layout.addWidget(installButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
detailsLayout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
contentFrameLayout.addWidget(detailsWidget)
|
||||||
|
mainLayout.addStretch()
|
||||||
|
|
||||||
|
self.main_window.stackedWidget.addWidget(detailPage)
|
||||||
|
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||||
|
self.main_window.currentDetailPage = detailPage
|
||||||
|
|
||||||
|
# Animation
|
||||||
|
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
|
||||||
|
detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
|
||||||
|
|
||||||
|
# Update page reference
|
||||||
|
self.main_window.currentDetailPage = detailPage
|
||||||
|
|
||||||
|
original_load = load_image_and_restore_effect
|
||||||
|
|
||||||
|
def enhanced_load():
|
||||||
|
original_load()
|
||||||
|
QTimer.singleShot(50, try_set_focus)
|
||||||
|
|
||||||
|
def try_set_focus():
|
||||||
|
if not (installButton and not installButton.isHidden()):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure page is active
|
||||||
|
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||||
|
detailPage.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
installButton.update()
|
||||||
|
detailPage.raise_()
|
||||||
|
self.main_window.activateWindow()
|
||||||
|
|
||||||
|
if installButton.hasFocus():
|
||||||
|
logger.debug("Install button successfully received focus")
|
||||||
|
else:
|
||||||
|
logger.debug("Retrying focus...")
|
||||||
|
QTimer.singleShot(20, retry_focus)
|
||||||
|
|
||||||
|
def retry_focus():
|
||||||
|
if not (installButton and not installButton.isHidden() and not installButton.hasFocus()):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process events to ensure UI state is updated
|
||||||
|
QApplication.processEvents()
|
||||||
|
self.main_window.activateWindow()
|
||||||
|
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||||
|
detailPage.raise_()
|
||||||
|
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
installButton.update()
|
||||||
|
|
||||||
|
if not installButton.hasFocus():
|
||||||
|
logger.debug("Final retry...")
|
||||||
|
installButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
QApplication.processEvents()
|
||||||
|
|
||||||
|
if installButton.hasFocus():
|
||||||
|
logger.debug("Install button received focus after final retry")
|
||||||
|
else:
|
||||||
|
logger.debug("Install button still doesn't have focus")
|
||||||
|
|
||||||
|
detail_animations.animate_detail_page(
|
||||||
|
detailPage,
|
||||||
|
enhanced_load,
|
||||||
|
cleanup_animation
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_autoinstall_game_installed(self, script_name, game_name):
|
||||||
|
"""Check if an auto-install game is already installed by looking for .desktop files."""
|
||||||
|
if not self.main_window.portproton_location:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Look for .desktop files that might match this game/script
|
||||||
|
try:
|
||||||
|
desktop_files = os.listdir(self.main_window.portproton_location)
|
||||||
|
for file in desktop_files:
|
||||||
|
if file.endswith('.desktop'):
|
||||||
|
# Check if the desktop file contains references to the script or game name
|
||||||
|
try:
|
||||||
|
with open(os.path.join(self.main_window.portproton_location, file), encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
if script_name.lower() in content.lower() or game_name.lower() in content.lower():
|
||||||
|
return True
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def toggleFavoriteInDetailPage(self, game_name, label):
|
||||||
|
favorites = read_favorites()
|
||||||
|
if game_name in favorites:
|
||||||
|
favorites.remove(game_name)
|
||||||
|
label.setText("☆")
|
||||||
|
else:
|
||||||
|
favorites.append(game_name)
|
||||||
|
label.setText("★")
|
||||||
|
save_favorites(favorites)
|
||||||
|
self.main_window.game_library_manager.update_game_grid()
|
||||||
|
|
||||||
|
def goBackDetailPage(self, page: QWidget | None) -> None:
|
||||||
|
if page is None or page != self.main_window.stackedWidget.currentWidget() or getattr(self, '_exit_animation_in_progress', False):
|
||||||
|
return
|
||||||
|
self._exit_animation_in_progress = True
|
||||||
|
self._detail_page_active = False
|
||||||
|
self._current_detail_page = None
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
"""Helper function to clean up after animation."""
|
||||||
|
try:
|
||||||
|
# Stop and clean up any existing animations for this page
|
||||||
|
if hasattr(self, '_animations') and page in self._animations:
|
||||||
|
try:
|
||||||
|
animation = self._animations[page]
|
||||||
|
if isinstance(animation, QAbstractAnimation):
|
||||||
|
if animation.state() == QAbstractAnimation.State.Running:
|
||||||
|
animation.stop()
|
||||||
|
# Since animation is set to delete when stopped, we don't manually delete it
|
||||||
|
del self._animations[page]
|
||||||
|
except (KeyError, RuntimeError):
|
||||||
|
pass # Animation already deleted or not found
|
||||||
|
|
||||||
|
# Ensure page is still valid before trying to remove it
|
||||||
|
# Check if page is still in the stacked widget by iterating through all widgets
|
||||||
|
page_found = False
|
||||||
|
for i in range(self.main_window.stackedWidget.count()):
|
||||||
|
if self.main_window.stackedWidget.widget(i) is page:
|
||||||
|
page_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if page_found:
|
||||||
|
# Remove the detail page widget
|
||||||
|
self.main_window.stackedWidget.removeWidget(page)
|
||||||
|
# Go back to the tab where the detail page was opened from
|
||||||
|
return_tab_index = getattr(self, '_return_to_tab_index', 0) # Default to library tab
|
||||||
|
self.main_window.stackedWidget.setCurrentIndex(return_tab_index)
|
||||||
|
|
||||||
|
# Ensure proper layout update after returning to the tab
|
||||||
|
# This is important when a refresh happened while detail page was open
|
||||||
|
if return_tab_index == 0: # Library tab
|
||||||
|
if hasattr(self.main_window, 'game_library_manager'):
|
||||||
|
QTimer.singleShot(10, lambda: self.main_window.game_library_manager.update_game_grid())
|
||||||
|
elif return_tab_index == 1: # Auto Install tab
|
||||||
|
# Force update of the auto install container layout
|
||||||
|
if hasattr(self.main_window, 'autoInstallContainer'):
|
||||||
|
QTimer.singleShot(10, lambda: self.main_window.autoInstallContainer.updateGeometry())
|
||||||
|
if hasattr(self.main_window, 'autoInstallContainerLayout'):
|
||||||
|
QTimer.singleShot(15, lambda: self.main_window.autoInstallContainerLayout.update())
|
||||||
|
else:
|
||||||
|
logger.debug("Page not found in stacked widget, may have been removed already")
|
||||||
|
|
||||||
|
# Clear references to avoid dangling references
|
||||||
|
if hasattr(self.main_window, 'currentDetailPage'):
|
||||||
|
self.main_window.currentDetailPage = None
|
||||||
|
if hasattr(self.main_window, 'current_exec_line'):
|
||||||
|
self.main_window.current_exec_line = None
|
||||||
|
if hasattr(self.main_window, 'current_play_button'):
|
||||||
|
self.main_window.current_play_button = None
|
||||||
|
|
||||||
|
self._exit_animation_in_progress = False
|
||||||
|
except RuntimeError:
|
||||||
|
# Widget was already deleted, which is expected after deleteLater()
|
||||||
|
logger.debug("Detail page already deleted during cleanup")
|
||||||
|
self._exit_animation_in_progress = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in cleanup: {e}", exc_info=True)
|
||||||
|
self._exit_animation_in_progress = False
|
||||||
|
|
||||||
|
# Start exit animation
|
||||||
|
try:
|
||||||
|
# Check if the page is still valid before starting animation
|
||||||
|
if page and not page.isHidden() and page.parent() is not None:
|
||||||
|
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
|
||||||
|
detail_animations.animate_detail_page_exit(page, cleanup)
|
||||||
|
else:
|
||||||
|
logger.warning("Detail page not valid, bypassing animation and cleaning up directly")
|
||||||
|
self._exit_animation_in_progress = False
|
||||||
|
cleanup()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error starting exit animation: {e}", exc_info=True)
|
||||||
|
self._exit_animation_in_progress = False
|
||||||
|
cleanup() # Fallback to cleanup if animation fails
|
||||||
|
|
||||||
|
def open_portproton_forum_topic(self, name):
|
||||||
|
result = self.portproton_api.get_forum_topic_slug(name)
|
||||||
|
base_url = "https://linux-gaming.ru/"
|
||||||
|
if result.startswith("search?q="):
|
||||||
|
url = QUrl(f"{base_url}{result}")
|
||||||
|
else:
|
||||||
|
url = QUrl(f"{base_url}t/{result}")
|
||||||
|
QDesktopServices.openUrl(url)
|
||||||
@@ -126,8 +126,6 @@ class Downloader(QObject):
|
|||||||
self._has_internet = True
|
self._has_internet = True
|
||||||
return self._has_internet
|
return self._has_internet
|
||||||
|
|
||||||
def reset_internet_check(self):
|
|
||||||
self._has_internet = None
|
|
||||||
|
|
||||||
def _get_url_lock(self, url):
|
def _get_url_lock(self, url):
|
||||||
with self._global_lock:
|
with self._global_lock:
|
||||||
@@ -247,9 +245,6 @@ class Downloader(QObject):
|
|||||||
with self._global_lock:
|
with self._global_lock:
|
||||||
self._cache.clear()
|
self._cache.clear()
|
||||||
|
|
||||||
def is_cached(self, url):
|
|
||||||
with self._global_lock:
|
|
||||||
return url in self._cache
|
|
||||||
|
|
||||||
def get_latest_legendary_release(self):
|
def get_latest_legendary_release(self):
|
||||||
"""Get the latest legendary release info from GitHub API."""
|
"""Get the latest legendary release info from GitHub API."""
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _
|
|||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
from portprotonqt.image_utils import load_pixmap_async
|
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.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 (
|
from portprotonqt.steam_api import (
|
||||||
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
|
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, call_steam_api
|
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
|
||||||
@@ -254,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Determine wrapper
|
# Determine wrapper
|
||||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
wrapper = get_portproton_start_command()
|
||||||
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
|
|
||||||
|
|
||||||
# Create launch script
|
# Create launch script
|
||||||
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
|
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
|
||||||
@@ -465,9 +458,13 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
|||||||
if downloaded_count == total_covers:
|
if downloaded_count == total_covers:
|
||||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||||
|
|
||||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
def on_steam_apps(steam_data: tuple[list | None, dict | None]):
|
||||||
nonlocal steam_appid
|
nonlocal steam_appid
|
||||||
steam_apps, steam_apps_index = steam_data
|
steam_apps, steam_apps_index = steam_data
|
||||||
|
if not steam_apps or not steam_apps_index:
|
||||||
|
logger.info(f"No Steam data available for EGS game {game_title}, skipping cover download")
|
||||||
|
callback((True, f"Game '{game_title}' added to Steam"))
|
||||||
|
return
|
||||||
matching_app = search_app(game_title, steam_apps_index)
|
matching_app = search_app(game_title, steam_apps_index)
|
||||||
steam_appid = matching_app.get("appid") if matching_app else None
|
steam_appid = matching_app.get("appid") if matching_app else None
|
||||||
|
|
||||||
@@ -562,49 +559,11 @@ def get_egs_game_description_async(
|
|||||||
cleaned = re.sub(r'[^a-z0-9 ]', '', title.lower()).strip()
|
cleaned = re.sub(r'[^a-z0-9 ]', '', title.lower()).strip()
|
||||||
return re.sub(r'\s+', '-', cleaned)
|
return re.sub(r'\s+', '-', cleaned)
|
||||||
|
|
||||||
def get_product_slug(namespace: str) -> str:
|
|
||||||
"""Fetches the product slug using the namespace via GraphQL."""
|
|
||||||
search_query = {
|
|
||||||
"query": """
|
|
||||||
query {
|
|
||||||
Catalog {
|
|
||||||
catalogNs(namespace: $namespace) {
|
|
||||||
mappings(pageType: "productHome") {
|
|
||||||
pageSlug
|
|
||||||
pageType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
"variables": {"namespace": namespace}
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
"https://launcher.store.epicgames.com/graphql",
|
|
||||||
json=search_query,
|
|
||||||
headers=headers,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = orjson.loads(response.content)
|
|
||||||
mappings = data.get("data", {}).get("Catalog", {}).get("catalogNs", {}).get("mappings", [])
|
|
||||||
for mapping in mappings:
|
|
||||||
if mapping.get("pageType") == "productHome":
|
|
||||||
return mapping.get("pageSlug", "")
|
|
||||||
logger.warning("No productHome slug found for namespace %s", namespace)
|
|
||||||
return ""
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.warning("Failed to fetch product slug for namespace %s: %s", namespace, str(e))
|
|
||||||
return ""
|
|
||||||
except orjson.JSONDecodeError:
|
|
||||||
logger.warning("Invalid JSON response for namespace %s", namespace)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def fetch_legacy_description(url: str) -> str:
|
def fetch_legacy_description(url: str) -> str:
|
||||||
"""Fetches description from the legacy API, handling DNS failures."""
|
"""Fetches description from the legacy API, handling DNS failures."""
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=headers, timeout=5)
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = orjson.loads(response.content)
|
data = orjson.loads(response.content)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -626,6 +585,9 @@ def get_egs_game_description_async(
|
|||||||
except requests.exceptions.ConnectionError as e:
|
except requests.exceptions.ConnectionError as e:
|
||||||
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
|
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
|
||||||
return ""
|
return ""
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning("Request timeout for legacy API %s", url)
|
||||||
|
return ""
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
|
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
|
||||||
return ""
|
return ""
|
||||||
@@ -677,7 +639,7 @@ def get_egs_game_description_async(
|
|||||||
url = "https://graphql.epicgames.com/graphql"
|
url = "https://graphql.epicgames.com/graphql"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(url, json=search_query, headers=headers, timeout=5)
|
response = requests.post(url, json=search_query, headers=headers, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = orjson.loads(response.content)
|
data = orjson.loads(response.content)
|
||||||
if namespace:
|
if namespace:
|
||||||
@@ -696,6 +658,9 @@ def get_egs_game_description_async(
|
|||||||
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
|
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
|
||||||
return element.get("description", ""), element.get("productSlug", "")
|
return element.get("description", ""), element.get("productSlug", "")
|
||||||
return "", ""
|
return "", ""
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning("GraphQL request timeout for %s with locale %s", app_name, locale)
|
||||||
|
return "", ""
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
|
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
|
||||||
return "", ""
|
return "", ""
|
||||||
@@ -724,6 +689,10 @@ def get_egs_game_description_async(
|
|||||||
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
|
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
|
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning("Legacy API request timed out for %s", app_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Unexpected error fetching legacy API for %s: %s", app_name, str(e))
|
||||||
|
|
||||||
# Step 3: If still no description and no namespace, try GraphQL with title
|
# Step 3: If still no description and no namespace, try GraphQL with title
|
||||||
if not description and not namespace:
|
if not description and not namespace:
|
||||||
@@ -938,8 +907,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
|||||||
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
|
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
|
||||||
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
|
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
|
||||||
|
|
||||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
def on_steam_apps(steam_data: tuple[list | None, dict | None]):
|
||||||
steam_apps, steam_apps_index = steam_data
|
steam_apps, steam_apps_index = steam_data
|
||||||
|
if not steam_apps or not steam_apps_index:
|
||||||
|
logger.info(f"No Steam data available for EGS game {title}, skipping appid lookup")
|
||||||
|
steam_appid = None
|
||||||
|
else:
|
||||||
matching_app = search_app(title, steam_apps_index)
|
matching_app = search_app(title, steam_apps_index)
|
||||||
steam_appid = matching_app.get("appid") if matching_app else None
|
steam_appid = matching_app.get("appid") if matching_app else None
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
|
from PySide6.QtGui import QPainter, QColor, QDesktopServices
|
||||||
from PySide6.QtCore import Signal, Property, Qt, QUrl
|
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
|
||||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
||||||
from collections.abc import Callable
|
|
||||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||||
from portprotonqt.localization import _
|
from portprotonqt.localization import _
|
||||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
|
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
|
||||||
@@ -10,7 +9,6 @@ from portprotonqt.custom_widgets import ClickableLabel
|
|||||||
from portprotonqt.portproton_api import PortProtonAPI
|
from portprotonqt.portproton_api import PortProtonAPI
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
from portprotonqt.animations import GameCardAnimations
|
from portprotonqt.animations import GameCardAnimations
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
class GameCard(QFrame):
|
class GameCard(QFrame):
|
||||||
borderWidthChanged = Signal()
|
borderWidthChanged = Signal()
|
||||||
@@ -101,7 +99,7 @@ class GameCard(QFrame):
|
|||||||
|
|
||||||
self.favoriteLabel = ClickableLabel(self.coverWidget)
|
self.favoriteLabel = ClickableLabel(self.coverWidget)
|
||||||
self.favoriteLabel.clicked.connect(self.toggle_favorite)
|
self.favoriteLabel.clicked.connect(self.toggle_favorite)
|
||||||
self.is_favorite = self.name in read_favorites()
|
self.is_favorite = self.name in set(read_favorites())
|
||||||
self.update_favorite_icon()
|
self.update_favorite_icon()
|
||||||
self.favoriteLabel.raise_()
|
self.favoriteLabel.raise_()
|
||||||
|
|
||||||
@@ -202,13 +200,27 @@ class GameCard(QFrame):
|
|||||||
self.update_cover_pixmap()
|
self.update_cover_pixmap()
|
||||||
|
|
||||||
def update_cover_pixmap(self):
|
def update_cover_pixmap(self):
|
||||||
if self.base_pixmap:
|
# Check if the coverLabel still exists before trying to update it
|
||||||
|
# This prevents the "Internal C++ object already deleted" error when
|
||||||
|
# the widget has been destroyed but the async callback still executes
|
||||||
|
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.base_pixmap and not self.base_pixmap.isNull():
|
||||||
scaled_width = int(self.base_card_width * self._scale)
|
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)
|
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))
|
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
|
||||||
|
try:
|
||||||
self.coverLabel.setPixmap(rounded_pixmap)
|
self.coverLabel.setPixmap(rounded_pixmap)
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted between the check and the call
|
||||||
|
pass
|
||||||
|
|
||||||
def _position_badges(self, current_width):
|
def _position_badges(self, current_width):
|
||||||
|
# Check if the card has been destroyed before updating
|
||||||
|
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||||
|
return
|
||||||
|
|
||||||
right_margin = int(8 * self._scale)
|
right_margin = int(8 * self._scale)
|
||||||
badge_spacing = int(current_width * 0.02)
|
badge_spacing = int(current_width * 0.02)
|
||||||
top_y = int(10 * self._scale)
|
top_y = int(10 * self._scale)
|
||||||
@@ -227,16 +239,28 @@ class GameCard(QFrame):
|
|||||||
if is_visible:
|
if is_visible:
|
||||||
badge_x = current_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_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
try:
|
||||||
badge.move(int(badge_x), int(badge_y))
|
badge.move(int(badge_x), int(badge_y))
|
||||||
badge_y_positions.append(badge_y + badge.height())
|
badge_y_positions.append(badge_y + badge.height())
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
self.anticheatLabel.raise_()
|
self.anticheatLabel.raise_()
|
||||||
self.protondbLabel.raise_()
|
self.protondbLabel.raise_()
|
||||||
self.portprotonLabel.raise_()
|
self.portprotonLabel.raise_()
|
||||||
self.egsLabel.raise_()
|
self.egsLabel.raise_()
|
||||||
self.steamLabel.raise_()
|
self.steamLabel.raise_()
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
pass
|
||||||
|
|
||||||
def update_scale(self):
|
def update_scale(self):
|
||||||
|
# Check if the card has been destroyed before updating
|
||||||
|
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||||
|
return
|
||||||
|
|
||||||
scaled_width = int(self.base_card_width * self._scale)
|
scaled_width = int(self.base_card_width * self._scale)
|
||||||
scaled_height = int(self.base_card_width * 1.8 * self._scale)
|
scaled_height = int(self.base_card_width * 1.8 * self._scale)
|
||||||
scaled_extra = int(self.base_extra_margin * self._scale)
|
scaled_extra = int(self.base_extra_margin * self._scale)
|
||||||
@@ -257,25 +281,42 @@ class GameCard(QFrame):
|
|||||||
icon_space = int(scaled_width * 0.012)
|
icon_space = int(scaled_width * 0.012)
|
||||||
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
|
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
|
||||||
if label is not None:
|
if label is not None:
|
||||||
|
try:
|
||||||
label.setFixedWidth(badge_width)
|
label.setFixedWidth(badge_width)
|
||||||
label.setIconSize(icon_size, icon_space)
|
label.setIconSize(icon_size, icon_space)
|
||||||
label.setCardWidth(scaled_width)
|
label.setCardWidth(scaled_width)
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
pass
|
||||||
|
|
||||||
self._position_badges(scaled_width)
|
self._position_badges(scaled_width)
|
||||||
|
|
||||||
if self.base_font_size is not None:
|
if self.base_font_size is not None:
|
||||||
|
try:
|
||||||
font = self.nameLabel.font()
|
font = self.nameLabel.font()
|
||||||
new_font_size = self.base_font_size * self._scale
|
new_font_size = self.base_font_size * self._scale
|
||||||
if new_font_size > 0:
|
if new_font_size > 0:
|
||||||
font.setPointSizeF(new_font_size)
|
font.setPointSizeF(new_font_size)
|
||||||
self.nameLabel.setFont(font)
|
self.nameLabel.setFont(font)
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
self.shadow.setBlurRadius(int(20 * self._scale))
|
self.shadow.setBlurRadius(int(20 * self._scale))
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
self.updateGeometry()
|
self.updateGeometry()
|
||||||
self.update()
|
self.update()
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
pass
|
||||||
|
|
||||||
# Ensure parent layout is updated safely
|
# Ensure parent layout is updated safely
|
||||||
|
try:
|
||||||
parent = self.parentWidget()
|
parent = self.parentWidget()
|
||||||
if parent:
|
if parent:
|
||||||
layout = parent.layout()
|
layout = parent.layout()
|
||||||
@@ -284,6 +325,9 @@ class GameCard(QFrame):
|
|||||||
layout.activate()
|
layout.activate()
|
||||||
layout.update()
|
layout.update()
|
||||||
parent.updateGeometry()
|
parent.updateGeometry()
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
pass
|
||||||
|
|
||||||
def update_card_size(self, new_width: int):
|
def update_card_size(self, new_width: int):
|
||||||
self.base_card_width = new_width
|
self.base_card_width = new_width
|
||||||
@@ -291,6 +335,10 @@ class GameCard(QFrame):
|
|||||||
self.update_scale()
|
self.update_scale()
|
||||||
|
|
||||||
def update_badge_visibility(self, display_filter: str):
|
def update_badge_visibility(self, display_filter: str):
|
||||||
|
# Check if the card has been destroyed before updating
|
||||||
|
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||||
|
return
|
||||||
|
|
||||||
self.display_filter = display_filter
|
self.display_filter = display_filter
|
||||||
self.steam_visible = (str(self.game_source).lower() == "steam" and self.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.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
||||||
@@ -298,11 +346,15 @@ class GameCard(QFrame):
|
|||||||
protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
|
protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
|
||||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
|
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
|
||||||
|
|
||||||
|
try:
|
||||||
self.steamLabel.setVisible(self.steam_visible)
|
self.steamLabel.setVisible(self.steam_visible)
|
||||||
self.egsLabel.setVisible(self.egs_visible)
|
self.egsLabel.setVisible(self.egs_visible)
|
||||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||||
self.protondbLabel.setVisible(protondb_visible)
|
self.protondbLabel.setVisible(protondb_visible)
|
||||||
self.anticheatLabel.setVisible(anticheat_visible)
|
self.anticheatLabel.setVisible(anticheat_visible)
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
return
|
||||||
|
|
||||||
scaled_width = int(self.base_card_width * self._scale)
|
scaled_width = int(self.base_card_width * self._scale)
|
||||||
self._position_badges(scaled_width)
|
self._position_badges(scaled_width)
|
||||||
@@ -397,20 +449,43 @@ class GameCard(QFrame):
|
|||||||
QDesktopServices.openUrl(url)
|
QDesktopServices.openUrl(url)
|
||||||
|
|
||||||
def update_favorite_icon(self):
|
def update_favorite_icon(self):
|
||||||
|
# Check if the card has been destroyed before updating
|
||||||
|
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
if self.is_favorite:
|
if self.is_favorite:
|
||||||
self.favoriteLabel.setText("★")
|
self.favoriteLabel.setText("★")
|
||||||
else:
|
else:
|
||||||
self.favoriteLabel.setText("☆")
|
self.favoriteLabel.setText("☆")
|
||||||
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
parent = self.parent()
|
||||||
|
while parent:
|
||||||
|
if hasattr(parent, 'game_library_manager'):
|
||||||
|
# Access using getattr with default to avoid Ruff B009 warning
|
||||||
|
manager = getattr(parent, 'game_library_manager', None)
|
||||||
|
if manager is not None:
|
||||||
|
QTimer.singleShot(0, manager.update_game_grid)
|
||||||
|
break
|
||||||
|
parent = parent.parent()
|
||||||
|
except RuntimeError:
|
||||||
|
# Handle the case where the Qt object was deleted
|
||||||
|
pass
|
||||||
|
|
||||||
def toggle_favorite(self):
|
def toggle_favorite(self):
|
||||||
favorites = read_favorites()
|
favorites = read_favorites()
|
||||||
|
favorites_set = set(favorites)
|
||||||
if self.is_favorite:
|
if self.is_favorite:
|
||||||
if self.name in favorites:
|
if self.name in favorites_set:
|
||||||
favorites.remove(self.name)
|
favorites.remove(self.name)
|
||||||
self.is_favorite = False
|
self.is_favorite = False
|
||||||
else:
|
else:
|
||||||
if self.name not in favorites:
|
if self.name not in favorites_set:
|
||||||
favorites.append(self.name)
|
favorites.append(self.name)
|
||||||
self.is_favorite = True
|
self.is_favorite = True
|
||||||
save_favorites(favorites)
|
save_favorites(favorites)
|
||||||
@@ -443,9 +518,10 @@ class GameCard(QFrame):
|
|||||||
self.update_scale()
|
self.update_scale()
|
||||||
self.scaleChanged.emit()
|
self.scaleChanged.emit()
|
||||||
|
|
||||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
|
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=borderWidthChanged)
|
||||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
|
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=gradientAngleChanged)
|
||||||
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
|
scale = Property(float, getScale, setScale, None, "", notify=scaleChanged)
|
||||||
|
|
||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
super().paintEvent(event)
|
super().paintEvent(event)
|
||||||
@@ -484,6 +560,23 @@ class GameCard(QFrame):
|
|||||||
)
|
)
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Clean up animations to prevent memory leaks when the card is destroyed."""
|
||||||
|
if hasattr(self, 'animations') and self.animations:
|
||||||
|
try:
|
||||||
|
self.animations.cleanup()
|
||||||
|
except RuntimeError:
|
||||||
|
# Object already deleted
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""Destructor to ensure cleanup happens."""
|
||||||
|
try:
|
||||||
|
self.cleanup()
|
||||||
|
except RuntimeError:
|
||||||
|
# Object already deleted
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def keyPressEvent(self, event):
|
def keyPressEvent(self, event):
|
||||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
from portprotonqt.game_card import GameCard
|
from portprotonqt.game_card import GameCard
|
||||||
|
from portprotonqt.search_utils import SearchOptimizer, ThreadedSearch
|
||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
|
||||||
from PySide6.QtCore import Qt, QTimer
|
from PySide6.QtCore import Qt, QTimer
|
||||||
from portprotonqt.custom_widgets import FlowLayout
|
from portprotonqt.custom_widgets import FlowLayout
|
||||||
@@ -32,9 +33,10 @@ class MainWindowProtocol(Protocol):
|
|||||||
|
|
||||||
# Required attributes
|
# Required attributes
|
||||||
searchEdit: CustomLineEdit
|
searchEdit: CustomLineEdit
|
||||||
_last_card_width: int
|
card_width: int
|
||||||
current_hovered_card: GameCard | None
|
current_hovered_card: GameCard | None
|
||||||
current_focused_card: GameCard | None
|
current_focused_card: GameCard | None
|
||||||
|
gamesListWidget: QWidget | None
|
||||||
|
|
||||||
class GameLibraryManager:
|
class GameLibraryManager:
|
||||||
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
|
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
|
||||||
@@ -54,6 +56,9 @@ class GameLibraryManager:
|
|||||||
self.pending_deletions = deque()
|
self.pending_deletions = deque()
|
||||||
self.is_filtering = False
|
self.is_filtering = False
|
||||||
self.dirty = False
|
self.dirty = False
|
||||||
|
# Initialize search optimizer
|
||||||
|
self.search_optimizer = SearchOptimizer()
|
||||||
|
self.search_thread: ThreadedSearch | None = None
|
||||||
|
|
||||||
def create_games_library_widget(self):
|
def create_games_library_widget(self):
|
||||||
"""Creates the games library widget with search, grid, and slider."""
|
"""Creates the games library widget with search, grid, and slider."""
|
||||||
@@ -127,6 +132,7 @@ class GameLibraryManager:
|
|||||||
self.card_width = self.sizeSlider.value()
|
self.card_width = self.sizeSlider.value()
|
||||||
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
||||||
save_card_size(self.card_width)
|
save_card_size(self.card_width)
|
||||||
|
self.main_window.card_width = self.card_width
|
||||||
for card in self.game_card_cache.values():
|
for card in self.game_card_cache.values():
|
||||||
card.update_card_size(self.card_width)
|
card.update_card_size(self.card_width)
|
||||||
self.update_game_grid()
|
self.update_game_grid()
|
||||||
@@ -159,12 +165,18 @@ class GameLibraryManager:
|
|||||||
|
|
||||||
if is_focused:
|
if is_focused:
|
||||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||||
|
try:
|
||||||
self.main_window.current_hovered_card._hovered = False
|
self.main_window.current_hovered_card._hovered = False
|
||||||
self.main_window.current_hovered_card.leaveEvent(None)
|
self.main_window.current_hovered_card.leaveEvent(None)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Card already deleted
|
||||||
self.main_window.current_hovered_card = None
|
self.main_window.current_hovered_card = None
|
||||||
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
||||||
|
try:
|
||||||
self.main_window.current_focused_card._focused = False
|
self.main_window.current_focused_card._focused = False
|
||||||
self.main_window.current_focused_card.clearFocus()
|
self.main_window.current_focused_card.clearFocus()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Card already deleted
|
||||||
self.main_window.current_focused_card = card
|
self.main_window.current_focused_card = card
|
||||||
else:
|
else:
|
||||||
if self.main_window.current_focused_card == card:
|
if self.main_window.current_focused_card == card:
|
||||||
@@ -185,11 +197,19 @@ class GameLibraryManager:
|
|||||||
|
|
||||||
if is_hovered:
|
if is_hovered:
|
||||||
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
||||||
|
try:
|
||||||
|
if self.main_window.current_focused_card:
|
||||||
self.main_window.current_focused_card._focused = False
|
self.main_window.current_focused_card._focused = False
|
||||||
self.main_window.current_focused_card.clearFocus()
|
self.main_window.current_focused_card.clearFocus()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Card already deleted
|
||||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||||
|
try:
|
||||||
|
if self.main_window.current_hovered_card:
|
||||||
self.main_window.current_hovered_card._hovered = False
|
self.main_window.current_hovered_card._hovered = False
|
||||||
self.main_window.current_hovered_card.leaveEvent(None)
|
self.main_window.current_hovered_card.leaveEvent(None)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Card already deleted
|
||||||
self.main_window.current_hovered_card = card
|
self.main_window.current_hovered_card = card
|
||||||
else:
|
else:
|
||||||
if self.main_window.current_hovered_card == card:
|
if self.main_window.current_hovered_card == card:
|
||||||
@@ -208,6 +228,10 @@ class GameLibraryManager:
|
|||||||
if games_list is not None:
|
if games_list is not None:
|
||||||
self.filtered_games = games_list
|
self.filtered_games = games_list
|
||||||
self.dirty = True # Full rebuild only for non-filter
|
self.dirty = True # Full rebuild only for non-filter
|
||||||
|
else:
|
||||||
|
# When filtering, we want to update with the current filtered_games
|
||||||
|
# which has already been set by _perform_search
|
||||||
|
pass
|
||||||
self.is_filtering = is_filter
|
self.is_filtering = is_filter
|
||||||
self._pending_update = True
|
self._pending_update = True
|
||||||
|
|
||||||
@@ -216,6 +240,20 @@ class GameLibraryManager:
|
|||||||
else:
|
else:
|
||||||
self._update_game_grid_immediate()
|
self._update_game_grid_immediate()
|
||||||
|
|
||||||
|
def force_update_cards_library(self):
|
||||||
|
if self.gamesListWidget and self.gamesListLayout:
|
||||||
|
# Use singleShot to ensure UI updates happen after all other operations complete
|
||||||
|
# This prevents potential freezing in PySide 6.10.1
|
||||||
|
QTimer.singleShot(0, self._perform_force_update)
|
||||||
|
|
||||||
|
def _perform_force_update(self):
|
||||||
|
"""Perform the actual force update on the layout."""
|
||||||
|
if self.gamesListLayout:
|
||||||
|
self.gamesListLayout.invalidate()
|
||||||
|
if self.gamesListWidget:
|
||||||
|
self.gamesListWidget.adjustSize()
|
||||||
|
self.gamesListWidget.updateGeometry()
|
||||||
|
|
||||||
def _update_game_grid_immediate(self):
|
def _update_game_grid_immediate(self):
|
||||||
"""Updates the game grid with the provided or current game list."""
|
"""Updates the game grid with the provided or current game list."""
|
||||||
if self.gamesListLayout is None or self.gamesListWidget is None:
|
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||||
@@ -224,8 +262,9 @@ class GameLibraryManager:
|
|||||||
search_text = self.main_window.searchEdit.text().strip().lower()
|
search_text = self.main_window.searchEdit.text().strip().lower()
|
||||||
|
|
||||||
if self.is_filtering:
|
if self.is_filtering:
|
||||||
# Filter mode: do not change layout, only hide/show cards
|
# Filter mode: use the pre-computed filtered_games from optimized search
|
||||||
self._apply_filter_visibility(search_text)
|
# This means we already have the exact games to show
|
||||||
|
self._update_search_results(search_text)
|
||||||
else:
|
else:
|
||||||
# Full update: sorting, removal/addition, reorganization
|
# Full update: sorting, removal/addition, reorganization
|
||||||
games_list = self.filtered_games if self.filtered_games else self.games
|
games_list = self.filtered_games if self.filtered_games else self.games
|
||||||
@@ -253,8 +292,9 @@ class GameLibraryManager:
|
|||||||
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
|
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
|
# Quick partition: Sort favorites and non-favorites separately, then merge
|
||||||
fav_games = [g for g in games_list if g[0] in favorites]
|
favorites_set = set(favorites) # Convert to set for O(1) lookup
|
||||||
non_fav_games = [g for g in games_list if g[0] not in favorites]
|
fav_games = [g for g in games_list if g[0] in favorites_set]
|
||||||
|
non_fav_games = [g for g in games_list if g[0] not in favorites_set]
|
||||||
sorted_fav = sorted(fav_games, key=partition_sort_key)
|
sorted_fav = sorted(fav_games, key=partition_sort_key)
|
||||||
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
|
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
|
||||||
sorted_games = sorted_fav + sorted_non_fav
|
sorted_games = sorted_fav + sorted_non_fav
|
||||||
@@ -343,31 +383,74 @@ class GameLibraryManager:
|
|||||||
if self.gamesListLayout is not None:
|
if self.gamesListLayout is not None:
|
||||||
self.gamesListLayout.update()
|
self.gamesListLayout.update()
|
||||||
self.gamesListWidget.updateGeometry()
|
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
|
self.is_filtering = False # Reset flag in any case
|
||||||
|
|
||||||
def _apply_filter_visibility(self, search_text: str):
|
def _update_search_results(self, search_text: str = ""):
|
||||||
"""Applies visibility to cards based on search, without changing the layout."""
|
"""Update the grid with pre-computed search results."""
|
||||||
visible_count = 0
|
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||||
for game_key, card in self.game_card_cache.items():
|
return
|
||||||
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 geometry update so FlowLayout accounts for hidden widgets
|
# Batch layout updates
|
||||||
|
self.gamesListWidget.setUpdatesEnabled(False)
|
||||||
if self.gamesListLayout is not None:
|
if self.gamesListLayout is not None:
|
||||||
|
self.gamesListLayout.setEnabled(False) # Disable layout during batch
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create set of keys for current filtered games for fast lookup
|
||||||
|
filtered_keys = {(game[0], game[4]) for game in self.filtered_games} # (name, exec_line)
|
||||||
|
|
||||||
|
# Process existing cards: show cards that are in filtered results, hide others
|
||||||
|
cards_to_hide = []
|
||||||
|
for card_key, card in self.game_card_cache.items():
|
||||||
|
if card_key in filtered_keys:
|
||||||
|
# Card should be visible
|
||||||
|
if not card.isVisible():
|
||||||
|
card.setVisible(True)
|
||||||
|
else:
|
||||||
|
# Card should be hidden
|
||||||
|
if card.isVisible():
|
||||||
|
card.setVisible(False)
|
||||||
|
cards_to_hide.append(card_key)
|
||||||
|
|
||||||
|
# Now add any missing cards that are in filtered results but not in cache
|
||||||
|
cards_to_add = []
|
||||||
|
for game_data in self.filtered_games:
|
||||||
|
game_name = game_data[0]
|
||||||
|
exec_line = game_data[4]
|
||||||
|
game_key = (game_name, exec_line)
|
||||||
|
|
||||||
|
if game_key not in self.game_card_cache:
|
||||||
|
if self.context_menu_manager is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
card = self._create_game_card(game_data)
|
||||||
|
self.game_card_cache[game_key] = card
|
||||||
|
card.setVisible(True) # New cards should be visible
|
||||||
|
cards_to_add.append((game_key, card))
|
||||||
|
|
||||||
|
# Add new cards to layout
|
||||||
|
for _game_key, card in cards_to_add:
|
||||||
|
self.gamesListLayout.addWidget(card)
|
||||||
|
|
||||||
|
# Remove cards that are no longer needed (if any)
|
||||||
|
# Note: we're not removing them completely as they might be needed later
|
||||||
|
# Instead, we just hide them and they'll be reused if needed
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if self.gamesListLayout is not None:
|
||||||
|
self.gamesListLayout.setEnabled(True)
|
||||||
|
self.gamesListWidget.setUpdatesEnabled(True)
|
||||||
|
if self.gamesListLayout is not None:
|
||||||
|
self.gamesListLayout.update()
|
||||||
|
self.gamesListWidget.updateGeometry()
|
||||||
|
|
||||||
|
self.force_update_cards_library()
|
||||||
|
|
||||||
self.gamesListLayout.update()
|
self.gamesListLayout.update()
|
||||||
if self.gamesListWidget is not None:
|
if self.gamesListWidget is not None:
|
||||||
self.gamesListWidget.updateGeometry()
|
self.gamesListWidget.updateGeometry()
|
||||||
self.main_window._last_card_width = self.card_width
|
|
||||||
|
|
||||||
# If search is empty, load images for visible ones
|
# If search is empty, load images for visible ones
|
||||||
if not search_text:
|
if not search_text:
|
||||||
@@ -380,6 +463,7 @@ class GameLibraryManager:
|
|||||||
select_callback=self.main_window.openGameDetailPage,
|
select_callback=self.main_window.openGameDetailPage,
|
||||||
theme=self.theme,
|
theme=self.theme,
|
||||||
card_width=self.card_width,
|
card_width=self.card_width,
|
||||||
|
parent=self.gamesListWidget,
|
||||||
context_menu_manager=self.context_menu_manager
|
context_menu_manager=self.context_menu_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -402,6 +486,11 @@ class GameLibraryManager:
|
|||||||
def _flush_deletions(self):
|
def _flush_deletions(self):
|
||||||
"""Delete pending widgets off the main update cycle."""
|
"""Delete pending widgets off the main update cycle."""
|
||||||
for card in list(self.pending_deletions):
|
for card in list(self.pending_deletions):
|
||||||
|
# Clear any references to this card if it's currently focused/hovered
|
||||||
|
if self.main_window.current_focused_card == card:
|
||||||
|
self.main_window.current_focused_card = None
|
||||||
|
if self.main_window.current_hovered_card == card:
|
||||||
|
self.main_window.current_hovered_card = None
|
||||||
card.deleteLater()
|
card.deleteLater()
|
||||||
self.pending_deletions.remove(card)
|
self.pending_deletions.remove(card)
|
||||||
|
|
||||||
@@ -409,24 +498,61 @@ class GameLibraryManager:
|
|||||||
"""Clears all widgets from the layout."""
|
"""Clears all widgets from the layout."""
|
||||||
if layout is None:
|
if layout is None:
|
||||||
return
|
return
|
||||||
|
# Remove all widgets from the layout and clean up caches
|
||||||
while layout.count():
|
while layout.count():
|
||||||
child = layout.takeAt(0)
|
child = layout.takeAt(0)
|
||||||
if child.widget():
|
if child.widget():
|
||||||
widget = child.widget()
|
widget = child.widget()
|
||||||
|
# Clean up cache if widget exists in it
|
||||||
for key, card in list(self.game_card_cache.items()):
|
for key, card in list(self.game_card_cache.items()):
|
||||||
if card == widget:
|
if card == widget:
|
||||||
del self.game_card_cache[key]
|
del self.game_card_cache[key]
|
||||||
if key in self.pending_images:
|
if key in self.pending_images:
|
||||||
del self.pending_images[key]
|
del self.pending_images[key]
|
||||||
|
break
|
||||||
|
# Always schedule widget for deletion regardless of cache state
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
|
|
||||||
|
# Also clear the cache completely if needed (in case layout wasn't in sync)
|
||||||
|
self.game_card_cache.clear()
|
||||||
|
self.pending_images.clear()
|
||||||
|
|
||||||
def set_games(self, games: list[tuple]):
|
def set_games(self, games: list[tuple]):
|
||||||
"""Sets the games list and updates the filtered games."""
|
"""Sets the games list and updates the filtered games."""
|
||||||
self.games = games
|
self.games = games
|
||||||
self.filtered_games = self.games
|
self.filtered_games = self.games
|
||||||
|
|
||||||
|
# Build search indices for fast searching
|
||||||
|
self._build_search_indices(games)
|
||||||
|
|
||||||
self.dirty = True # Full resort needed
|
self.dirty = True # Full resort needed
|
||||||
self.update_game_grid()
|
self.update_game_grid()
|
||||||
|
|
||||||
|
def _build_search_indices(self, games: list[tuple]):
|
||||||
|
"""Build search indices for fast searching."""
|
||||||
|
# Prepare items for indexing: (search_key, game_data)
|
||||||
|
# We'll index by game name (index 0) and potentially other fields
|
||||||
|
items = []
|
||||||
|
for game in games:
|
||||||
|
# game is a tuple: (name, description, cover, appid, exec_line, controller_support,
|
||||||
|
# last_launch, formatted_playtime, protondb_tier, anticheat_status,
|
||||||
|
# last_played_timestamp, playtime_seconds, game_source)
|
||||||
|
name = str(game[0]).lower() if game[0] else ""
|
||||||
|
description = str(game[1]).lower() if game[1] else ""
|
||||||
|
|
||||||
|
# Create multiple search entries for better matching
|
||||||
|
items.append((name, game)) # Exact name
|
||||||
|
# Add other searchable fields if needed
|
||||||
|
if description:
|
||||||
|
items.append((description, game))
|
||||||
|
|
||||||
|
# Also add individual words from the name for partial matching
|
||||||
|
for word in name.split():
|
||||||
|
if len(word) > 2: # Only index words longer than 2 characters
|
||||||
|
items.append((word, game))
|
||||||
|
|
||||||
|
self.search_optimizer.build_indices(items)
|
||||||
|
|
||||||
def add_game_incremental(self, game_data: tuple):
|
def add_game_incremental(self, game_data: tuple):
|
||||||
"""Add a single game without full reload."""
|
"""Add a single game without full reload."""
|
||||||
self.games.append(game_data)
|
self.games.append(game_data)
|
||||||
@@ -450,4 +576,54 @@ class GameLibraryManager:
|
|||||||
|
|
||||||
def filter_games_delayed(self):
|
def filter_games_delayed(self):
|
||||||
"""Filters games based on search text and updates the grid."""
|
"""Filters games based on search text and updates the grid."""
|
||||||
|
search_text = self.main_window.searchEdit.text().strip().lower()
|
||||||
|
|
||||||
|
if not search_text:
|
||||||
|
# If search is empty, show all games
|
||||||
|
self.filtered_games = self.games
|
||||||
|
self.update_game_grid(is_filter=True)
|
||||||
|
else:
|
||||||
|
# Use the optimized search
|
||||||
|
self._perform_search(search_text)
|
||||||
|
|
||||||
|
def _perform_search(self, search_text: str):
|
||||||
|
"""Perform the actual search using optimized search algorithms."""
|
||||||
|
if not search_text:
|
||||||
|
self.filtered_games = self.games
|
||||||
|
self.update_game_grid(is_filter=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use exact search first
|
||||||
|
exact_result = self.search_optimizer.exact_search(search_text)
|
||||||
|
if exact_result:
|
||||||
|
# If exact match found, show only that game
|
||||||
|
self.filtered_games = [exact_result]
|
||||||
|
self.update_game_grid(is_filter=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try prefix search
|
||||||
|
prefix_results = self.search_optimizer.prefix_search(search_text)
|
||||||
|
if prefix_results:
|
||||||
|
# Get the actual game data from the prefix matches
|
||||||
|
filtered_games = []
|
||||||
|
for _match_text, game_data in prefix_results:
|
||||||
|
if game_data not in filtered_games: # Avoid duplicates
|
||||||
|
filtered_games.append(game_data)
|
||||||
|
self.filtered_games = filtered_games
|
||||||
|
self.update_game_grid(is_filter=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Finally, try fuzzy search
|
||||||
|
fuzzy_results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=60.0)
|
||||||
|
if fuzzy_results:
|
||||||
|
# Get the actual game data from the fuzzy matches
|
||||||
|
filtered_games = []
|
||||||
|
for _match_text, game_data, _score in fuzzy_results:
|
||||||
|
if game_data not in filtered_games: # Avoid duplicates
|
||||||
|
filtered_games.append(game_data)
|
||||||
|
self.filtered_games = filtered_games
|
||||||
|
self.update_game_grid(is_filter=True)
|
||||||
|
else:
|
||||||
|
# If no results found, show empty list
|
||||||
|
self.filtered_games = []
|
||||||
self.update_game_grid(is_filter=True)
|
self.update_game_grid(is_filter=True)
|
||||||
|
|||||||
1110
portprotonqt/get_wine_module.py
Normal file
@@ -36,6 +36,17 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
|||||||
current_theme_name = read_theme_from_config()
|
current_theme_name = read_theme_from_config()
|
||||||
|
|
||||||
def finish_with(pixmap: QPixmap):
|
def finish_with(pixmap: QPixmap):
|
||||||
|
# Check if pixmap is valid before attempting to scale it
|
||||||
|
if pixmap.isNull():
|
||||||
|
# Create a default placeholder pixmap instead of trying to scale a null pixmap
|
||||||
|
placeholder_pixmap = QPixmap(width, height)
|
||||||
|
placeholder_pixmap.fill(QColor("#333333"))
|
||||||
|
painter = QPainter(placeholder_pixmap)
|
||||||
|
painter.setPen(QPen(QColor("white")))
|
||||||
|
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||||
|
painter.end()
|
||||||
|
callback(placeholder_pixmap)
|
||||||
|
else:
|
||||||
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
||||||
x = (scaled.width() - width) // 2
|
x = (scaled.width() - width) // 2
|
||||||
y = (scaled.height() - height) // 2
|
y = (scaled.height() - height) // 2
|
||||||
@@ -58,6 +69,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
|||||||
local_path = os.path.join(image_folder, f"{appid}.jpg")
|
local_path = os.path.join(image_folder, f"{appid}.jpg")
|
||||||
if os.path.exists(local_path):
|
if os.path.exists(local_path):
|
||||||
pixmap = QPixmap(local_path)
|
pixmap = QPixmap(local_path)
|
||||||
|
# Check if the pixmap loaded successfully
|
||||||
|
if pixmap.isNull():
|
||||||
|
logger.warning(f"Failed to load image from {local_path}")
|
||||||
finish_with(pixmap)
|
finish_with(pixmap)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -69,6 +83,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
|||||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||||
if placeholder_path and QFile.exists(placeholder_path):
|
if placeholder_path and QFile.exists(placeholder_path):
|
||||||
pixmap.load(placeholder_path)
|
pixmap.load(placeholder_path)
|
||||||
|
if pixmap.isNull():
|
||||||
|
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
|
||||||
else:
|
else:
|
||||||
pixmap = QPixmap(width, height)
|
pixmap = QPixmap(width, height)
|
||||||
pixmap.fill(QColor("#333333"))
|
pixmap.fill(QColor("#333333"))
|
||||||
@@ -83,11 +99,19 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка обработки URL {cover}: {e}")
|
logger.error(f"Ошибка обработки URL {cover}: {e}")
|
||||||
|
|
||||||
if cover and cover.startswith(("http://", "https://")):
|
# SteamGridDB (SGDB)
|
||||||
|
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||||
try:
|
try:
|
||||||
local_path = os.path.join(image_folder, f"{app_name}.jpg")
|
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):
|
if os.path.exists(local_path):
|
||||||
pixmap = QPixmap(local_path)
|
pixmap = QPixmap(local_path)
|
||||||
|
# Check if the pixmap loaded successfully
|
||||||
|
if pixmap.isNull():
|
||||||
|
logger.warning(f"Failed to load image from {local_path}")
|
||||||
finish_with(pixmap)
|
finish_with(pixmap)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -99,6 +123,45 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
|||||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||||
if placeholder_path and QFile.exists(placeholder_path):
|
if placeholder_path and QFile.exists(placeholder_path):
|
||||||
pixmap.load(placeholder_path)
|
pixmap.load(placeholder_path)
|
||||||
|
if pixmap.isNull():
|
||||||
|
logger.warning(f"Failed to load placeholder image from {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")
|
||||||
|
if os.path.exists(local_path):
|
||||||
|
pixmap = QPixmap(local_path)
|
||||||
|
# Check if the pixmap loaded successfully
|
||||||
|
if pixmap.isNull():
|
||||||
|
logger.warning(f"Failed to load image from {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)
|
||||||
|
if pixmap.isNull():
|
||||||
|
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
|
||||||
else:
|
else:
|
||||||
pixmap = QPixmap(width, height)
|
pixmap = QPixmap(width, height)
|
||||||
pixmap.fill(QColor("#333333"))
|
pixmap.fill(QColor("#333333"))
|
||||||
@@ -115,6 +178,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
|||||||
|
|
||||||
if cover and QFile.exists(cover):
|
if cover and QFile.exists(cover):
|
||||||
pixmap = QPixmap(cover)
|
pixmap = QPixmap(cover)
|
||||||
|
# Check if the pixmap loaded successfully
|
||||||
|
if pixmap.isNull():
|
||||||
|
logger.warning(f"Failed to load image from {cover}")
|
||||||
finish_with(pixmap)
|
finish_with(pixmap)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -122,6 +188,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
|||||||
pixmap = QPixmap()
|
pixmap = QPixmap()
|
||||||
if placeholder_path and QFile.exists(placeholder_path):
|
if placeholder_path and QFile.exists(placeholder_path):
|
||||||
pixmap.load(placeholder_path)
|
pixmap.load(placeholder_path)
|
||||||
|
if pixmap.isNull():
|
||||||
|
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
|
||||||
else:
|
else:
|
||||||
pixmap = QPixmap(width, height)
|
pixmap = QPixmap(width, height)
|
||||||
pixmap.fill(QColor("#333333"))
|
pixmap.fill(QColor("#333333"))
|
||||||
@@ -131,9 +199,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
|||||||
painter.end()
|
painter.end()
|
||||||
finish_with(pixmap)
|
finish_with(pixmap)
|
||||||
|
|
||||||
with queue_lock:
|
# Submit the process_image function directly to the executor
|
||||||
image_load_queue.put(process_image)
|
# This avoids the potential blocking issue with queue.get() in PySide 6.10.1
|
||||||
image_executor.submit(lambda: image_load_queue.get()())
|
image_executor.submit(process_image)
|
||||||
|
|
||||||
def round_corners(pixmap, radius):
|
def round_corners(pixmap, radius):
|
||||||
"""
|
"""
|
||||||
@@ -141,7 +209,15 @@ def round_corners(pixmap, radius):
|
|||||||
"""
|
"""
|
||||||
if pixmap.isNull():
|
if pixmap.isNull():
|
||||||
return pixmap
|
return pixmap
|
||||||
|
|
||||||
|
# Check if radius is valid to prevent issues
|
||||||
|
if radius <= 0:
|
||||||
|
return pixmap
|
||||||
|
|
||||||
size = pixmap.size()
|
size = pixmap.size()
|
||||||
|
if size.width() <= 0 or size.height() <= 0:
|
||||||
|
return pixmap
|
||||||
|
|
||||||
rounded = QPixmap(size)
|
rounded = QPixmap(size)
|
||||||
rounded.fill(QColor(0, 0, 0, 0))
|
rounded.fill(QColor(0, 0, 0, 0))
|
||||||
painter = QPainter(rounded)
|
painter = QPainter(rounded)
|
||||||
@@ -244,6 +320,17 @@ class FullscreenDialog(QDialog):
|
|||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
|
|
||||||
pixmap, caption = self.images[self.current_index]
|
pixmap, caption = self.images[self.current_index]
|
||||||
|
# Check if pixmap is valid before attempting to scale it
|
||||||
|
if pixmap.isNull():
|
||||||
|
# Create a default placeholder pixmap instead of trying to scale a null pixmap
|
||||||
|
placeholder_pixmap = QPixmap(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
|
||||||
|
placeholder_pixmap.fill(QColor("#333333"))
|
||||||
|
painter = QPainter(placeholder_pixmap)
|
||||||
|
painter.setPen(QPen(QColor("white")))
|
||||||
|
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||||
|
painter.end()
|
||||||
|
self.imageLabel.setPixmap(placeholder_pixmap)
|
||||||
|
else:
|
||||||
# Учитываем devicePixelRatio для масштабирования высокого качества
|
# Учитываем devicePixelRatio для масштабирования высокого качества
|
||||||
device_pixel_ratio = get_device_pixel_ratio()
|
device_pixel_ratio = get_device_pixel_ratio()
|
||||||
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
|
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
|
||||||
|
|||||||
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', ';', ':', '_']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
# German (Germany) translations for PortProtonQt.
|
# German (Germany) translations for PortProtonQt.
|
||||||
# Copyright (C) 2025 boria138
|
# Copyright (C) 2026 boria138
|
||||||
# This file is distributed under the same license as the PortProtonQt
|
# This file is distributed under the same license as the PortProtonQt
|
||||||
# project.
|
# project.
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
|
||||||
#
|
#
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
|
"POT-Creation-Date: 2026-01-04 00:12+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: de_DE\n"
|
"Language: de_DE\n"
|
||||||
@@ -76,10 +76,6 @@ msgstr ""
|
|||||||
msgid "Legendary executable not found at {path}"
|
msgid "Legendary executable not found at {path}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "start.sh not found at {path}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Success"
|
msgid "Success"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -124,6 +120,10 @@ msgstr ""
|
|||||||
msgid "Removed '{game_name}' from favorites"
|
msgid "Removed '{game_name}' from favorites"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "start.sh not found at {path}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Launch game \"{name}\" with PortProton"
|
msgid "Launch game \"{name}\" with PortProton"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -191,6 +191,10 @@ msgstr ""
|
|||||||
msgid "Failed to delete custom data: {error}"
|
msgid "Failed to delete custom data: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Added '{game_name}' successfully"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Game name and executable path are required"
|
msgid "Game name and executable path are required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -213,6 +217,10 @@ msgstr ""
|
|||||||
msgid "Failed to copy cover image: {error}"
|
msgid "Failed to copy cover image: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Unsupported image format: {extension}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -248,13 +256,135 @@ msgstr ""
|
|||||||
msgid "Select All"
|
msgid "Select All"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete Wine"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected WINE:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No WINE selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete Selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Clear All"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Launching {0}"
|
msgid "Selected {} WINE:\n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No Selection"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please select at least one WINE to delete."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ""
|
||||||
|
"Are you sure you want to delete the following WINE versions?\n"
|
||||||
|
"\n"
|
||||||
|
"{}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to delete WINE '{}': {}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Some Deletions Failed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ""
|
||||||
|
"Some WINE versions could not be deleted:\n"
|
||||||
|
"\n"
|
||||||
|
"{}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected WINE versions deleted successfully."
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
msgid "partial"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "none"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Gamepad Support: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Stop"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Play"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Reinstall"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Install"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Open"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Select Dir"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prev Dir"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Toggle"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force Install"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prev Tab"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Next Tab"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Save"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Launching {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "File Explorer"
|
msgid "File Explorer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -286,6 +416,9 @@ msgstr ""
|
|||||||
msgid "Custom Cover:"
|
msgid "Custom Cover:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enter local path or URL for cover image"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Cover Preview:"
|
msgid "Cover Preview:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -304,6 +437,75 @@ msgstr ""
|
|||||||
msgid "No cover selected"
|
msgid "No cover selected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix Manager"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Set"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Libraries"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Information"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Fonts"
|
||||||
|
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 "Exe Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search settings..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Main"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Advanced"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Setting"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Value"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Description"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "disabled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No changes to apply."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Failed to apply changes. Check logs."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Settings updated successfully."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Epic Games Store games..."
|
msgid "Loading Epic Games Store games..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -343,18 +545,91 @@ msgstr ""
|
|||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Get other Wine"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected assets:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No assets selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading: "
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Download Selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Asset Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Selected {} assets:\n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading in Progress"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cannot clear selection while extraction is in progress."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please select at least one archive to download."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please wait for current downloading to complete."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading Complete"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "All selected archives have been downloaded!"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Downloading: {0} ({1}%)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Extracting: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ", ETA: {}s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ", Speed: {:.1f}MB/s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Extracting: {0}{1}{2}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Extraction Error"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to extract archive: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Operation Cancelled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Download or extraction has been cancelled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Unknown Game"
|
msgid "Unknown Game"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Starting PortProton..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Library"
|
msgid "Library"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Auto Install"
|
msgid "Auto Install"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Emulators"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Wine Settings"
|
msgid "Wine Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -364,10 +639,32 @@ msgstr ""
|
|||||||
msgid "Themes"
|
msgid "Themes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Back"
|
msgid "Fullscreen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Fullscreen"
|
msgid "Refresh Grid"
|
||||||
|
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 "Game library refreshed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Steam games..."
|
msgid "Loading Steam games..."
|
||||||
@@ -382,13 +679,113 @@ msgstr ""
|
|||||||
msgid "Find Games ..."
|
msgid "Find Games ..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Here you can configure automatic game installation..."
|
msgid "A refresh is already in progress..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "List of available emulators and their configuration..."
|
msgid "Refreshing..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Various Wine parameters and versions..."
|
msgid "Refreshing game library..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Added '{name}'"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Compatibility tool:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
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 "Download other WINE"
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
msgid "Clearing prefix..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Failed to start prefix clear process."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix cleared successfully."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Prefix clear failed with exit code {}."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to run clear prefix command: {}"
|
||||||
|
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 ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Main PortProton parameters..."
|
msgid "Main PortProton parameters..."
|
||||||
@@ -424,6 +821,9 @@ msgstr ""
|
|||||||
msgid "Games Display Filter:"
|
msgid "Games Display Filter:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad Type:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Proxy URL"
|
msgid "Proxy URL"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -448,6 +848,12 @@ msgstr ""
|
|||||||
msgid "Application Fullscreen Mode:"
|
msgid "Application Fullscreen Mode:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Minimize to tray on close"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Application Close Mode:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Auto Fullscreen on Gamepad connected"
|
msgid "Auto Fullscreen on Gamepad connected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -522,38 +928,8 @@ msgstr ""
|
|||||||
msgid "Error applying theme '{0}'"
|
msgid "Error applying theme '{0}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "LAST LAUNCH"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "PLAY TIME"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "MAIN STORY"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "MAIN + SIDES"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "COMPLETIONIST"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "full"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "partial"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "none"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Gamepad Support: {0}"
|
msgid "Executable not found: {0}"
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Stop"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Play"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
@@ -580,6 +956,262 @@ msgstr ""
|
|||||||
msgid "File not found: {0}"
|
msgid "File not found: {0}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Using FPS and system load monitoring (Turns on and off by the key "
|
||||||
|
"combination - right Shift + F12)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Enable vkBasalt by default to improve graphics in games running on "
|
||||||
|
"Vulkan. (The HOME hotkey disables vkbasalt)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
|
||||||
|
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Super + F : Toggle fullscreen\n"
|
||||||
|
"Super + N : Toggle nearest neighbour filtering\n"
|
||||||
|
"Super + U : Toggle FSR upscaling\n"
|
||||||
|
"Super + Y : Toggle NIS upscaling\n"
|
||||||
|
"Super + I : Increase FSR sharpness by 1\n"
|
||||||
|
"Super + O : Decrease FSR sharpness by 1\n"
|
||||||
|
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
|
||||||
|
"Super + G : Toggle keyboard grab\n"
|
||||||
|
"Super + C : Update clipboard"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable in-process synchronization primitives based on eventfd."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable futex-based in-process synchronization primitives."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable in-process synchronization via the Linux ntsync driver."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable vkd3d support - Ray Tracing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable DLSS on supported NVIDIA graphics cards"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable Lossless Scaling frame generation (experimental)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Disguise all NVIDIA GPU features"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Run the application in WINE virtual desktop"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Run the application in a terminal"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use system GameMode for performance optimization"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable forced use of third-party DirectX libraries"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Fix pink-tinted video playback in some games"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Reduce PulseAudio latency to fix intermittent sound"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force US keyboard layout"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use WINE shader caching"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force use of built-in DXGI library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable OBS Studio capture via obs-vkcapture"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Disable desktop compositing for performance"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use container launch mode (recommended default)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force DirectInput protocol instead of XInput"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable experimental native Wayland support"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable HDR settings under native Wayland"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use Gallium Zink (OpenGL via Vulkan)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use WineD3D Vulkan backend (Damavand)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use async dxvk-sarek (experimental)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Wine Version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Select the Wine or Proton version to use for this executable."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Specify the Wine prefix to run this game with"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Newest"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Stable"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Vulkan Backend"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||||
|
"\n"
|
||||||
|
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||||
|
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||||
|
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||||
|
"driver)\n"
|
||||||
|
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||||
|
"Vulkan 1.1+)\n"
|
||||||
|
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Windows version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Changing the WINDOWS emulation version may be required to run older "
|
||||||
|
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "DLL Overrides"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Forced to use/disable the library only for the given application.\n"
|
||||||
|
"\n"
|
||||||
|
"A brief instruction:\n"
|
||||||
|
"* libraries are written WITHOUT the .dll file extension\n"
|
||||||
|
"* libraries are separated by semicolons - ;\n"
|
||||||
|
"* library=n - use the WINDOWS (third-party) library\n"
|
||||||
|
"* library=b - use WINE (built-in) library\n"
|
||||||
|
"* library=n,b - use WINDOWS library and then WINE\n"
|
||||||
|
"* library=b,n - use WINE library and then WINDOWS\n"
|
||||||
|
"* library= - disable the use of this library\n"
|
||||||
|
"\n"
|
||||||
|
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Launch Arguments"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Adding an argument after the .exe file, just like you would add an "
|
||||||
|
"argument in a shortcut on a WINDOWS system.\n"
|
||||||
|
"\n"
|
||||||
|
"Example: -dx11 -skipintro 1"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "CPU Cores Limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||||
|
"recommended to set the value equal to 8)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "OpenGL Version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"You can select the required OpenGL version, some games require a forced "
|
||||||
|
"Compatibility Profile (COMP)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "VKD3D Feature Level"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "You can set a forced feature level VKD3D for games on DirectX12"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locale"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Window Mode"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Window mode (for Vulkan and OpenGL):\n"
|
||||||
|
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
|
||||||
|
"immediate - Unlimited frame rate + tearing.\n"
|
||||||
|
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
|
||||||
|
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
|
||||||
|
" rate."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "AMD Vulkan Driver"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Select needed AMD vulkan implementation. Choosing which implementation of"
|
||||||
|
" vulkan will be used to run the game"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "NUMA Node"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
|
||||||
|
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
|
||||||
|
" single node reduces memory-access latency and limits costly core-to-core"
|
||||||
|
" switches."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Reboot"
|
msgid "Reboot"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Spanish (Spain) translations for PortProtonQt.
|
# Spanish (Spain) translations for PortProtonQt.
|
||||||
# Copyright (C) 2025 boria138
|
# Copyright (C) 2026 boria138
|
||||||
# This file is distributed under the same license as the PortProtonQt
|
# This file is distributed under the same license as the PortProtonQt
|
||||||
# project.
|
# project.
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
|
||||||
#
|
#
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
|
"POT-Creation-Date: 2026-01-04 00:12+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: es_ES\n"
|
"Language: es_ES\n"
|
||||||
@@ -76,10 +76,6 @@ msgstr ""
|
|||||||
msgid "Legendary executable not found at {path}"
|
msgid "Legendary executable not found at {path}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "start.sh not found at {path}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Success"
|
msgid "Success"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -124,6 +120,10 @@ msgstr ""
|
|||||||
msgid "Removed '{game_name}' from favorites"
|
msgid "Removed '{game_name}' from favorites"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "start.sh not found at {path}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Launch game \"{name}\" with PortProton"
|
msgid "Launch game \"{name}\" with PortProton"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -191,6 +191,10 @@ msgstr ""
|
|||||||
msgid "Failed to delete custom data: {error}"
|
msgid "Failed to delete custom data: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Added '{game_name}' successfully"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Game name and executable path are required"
|
msgid "Game name and executable path are required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -213,6 +217,10 @@ msgstr ""
|
|||||||
msgid "Failed to copy cover image: {error}"
|
msgid "Failed to copy cover image: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Unsupported image format: {extension}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -248,13 +256,135 @@ msgstr ""
|
|||||||
msgid "Select All"
|
msgid "Select All"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete Wine"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected WINE:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No WINE selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete Selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Clear All"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Launching {0}"
|
msgid "Selected {} WINE:\n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No Selection"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please select at least one WINE to delete."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ""
|
||||||
|
"Are you sure you want to delete the following WINE versions?\n"
|
||||||
|
"\n"
|
||||||
|
"{}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to delete WINE '{}': {}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Some Deletions Failed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ""
|
||||||
|
"Some WINE versions could not be deleted:\n"
|
||||||
|
"\n"
|
||||||
|
"{}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected WINE versions deleted successfully."
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
msgid "partial"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "none"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Gamepad Support: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Stop"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Play"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Reinstall"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Install"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Open"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Select Dir"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prev Dir"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Toggle"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force Install"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prev Tab"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Next Tab"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Save"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Launching {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "File Explorer"
|
msgid "File Explorer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -286,6 +416,9 @@ msgstr ""
|
|||||||
msgid "Custom Cover:"
|
msgid "Custom Cover:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enter local path or URL for cover image"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Cover Preview:"
|
msgid "Cover Preview:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -304,6 +437,75 @@ msgstr ""
|
|||||||
msgid "No cover selected"
|
msgid "No cover selected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix Manager"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Set"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Libraries"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Information"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Fonts"
|
||||||
|
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 "Exe Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search settings..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Main"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Advanced"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Setting"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Value"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Description"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "disabled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No changes to apply."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Failed to apply changes. Check logs."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Settings updated successfully."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Epic Games Store games..."
|
msgid "Loading Epic Games Store games..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -343,18 +545,91 @@ msgstr ""
|
|||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Get other Wine"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected assets:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No assets selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading: "
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Download Selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Asset Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Selected {} assets:\n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading in Progress"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cannot clear selection while extraction is in progress."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please select at least one archive to download."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please wait for current downloading to complete."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading Complete"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "All selected archives have been downloaded!"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Downloading: {0} ({1}%)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Extracting: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ", ETA: {}s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ", Speed: {:.1f}MB/s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Extracting: {0}{1}{2}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Extraction Error"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to extract archive: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Operation Cancelled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Download or extraction has been cancelled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Unknown Game"
|
msgid "Unknown Game"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Starting PortProton..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Library"
|
msgid "Library"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Auto Install"
|
msgid "Auto Install"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Emulators"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Wine Settings"
|
msgid "Wine Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -364,10 +639,32 @@ msgstr ""
|
|||||||
msgid "Themes"
|
msgid "Themes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Back"
|
msgid "Fullscreen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Fullscreen"
|
msgid "Refresh Grid"
|
||||||
|
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 "Game library refreshed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Steam games..."
|
msgid "Loading Steam games..."
|
||||||
@@ -382,13 +679,113 @@ msgstr ""
|
|||||||
msgid "Find Games ..."
|
msgid "Find Games ..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Here you can configure automatic game installation..."
|
msgid "A refresh is already in progress..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "List of available emulators and their configuration..."
|
msgid "Refreshing..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Various Wine parameters and versions..."
|
msgid "Refreshing game library..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Added '{name}'"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Compatibility tool:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
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 "Download other WINE"
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
msgid "Clearing prefix..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Failed to start prefix clear process."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix cleared successfully."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Prefix clear failed with exit code {}."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to run clear prefix command: {}"
|
||||||
|
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 ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Main PortProton parameters..."
|
msgid "Main PortProton parameters..."
|
||||||
@@ -424,6 +821,9 @@ msgstr ""
|
|||||||
msgid "Games Display Filter:"
|
msgid "Games Display Filter:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad Type:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Proxy URL"
|
msgid "Proxy URL"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -448,6 +848,12 @@ msgstr ""
|
|||||||
msgid "Application Fullscreen Mode:"
|
msgid "Application Fullscreen Mode:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Minimize to tray on close"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Application Close Mode:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Auto Fullscreen on Gamepad connected"
|
msgid "Auto Fullscreen on Gamepad connected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -522,38 +928,8 @@ msgstr ""
|
|||||||
msgid "Error applying theme '{0}'"
|
msgid "Error applying theme '{0}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "LAST LAUNCH"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "PLAY TIME"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "MAIN STORY"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "MAIN + SIDES"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "COMPLETIONIST"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "full"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "partial"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "none"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Gamepad Support: {0}"
|
msgid "Executable not found: {0}"
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Stop"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Play"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
@@ -580,6 +956,262 @@ msgstr ""
|
|||||||
msgid "File not found: {0}"
|
msgid "File not found: {0}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Using FPS and system load monitoring (Turns on and off by the key "
|
||||||
|
"combination - right Shift + F12)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Enable vkBasalt by default to improve graphics in games running on "
|
||||||
|
"Vulkan. (The HOME hotkey disables vkbasalt)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
|
||||||
|
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Super + F : Toggle fullscreen\n"
|
||||||
|
"Super + N : Toggle nearest neighbour filtering\n"
|
||||||
|
"Super + U : Toggle FSR upscaling\n"
|
||||||
|
"Super + Y : Toggle NIS upscaling\n"
|
||||||
|
"Super + I : Increase FSR sharpness by 1\n"
|
||||||
|
"Super + O : Decrease FSR sharpness by 1\n"
|
||||||
|
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
|
||||||
|
"Super + G : Toggle keyboard grab\n"
|
||||||
|
"Super + C : Update clipboard"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable in-process synchronization primitives based on eventfd."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable futex-based in-process synchronization primitives."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable in-process synchronization via the Linux ntsync driver."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable vkd3d support - Ray Tracing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable DLSS on supported NVIDIA graphics cards"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable Lossless Scaling frame generation (experimental)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Disguise all NVIDIA GPU features"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Run the application in WINE virtual desktop"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Run the application in a terminal"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use system GameMode for performance optimization"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable forced use of third-party DirectX libraries"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Fix pink-tinted video playback in some games"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Reduce PulseAudio latency to fix intermittent sound"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force US keyboard layout"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use WINE shader caching"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force use of built-in DXGI library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable OBS Studio capture via obs-vkcapture"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Disable desktop compositing for performance"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use container launch mode (recommended default)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force DirectInput protocol instead of XInput"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable experimental native Wayland support"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable HDR settings under native Wayland"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use Gallium Zink (OpenGL via Vulkan)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use WineD3D Vulkan backend (Damavand)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use async dxvk-sarek (experimental)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Wine Version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Select the Wine or Proton version to use for this executable."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Specify the Wine prefix to run this game with"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Newest"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Stable"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Vulkan Backend"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||||
|
"\n"
|
||||||
|
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||||
|
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||||
|
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||||
|
"driver)\n"
|
||||||
|
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||||
|
"Vulkan 1.1+)\n"
|
||||||
|
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Windows version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Changing the WINDOWS emulation version may be required to run older "
|
||||||
|
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "DLL Overrides"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Forced to use/disable the library only for the given application.\n"
|
||||||
|
"\n"
|
||||||
|
"A brief instruction:\n"
|
||||||
|
"* libraries are written WITHOUT the .dll file extension\n"
|
||||||
|
"* libraries are separated by semicolons - ;\n"
|
||||||
|
"* library=n - use the WINDOWS (third-party) library\n"
|
||||||
|
"* library=b - use WINE (built-in) library\n"
|
||||||
|
"* library=n,b - use WINDOWS library and then WINE\n"
|
||||||
|
"* library=b,n - use WINE library and then WINDOWS\n"
|
||||||
|
"* library= - disable the use of this library\n"
|
||||||
|
"\n"
|
||||||
|
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Launch Arguments"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Adding an argument after the .exe file, just like you would add an "
|
||||||
|
"argument in a shortcut on a WINDOWS system.\n"
|
||||||
|
"\n"
|
||||||
|
"Example: -dx11 -skipintro 1"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "CPU Cores Limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||||
|
"recommended to set the value equal to 8)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "OpenGL Version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"You can select the required OpenGL version, some games require a forced "
|
||||||
|
"Compatibility Profile (COMP)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "VKD3D Feature Level"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "You can set a forced feature level VKD3D for games on DirectX12"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locale"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Window Mode"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Window mode (for Vulkan and OpenGL):\n"
|
||||||
|
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
|
||||||
|
"immediate - Unlimited frame rate + tearing.\n"
|
||||||
|
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
|
||||||
|
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
|
||||||
|
" rate."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "AMD Vulkan Driver"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Select needed AMD vulkan implementation. Choosing which implementation of"
|
||||||
|
" vulkan will be used to run the game"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "NUMA Node"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
|
||||||
|
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
|
||||||
|
" single node reduces memory-access latency and limits costly core-to-core"
|
||||||
|
" switches."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Reboot"
|
msgid "Reboot"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Translations template for PortProtonQt.
|
# Translations template for PortProtonQt.
|
||||||
# Copyright (C) 2025 boria138
|
# Copyright (C) 2026 boria138
|
||||||
# This file is distributed under the same license as the PortProtonQt
|
# This file is distributed under the same license as the PortProtonQt
|
||||||
# project.
|
# project.
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
|
||||||
#
|
#
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
|
"POT-Creation-Date: 2026-01-04 00:12+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -74,10 +74,6 @@ msgstr ""
|
|||||||
msgid "Legendary executable not found at {path}"
|
msgid "Legendary executable not found at {path}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "start.sh not found at {path}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Success"
|
msgid "Success"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -122,6 +118,10 @@ msgstr ""
|
|||||||
msgid "Removed '{game_name}' from favorites"
|
msgid "Removed '{game_name}' from favorites"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "start.sh not found at {path}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Launch game \"{name}\" with PortProton"
|
msgid "Launch game \"{name}\" with PortProton"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -189,6 +189,10 @@ msgstr ""
|
|||||||
msgid "Failed to delete custom data: {error}"
|
msgid "Failed to delete custom data: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Added '{game_name}' successfully"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Game name and executable path are required"
|
msgid "Game name and executable path are required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -211,6 +215,10 @@ msgstr ""
|
|||||||
msgid "Failed to copy cover image: {error}"
|
msgid "Failed to copy cover image: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Unsupported image format: {extension}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -246,13 +254,135 @@ msgstr ""
|
|||||||
msgid "Select All"
|
msgid "Select All"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete Wine"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected WINE:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No WINE selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete Selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Clear All"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Launching {0}"
|
msgid "Selected {} WINE:\n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No Selection"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please select at least one WINE to delete."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ""
|
||||||
|
"Are you sure you want to delete the following WINE versions?\n"
|
||||||
|
"\n"
|
||||||
|
"{}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to delete WINE '{}': {}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Some Deletions Failed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ""
|
||||||
|
"Some WINE versions could not be deleted:\n"
|
||||||
|
"\n"
|
||||||
|
"{}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected WINE versions deleted successfully."
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
msgid "partial"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "none"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Gamepad Support: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Stop"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Play"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Reinstall"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Install"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Open"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Select Dir"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prev Dir"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Toggle"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force Install"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prev Tab"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Next Tab"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Save"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Launching {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "File Explorer"
|
msgid "File Explorer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -284,6 +414,9 @@ msgstr ""
|
|||||||
msgid "Custom Cover:"
|
msgid "Custom Cover:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enter local path or URL for cover image"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Cover Preview:"
|
msgid "Cover Preview:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -302,6 +435,75 @@ msgstr ""
|
|||||||
msgid "No cover selected"
|
msgid "No cover selected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix Manager"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Set"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Libraries"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Information"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Fonts"
|
||||||
|
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 "Exe Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search settings..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Main"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Advanced"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Setting"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Value"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Description"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "disabled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No changes to apply."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Failed to apply changes. Check logs."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Settings updated successfully."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Epic Games Store games..."
|
msgid "Loading Epic Games Store games..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -341,18 +543,91 @@ msgstr ""
|
|||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Get other Wine"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Selected assets:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No assets selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading: "
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Download Selected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Asset Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Selected {} assets:\n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading in Progress"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cannot clear selection while extraction is in progress."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please select at least one archive to download."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Please wait for current downloading to complete."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Downloading Complete"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "All selected archives have been downloaded!"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Downloading: {0} ({1}%)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Extracting: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ", ETA: {}s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid ", Speed: {:.1f}MB/s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Extracting: {0}{1}{2}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Extraction Error"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to extract archive: {0}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Operation Cancelled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Download or extraction has been cancelled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Unknown Game"
|
msgid "Unknown Game"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Starting PortProton..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Library"
|
msgid "Library"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Auto Install"
|
msgid "Auto Install"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Emulators"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Wine Settings"
|
msgid "Wine Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -362,10 +637,32 @@ msgstr ""
|
|||||||
msgid "Themes"
|
msgid "Themes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Back"
|
msgid "Fullscreen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Fullscreen"
|
msgid "Refresh Grid"
|
||||||
|
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 "Game library refreshed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Steam games..."
|
msgid "Loading Steam games..."
|
||||||
@@ -380,13 +677,113 @@ msgstr ""
|
|||||||
msgid "Find Games ..."
|
msgid "Find Games ..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Here you can configure automatic game installation..."
|
msgid "A refresh is already in progress..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "List of available emulators and their configuration..."
|
msgid "Refreshing..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Various Wine parameters and versions..."
|
msgid "Refreshing game library..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Added '{name}'"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Compatibility tool:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
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 "Download other WINE"
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
msgid "Clearing prefix..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Failed to start prefix clear process."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix cleared successfully."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Prefix clear failed with exit code {}."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Failed to run clear prefix command: {}"
|
||||||
|
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 ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Main PortProton parameters..."
|
msgid "Main PortProton parameters..."
|
||||||
@@ -422,6 +819,9 @@ msgstr ""
|
|||||||
msgid "Games Display Filter:"
|
msgid "Games Display Filter:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad Type:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Proxy URL"
|
msgid "Proxy URL"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -446,6 +846,12 @@ msgstr ""
|
|||||||
msgid "Application Fullscreen Mode:"
|
msgid "Application Fullscreen Mode:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Minimize to tray on close"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Application Close Mode:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Auto Fullscreen on Gamepad connected"
|
msgid "Auto Fullscreen on Gamepad connected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -520,38 +926,8 @@ msgstr ""
|
|||||||
msgid "Error applying theme '{0}'"
|
msgid "Error applying theme '{0}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "LAST LAUNCH"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "PLAY TIME"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "MAIN STORY"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "MAIN + SIDES"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "COMPLETIONIST"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "full"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "partial"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "none"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Gamepad Support: {0}"
|
msgid "Executable not found: {0}"
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Stop"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Play"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
@@ -578,6 +954,262 @@ msgstr ""
|
|||||||
msgid "File not found: {0}"
|
msgid "File not found: {0}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Using FPS and system load monitoring (Turns on and off by the key "
|
||||||
|
"combination - right Shift + F12)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Enable vkBasalt by default to improve graphics in games running on "
|
||||||
|
"Vulkan. (The HOME hotkey disables vkbasalt)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
|
||||||
|
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Super + F : Toggle fullscreen\n"
|
||||||
|
"Super + N : Toggle nearest neighbour filtering\n"
|
||||||
|
"Super + U : Toggle FSR upscaling\n"
|
||||||
|
"Super + Y : Toggle NIS upscaling\n"
|
||||||
|
"Super + I : Increase FSR sharpness by 1\n"
|
||||||
|
"Super + O : Decrease FSR sharpness by 1\n"
|
||||||
|
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
|
||||||
|
"Super + G : Toggle keyboard grab\n"
|
||||||
|
"Super + C : Update clipboard"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable in-process synchronization primitives based on eventfd."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable futex-based in-process synchronization primitives."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable in-process synchronization via the Linux ntsync driver."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable vkd3d support - Ray Tracing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable DLSS on supported NVIDIA graphics cards"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable Lossless Scaling frame generation (experimental)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Disguise all NVIDIA GPU features"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Run the application in WINE virtual desktop"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Run the application in a terminal"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use system GameMode for performance optimization"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable forced use of third-party DirectX libraries"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Fix pink-tinted video playback in some games"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Reduce PulseAudio latency to fix intermittent sound"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force US keyboard layout"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use WINE shader caching"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force use of built-in DXGI library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable OBS Studio capture via obs-vkcapture"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Disable desktop compositing for performance"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use container launch mode (recommended default)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force DirectInput protocol instead of XInput"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable experimental native Wayland support"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enable HDR settings under native Wayland"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use Gallium Zink (OpenGL via Vulkan)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use WineD3D Vulkan backend (Damavand)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Use async dxvk-sarek (experimental)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Wine Version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Select the Wine or Proton version to use for this executable."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Prefix Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Specify the Wine prefix to run this game with"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Newest"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Stable"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Vulkan Backend"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||||
|
"\n"
|
||||||
|
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||||
|
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||||
|
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||||
|
"driver)\n"
|
||||||
|
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||||
|
"Vulkan 1.1+)\n"
|
||||||
|
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Windows version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Changing the WINDOWS emulation version may be required to run older "
|
||||||
|
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "DLL Overrides"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Forced to use/disable the library only for the given application.\n"
|
||||||
|
"\n"
|
||||||
|
"A brief instruction:\n"
|
||||||
|
"* libraries are written WITHOUT the .dll file extension\n"
|
||||||
|
"* libraries are separated by semicolons - ;\n"
|
||||||
|
"* library=n - use the WINDOWS (third-party) library\n"
|
||||||
|
"* library=b - use WINE (built-in) library\n"
|
||||||
|
"* library=n,b - use WINDOWS library and then WINE\n"
|
||||||
|
"* library=b,n - use WINE library and then WINDOWS\n"
|
||||||
|
"* library= - disable the use of this library\n"
|
||||||
|
"\n"
|
||||||
|
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Launch Arguments"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Adding an argument after the .exe file, just like you would add an "
|
||||||
|
"argument in a shortcut on a WINDOWS system.\n"
|
||||||
|
"\n"
|
||||||
|
"Example: -dx11 -skipintro 1"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "CPU Cores Limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||||
|
"recommended to set the value equal to 8)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "OpenGL Version"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"You can select the required OpenGL version, some games require a forced "
|
||||||
|
"Compatibility Profile (COMP)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "VKD3D Feature Level"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "You can set a forced feature level VKD3D for games on DirectX12"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locale"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Window Mode"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Window mode (for Vulkan and OpenGL):\n"
|
||||||
|
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
|
||||||
|
"immediate - Unlimited frame rate + tearing.\n"
|
||||||
|
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
|
||||||
|
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
|
||||||
|
" rate."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "AMD Vulkan Driver"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"Select needed AMD vulkan implementation. Choosing which implementation of"
|
||||||
|
" vulkan will be used to run the game"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "NUMA Node"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid ""
|
||||||
|
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
|
||||||
|
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
|
||||||
|
" single node reduces memory-access latency and limits costly core-to-core"
|
||||||
|
" switches."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Reboot"
|
msgid "Reboot"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import gettext
|
import gettext
|
||||||
|
import configparser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
@@ -102,3 +103,97 @@ def read_metadata_translations(metadata_file, language_code):
|
|||||||
translations['description'] = line[len('description='):].strip()
|
translations['description'] = line[len('description='):].strip()
|
||||||
|
|
||||||
return translations
|
return translations
|
||||||
|
|
||||||
|
def get_screenshot_caption(base_filename, metainfo_file, language_code=None):
|
||||||
|
"""
|
||||||
|
Возвращает перевод названия скриншота на основе языка пользователя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_filename: Имя файла без расширения
|
||||||
|
metainfo_file: Путь к файлу metainfo.ini
|
||||||
|
language_code: Код языка (если None, будет определен автоматически)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Переведенное название скриншота
|
||||||
|
"""
|
||||||
|
if language_code is None:
|
||||||
|
system_locale = get_system_locale()
|
||||||
|
language_code = system_locale.split('_')[0] if '_' in system_locale else system_locale
|
||||||
|
|
||||||
|
# Загружаем переводы из metainfo.ini
|
||||||
|
screenshot_translations = {}
|
||||||
|
if metainfo_file and os.path.exists(metainfo_file):
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
cp.read(metainfo_file, encoding="utf-8")
|
||||||
|
if "Screenshots" in cp:
|
||||||
|
for key in cp.options("Screenshots"):
|
||||||
|
screenshot_translations[key] = cp.get("Screenshots", key)
|
||||||
|
|
||||||
|
# Ищем перевод в формате: base_filename_languagecode
|
||||||
|
caption = base_filename # По умолчанию используем базовое имя файла
|
||||||
|
|
||||||
|
if screenshot_translations:
|
||||||
|
# Попробуем перевод для конкретного языка (например, "library_ru")
|
||||||
|
lang_specific_key = f"{base_filename}_{language_code}"
|
||||||
|
# Попробуем английский перевод (например, "library_en")
|
||||||
|
english_key = f"{base_filename}_en"
|
||||||
|
|
||||||
|
if lang_specific_key in screenshot_translations:
|
||||||
|
caption = screenshot_translations[lang_specific_key]
|
||||||
|
elif english_key in screenshot_translations:
|
||||||
|
caption = screenshot_translations[english_key]
|
||||||
|
elif base_filename in screenshot_translations:
|
||||||
|
caption = screenshot_translations[base_filename] # fallback to untranslated key
|
||||||
|
|
||||||
|
return caption
|
||||||
|
|
||||||
|
def get_theme_translations(metainfo_file, language_code=None):
|
||||||
|
"""
|
||||||
|
Возвращает переводы названия и описания темы на основе языка пользователя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metainfo_file: Путь к файлу metainfo.ini
|
||||||
|
language_code: Код языка (если None, будет определен автоматически)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с полями 'name' и 'description' с переведенными значениями
|
||||||
|
"""
|
||||||
|
if language_code is None:
|
||||||
|
system_locale = get_system_locale()
|
||||||
|
language_code = system_locale.split('_')[0] if '_' in system_locale else system_locale
|
||||||
|
|
||||||
|
# Загружаем переводы из metainfo.ini
|
||||||
|
translations = {'name': '', 'description': ''}
|
||||||
|
|
||||||
|
if metainfo_file and os.path.exists(metainfo_file):
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
cp.read(metainfo_file, encoding="utf-8")
|
||||||
|
|
||||||
|
if "Metainfo" in cp:
|
||||||
|
# Попробуем перевод названия для конкретного языка (например, "name_ru")
|
||||||
|
lang_specific_name_key = f"name_{language_code}"
|
||||||
|
# Попробуем английский перевод названия (например, "name_en")
|
||||||
|
english_name_key = "name_en"
|
||||||
|
|
||||||
|
# Ищем перевод названия
|
||||||
|
if cp.has_option("Metainfo", lang_specific_name_key):
|
||||||
|
translations['name'] = cp.get("Metainfo", lang_specific_name_key)
|
||||||
|
elif cp.has_option("Metainfo", english_name_key):
|
||||||
|
translations['name'] = cp.get("Metainfo", english_name_key)
|
||||||
|
elif cp.has_option("Metainfo", "name"):
|
||||||
|
translations['name'] = cp.get("Metainfo", "name")
|
||||||
|
|
||||||
|
# Попробуем перевод описания для конкретного языка (например, "description_ru")
|
||||||
|
lang_specific_desc_key = f"description_{language_code}"
|
||||||
|
# Попробуем английский перевод описания (например, "description_en")
|
||||||
|
english_desc_key = "description_en"
|
||||||
|
|
||||||
|
# Ищем перевод описания
|
||||||
|
if cp.has_option("Metainfo", lang_specific_desc_key):
|
||||||
|
translations['description'] = cp.get("Metainfo", lang_specific_desc_key)
|
||||||
|
elif cp.has_option("Metainfo", english_desc_key):
|
||||||
|
translations['description'] = cp.get("Metainfo", english_desc_key)
|
||||||
|
elif cp.has_option("Metainfo", "description"):
|
||||||
|
translations['description'] = cp.get("Metainfo", "description")
|
||||||
|
|
||||||
|
return translations
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import orjson
|
|||||||
import requests
|
import requests
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import time
|
import time
|
||||||
|
import glob
|
||||||
|
import re
|
||||||
|
import hashlib
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from PySide6.QtCore import QThread, Signal
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
|
from portprotonqt.config_utils import get_portproton_location
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
|
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
|
||||||
|
AUTOINSTALL_CACHE_DURATION = 3600 # 1 hour for autoinstall cache
|
||||||
|
|
||||||
def normalize_name(s):
|
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.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")
|
self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
|
||||||
os.makedirs(self.custom_data_dir, exist_ok=True)
|
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._topics_data = None
|
||||||
|
self._autoinstall_cache = None # New: In-memory cache
|
||||||
|
|
||||||
def _get_game_dir(self, exe_name: str) -> str:
|
def _get_game_dir(self, exe_name: str) -> str:
|
||||||
game_dir = os.path.join(self.custom_data_dir, exe_name)
|
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}")
|
logger.debug(f"Failed to check file at {url}: {e}")
|
||||||
return False
|
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:
|
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)
|
game_dir = self._get_game_dir(exe_name)
|
||||||
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
|
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
|
||||||
@@ -148,8 +124,12 @@ class PortProtonAPI:
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
if self._check_file_exists(metadata_url, timeout):
|
# Check if metadata already exists locally before attempting download
|
||||||
local_metadata_path = os.path.join(game_dir, "metadata.txt")
|
local_metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||||
|
if os.path.exists(local_metadata_path):
|
||||||
|
logger.debug(f"Metadata already exists locally for {exe_name}: {local_metadata_path}")
|
||||||
|
results["metadata"] = local_metadata_path
|
||||||
|
elif self._check_file_exists(metadata_url, timeout):
|
||||||
pending_downloads += 1
|
pending_downloads += 1
|
||||||
self.downloader.download_async(
|
self.downloader.download_async(
|
||||||
metadata_url,
|
metadata_url,
|
||||||
@@ -163,6 +143,366 @@ class PortProtonAPI:
|
|||||||
if callback:
|
if callback:
|
||||||
callback(results)
|
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
|
||||||
|
|
||||||
|
local_cover_path = os.path.join(user_game_folder, "cover.png")
|
||||||
|
|
||||||
|
# Check if the cover already exists locally before attempting download
|
||||||
|
if os.path.exists(local_cover_path):
|
||||||
|
logger.debug(f"Async autoinstall cover already exists locally for {exe_name}: {local_cover_path}")
|
||||||
|
if callback:
|
||||||
|
callback(local_cover_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
cover_url = f"{self.base_url}/{exe_name}/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 download_autoinstall_metadata_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
|
||||||
|
"""Download autoinstall metadata.txt file."""
|
||||||
|
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.makedirs(user_game_folder, exist_ok=True)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
local_metadata_path = os.path.join(user_game_folder, "metadata.txt")
|
||||||
|
|
||||||
|
# Check if the file already exists locally before attempting download
|
||||||
|
if os.path.exists(local_metadata_path):
|
||||||
|
logger.debug(f"Async autoinstall metadata already exists locally for {exe_name}: {local_metadata_path}")
|
||||||
|
if callback:
|
||||||
|
callback(local_metadata_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
|
||||||
|
|
||||||
|
def on_metadata_downloaded(local_path: str | None):
|
||||||
|
if local_path:
|
||||||
|
logger.info(f"Async autoinstall metadata downloaded for {exe_name}: {local_path}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"No autoinstall metadata downloaded for {exe_name}")
|
||||||
|
if callback:
|
||||||
|
callback(local_path)
|
||||||
|
|
||||||
|
if self._check_file_exists(metadata_url, timeout):
|
||||||
|
self.downloader.download_async(
|
||||||
|
metadata_url,
|
||||||
|
local_metadata_path,
|
||||||
|
timeout=timeout,
|
||||||
|
callback=on_metadata_downloaded
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(f"No autoinstall metadata found for {exe_name}")
|
||||||
|
if callback:
|
||||||
|
callback(None)
|
||||||
|
|
||||||
|
def get_autoinstall_description(self, exe_name: str, lang_code: str = "en") -> str | None:
|
||||||
|
"""Read description from downloaded metadata.txt file for autoinstall game.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exe_name: The executable name/script name
|
||||||
|
lang_code: Language code ("en" or "ru" for description)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Description string or None if not found
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
metadata_path = os.path.join(autoinstall_root, exe_name, "metadata.txt")
|
||||||
|
|
||||||
|
if not os.path.exists(metadata_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(metadata_path, encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Parse the metadata content to extract description
|
||||||
|
# Format: description_en=... or description_ru=...
|
||||||
|
if lang_code == "ru":
|
||||||
|
pattern = r'^description_ru=(.*)$'
|
||||||
|
else:
|
||||||
|
pattern = r'^description_en=(.*)$'
|
||||||
|
|
||||||
|
import re
|
||||||
|
match = re.search(pattern, content, re.MULTILINE)
|
||||||
|
if match:
|
||||||
|
description = match.group(1).strip()
|
||||||
|
# Handle potential quoted strings
|
||||||
|
if description.startswith('"') and description.endswith('"'):
|
||||||
|
description = description[1:-1]
|
||||||
|
return description
|
||||||
|
else:
|
||||||
|
# Try fallback to the other language if the requested one is not found
|
||||||
|
fallback_lang = "ru" if lang_code == "en" else "en"
|
||||||
|
fallback_pattern = rf'^description_{fallback_lang}=(.*)$'
|
||||||
|
fallback_match = re.search(fallback_pattern, content, re.MULTILINE)
|
||||||
|
if fallback_match:
|
||||||
|
description = fallback_match.group(1).strip()
|
||||||
|
if description.startswith('"') and description.endswith('"'):
|
||||||
|
description = description[1:-1]
|
||||||
|
return description
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading metadata for {exe_name}: {e}")
|
||||||
|
|
||||||
|
return 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:
|
||||||
|
# Add timeout protection for file operations
|
||||||
|
start_time = time.time()
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
# Check for timeout during signature computation
|
||||||
|
if time.time() - start_time > 3: # 3 second timeout
|
||||||
|
logger.warning("Cache loading took too long, skipping cache")
|
||||||
|
return None
|
||||||
|
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."""
|
||||||
|
class AutoinstallWorker(QThread):
|
||||||
|
finished = Signal(list)
|
||||||
|
api: "PortProtonAPI"
|
||||||
|
portproton_location: str | None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
import time
|
||||||
|
# Check cache in this background thread, not in main thread
|
||||||
|
start_time = time.time()
|
||||||
|
cached_games = self.api._load_autoinstall_cache()
|
||||||
|
# If cache loading took too long (>2 seconds), skip cache and load directly
|
||||||
|
if time.time() - start_time > 2:
|
||||||
|
logger.warning("Cache loading took too long, proceeding without cache")
|
||||||
|
cached_games = None
|
||||||
|
|
||||||
|
if cached_games is not None:
|
||||||
|
self.finished.emit(cached_games)
|
||||||
|
return
|
||||||
|
|
||||||
|
# No cache: Load games from scratch
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Try to get the description from metadata file
|
||||||
|
description = ""
|
||||||
|
# Look for metadata in the expected location
|
||||||
|
try:
|
||||||
|
import locale
|
||||||
|
current_locale = locale.getlocale()[0] or 'en'
|
||||||
|
except (AttributeError, IndexError, TypeError):
|
||||||
|
current_locale = 'en'
|
||||||
|
lang_code = 'ru' if current_locale and 'ru' in current_locale.lower() else 'en'
|
||||||
|
|
||||||
|
# Try to read description from downloaded metadata
|
||||||
|
metadata_description = self.api.get_autoinstall_description(exe_name, lang_code)
|
||||||
|
if metadata_description:
|
||||||
|
description = metadata_description
|
||||||
|
|
||||||
|
game_tuple = (
|
||||||
|
display_name, description, 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):
|
def _load_topics_data(self):
|
||||||
"""Load and cache linux_gaming_topics_min.json from the archive."""
|
"""Load and cache linux_gaming_topics_min.json from the archive."""
|
||||||
if self._topics_data is not None:
|
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
|
||||||
240
portprotonqt/search_utils.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Utility module for search optimizations including Trie, hash tables, and fuzzy matching.
|
||||||
|
"""
|
||||||
|
from typing import Any
|
||||||
|
from rapidfuzz import fuzz
|
||||||
|
from threading import Lock
|
||||||
|
from portprotonqt.logger import get_logger
|
||||||
|
from PySide6.QtCore import QThread, Signal, QObject
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
class TrieNode:
|
||||||
|
"""Node in the Trie data structure."""
|
||||||
|
def __init__(self):
|
||||||
|
self.children = {}
|
||||||
|
self.is_end_word = False
|
||||||
|
self.payload = None # Store the original data in leaf nodes
|
||||||
|
|
||||||
|
class Trie:
|
||||||
|
"""Trie data structure for efficient prefix-based searching."""
|
||||||
|
def __init__(self):
|
||||||
|
self.root = TrieNode()
|
||||||
|
self._lock = Lock() # Thread safety for concurrent access
|
||||||
|
|
||||||
|
def insert(self, key: str, payload: Any):
|
||||||
|
"""Insert a key with payload into the Trie."""
|
||||||
|
with self._lock:
|
||||||
|
node = self.root
|
||||||
|
for char in key.lower():
|
||||||
|
if char not in node.children:
|
||||||
|
node.children[char] = TrieNode()
|
||||||
|
node = node.children[char]
|
||||||
|
node.is_end_word = True
|
||||||
|
node.payload = payload
|
||||||
|
|
||||||
|
def search_prefix(self, prefix: str) -> list[tuple[str, Any]]:
|
||||||
|
"""Find all entries with the given prefix."""
|
||||||
|
with self._lock:
|
||||||
|
node = self.root
|
||||||
|
for char in prefix.lower():
|
||||||
|
if char not in node.children:
|
||||||
|
return []
|
||||||
|
node = node.children[char]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
self._collect_all(node, prefix.lower(), results)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _collect_all(self, node: TrieNode, current_prefix: str, results: list[tuple[str, Any]]):
|
||||||
|
"""Collect all entries from the current node."""
|
||||||
|
if node.is_end_word:
|
||||||
|
results.append((current_prefix, node.payload))
|
||||||
|
|
||||||
|
for char, child_node in node.children.items():
|
||||||
|
self._collect_all(child_node, current_prefix + char, results)
|
||||||
|
|
||||||
|
class FuzzySearchIndex:
|
||||||
|
"""Index for fuzzy string matching with rapidfuzz."""
|
||||||
|
def __init__(self, items: list[tuple[str, Any]] | None = None):
|
||||||
|
self.items: list[tuple[str, Any]] = items or []
|
||||||
|
self.normalized_items: list[tuple[str, Any]] = []
|
||||||
|
self._lock = Lock()
|
||||||
|
self._build_normalized_index()
|
||||||
|
|
||||||
|
def _build_normalized_index(self):
|
||||||
|
"""Build a normalized index for fuzzy matching."""
|
||||||
|
with self._lock:
|
||||||
|
self.normalized_items = [(self._normalize(item[0]), item[1]) for item in self.items]
|
||||||
|
|
||||||
|
def _normalize(self, s: str) -> str:
|
||||||
|
"""Normalize string for fuzzy matching."""
|
||||||
|
s = s.lower()
|
||||||
|
for ch in ["™", "®"]:
|
||||||
|
s = s.replace(ch, "")
|
||||||
|
for ch in ["-", ":", ","]:
|
||||||
|
s = s.replace(ch, " ")
|
||||||
|
s = " ".join(s.split())
|
||||||
|
for suffix in ["bin", "app"]:
|
||||||
|
if s.endswith(suffix):
|
||||||
|
s = s[:-len(suffix)].strip()
|
||||||
|
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
|
||||||
|
words = s.split()
|
||||||
|
filtered_words = [word for word in words if word not in keywords_to_remove]
|
||||||
|
return " ".join(filtered_words)
|
||||||
|
|
||||||
|
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
|
||||||
|
"""Perform fuzzy search using rapidfuzz."""
|
||||||
|
with self._lock:
|
||||||
|
if not query or not self.normalized_items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query_normalized = self._normalize(query)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for i, (item_text, item_data) in enumerate(self.normalized_items):
|
||||||
|
score = fuzz.ratio(query_normalized, item_text)
|
||||||
|
if score >= min_score:
|
||||||
|
results.append((self.items[i][0], item_data, score))
|
||||||
|
|
||||||
|
# Sort by score descending
|
||||||
|
results.sort(key=lambda x: x[2], reverse=True)
|
||||||
|
return results[:limit]
|
||||||
|
|
||||||
|
class SearchOptimizer:
|
||||||
|
"""Main search optimization class combining multiple approaches."""
|
||||||
|
def __init__(self):
|
||||||
|
self.hash_index: dict[str, Any] = {}
|
||||||
|
self.trie_index = Trie()
|
||||||
|
self.fuzzy_index = None
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def build_indices(self, items: list[tuple[str, Any]]):
|
||||||
|
"""Build all search indices from items."""
|
||||||
|
with self._lock:
|
||||||
|
self.hash_index = {item[0].lower(): item[1] for item in items}
|
||||||
|
self.trie_index = Trie()
|
||||||
|
for key, value in self.hash_index.items():
|
||||||
|
self.trie_index.insert(key, value)
|
||||||
|
self.fuzzy_index = FuzzySearchIndex(items)
|
||||||
|
|
||||||
|
def exact_search(self, key: str) -> Any | None:
|
||||||
|
"""Perform exact hash-based lookup."""
|
||||||
|
with self._lock:
|
||||||
|
return self.hash_index.get(key.lower())
|
||||||
|
|
||||||
|
def prefix_search(self, prefix: str) -> list[tuple[str, Any]]:
|
||||||
|
"""Perform prefix search using Trie."""
|
||||||
|
with self._lock:
|
||||||
|
return self.trie_index.search_prefix(prefix)
|
||||||
|
|
||||||
|
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
|
||||||
|
"""Perform fuzzy search."""
|
||||||
|
if self.fuzzy_index:
|
||||||
|
return self.fuzzy_index.fuzzy_search(query, limit, min_score)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Threaded search implementation using QThread for performance optimization
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadedSearchWorker(QObject):
|
||||||
|
"""
|
||||||
|
A threaded worker for performing search operations without blocking the UI.
|
||||||
|
"""
|
||||||
|
search_started = Signal()
|
||||||
|
search_finished = Signal(list)
|
||||||
|
search_error = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.search_optimizer = SearchOptimizer()
|
||||||
|
self.games_data = []
|
||||||
|
|
||||||
|
def set_games_data(self, games_data: list):
|
||||||
|
"""Set the games data to be searched."""
|
||||||
|
self.games_data = games_data
|
||||||
|
# Build indices from the games data (name, description, etc.)
|
||||||
|
items = [(game[0], game) for game in games_data] # game[0] is the name
|
||||||
|
self.search_optimizer.build_indices(items)
|
||||||
|
|
||||||
|
def execute_search(self, search_text: str, search_type: str = "auto"):
|
||||||
|
"""
|
||||||
|
Execute search in a separate thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_text: Text to search for
|
||||||
|
search_type: Type of search ("exact", "prefix", "fuzzy", "auto")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.search_started.emit()
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if search_type == "exact" or (search_type == "auto" and len(search_text) > 2):
|
||||||
|
exact_result = self.search_optimizer.exact_search(search_text)
|
||||||
|
if exact_result:
|
||||||
|
results = [exact_result]
|
||||||
|
elif search_type == "prefix":
|
||||||
|
results = self.search_optimizer.prefix_search(search_text)
|
||||||
|
elif search_type == "fuzzy" or search_type == "auto":
|
||||||
|
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
|
||||||
|
else:
|
||||||
|
# Auto-detect search type based on input
|
||||||
|
if len(search_text) < 3:
|
||||||
|
results = self.search_optimizer.prefix_search(search_text)
|
||||||
|
else:
|
||||||
|
# Try exact first, then fuzzy
|
||||||
|
exact_result = self.search_optimizer.exact_search(search_text)
|
||||||
|
if exact_result:
|
||||||
|
results = [exact_result]
|
||||||
|
else:
|
||||||
|
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
|
||||||
|
|
||||||
|
end_time = time.time()
|
||||||
|
print(f"Search completed in {end_time - start_time:.4f} seconds")
|
||||||
|
|
||||||
|
self.search_finished.emit(results)
|
||||||
|
except Exception as e:
|
||||||
|
self.search_error.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadedSearch(QThread):
|
||||||
|
"""
|
||||||
|
QThread implementation for running search operations in the background.
|
||||||
|
"""
|
||||||
|
search_started = Signal()
|
||||||
|
search_finished = Signal(list)
|
||||||
|
search_error = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.worker = ThreadedSearchWorker()
|
||||||
|
self.search_text = ""
|
||||||
|
self.search_type = "auto"
|
||||||
|
self.games_data = []
|
||||||
|
|
||||||
|
# Connect worker signals to thread signals
|
||||||
|
self.worker.search_started.connect(self.search_started)
|
||||||
|
self.worker.search_finished.connect(self.search_finished)
|
||||||
|
self.worker.search_error.connect(self.search_error)
|
||||||
|
|
||||||
|
|
||||||
|
def set_games_data(self, games_data: list):
|
||||||
|
"""Set the games data to be searched."""
|
||||||
|
self.games_data = games_data
|
||||||
|
self.worker.set_games_data(games_data)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the search operation in the thread."""
|
||||||
|
self.worker.execute_search(self.search_text, self.search_type)
|
||||||
229
portprotonqt/settings_manager.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
def get_toggle_settings():
|
||||||
|
"""Get predefined toggle settings with descriptions."""
|
||||||
|
from portprotonqt.localization import _
|
||||||
|
|
||||||
|
return {
|
||||||
|
'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"),
|
||||||
|
'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"),
|
||||||
|
'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"),
|
||||||
|
'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"),
|
||||||
|
'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."),
|
||||||
|
'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"),
|
||||||
|
'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."),
|
||||||
|
'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."),
|
||||||
|
'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."),
|
||||||
|
'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"),
|
||||||
|
'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"),
|
||||||
|
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
|
||||||
|
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
|
||||||
|
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
|
||||||
|
'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
|
||||||
|
'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
|
||||||
|
'PW_USE_TERMINAL': _("Run the application in a terminal"),
|
||||||
|
'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
|
||||||
|
'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
|
||||||
|
'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
|
||||||
|
'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"),
|
||||||
|
'PW_USE_US_LAYOUT': _("Force US keyboard layout"),
|
||||||
|
'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"),
|
||||||
|
'PW_USE_SHADER_CACHE': _("Use WINE shader caching"),
|
||||||
|
'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"),
|
||||||
|
'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"),
|
||||||
|
'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"),
|
||||||
|
'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"),
|
||||||
|
'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"),
|
||||||
|
'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"),
|
||||||
|
'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"),
|
||||||
|
'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"),
|
||||||
|
'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"),
|
||||||
|
'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"),
|
||||||
|
'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"),
|
||||||
|
'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"),
|
||||||
|
'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"),
|
||||||
|
'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_advanced_settings(disabled_text, logical_core_options, locale_options,
|
||||||
|
amd_vulkan_drivers, is_amd, numa_nodes, dist_options=None, prefix_options=None):
|
||||||
|
"""Get advanced settings configuration."""
|
||||||
|
from portprotonqt.localization import _
|
||||||
|
|
||||||
|
advanced_settings = []
|
||||||
|
if dist_options is None:
|
||||||
|
dist_options = []
|
||||||
|
if prefix_options is None:
|
||||||
|
prefix_options = []
|
||||||
|
|
||||||
|
# 1. Wine Version
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_WINE_USE',
|
||||||
|
'name': _("Wine Version"),
|
||||||
|
'description': _("Select the Wine or Proton version to use for this executable."),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': dist_options,
|
||||||
|
'default': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Prefix Name
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_PREFIX_NAME',
|
||||||
|
'name': _("Prefix Name"),
|
||||||
|
'description': _("Specify the Wine prefix to run this game with"),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': prefix_options,
|
||||||
|
'default': 'DEFAULT'
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Vulkan Backend
|
||||||
|
vulkan_options = [
|
||||||
|
_("Newest"), # → 6
|
||||||
|
_("Stable"), # → 2
|
||||||
|
("Sarek"), # → 1
|
||||||
|
("WINED3D – OpenGL") # → 0
|
||||||
|
]
|
||||||
|
|
||||||
|
# Маппинг: отображаемый текст → реальное значение в ppdb
|
||||||
|
vulkan_value_map = {
|
||||||
|
vulkan_options[0]: "6",
|
||||||
|
vulkan_options[1]: "2",
|
||||||
|
vulkan_options[2]: "1",
|
||||||
|
vulkan_options[3]: "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_VULKAN_USE',
|
||||||
|
'name': _("Vulkan Backend"),
|
||||||
|
'description': _(
|
||||||
|
"Select the DirectX → Vulkan/OpenGL backend:\n\n"
|
||||||
|
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||||
|
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ driver)\n"
|
||||||
|
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, Vulkan 1.1+)\n"
|
||||||
|
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||||
|
),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': vulkan_options,
|
||||||
|
'default': '6',
|
||||||
|
'_value_map': vulkan_value_map
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Windows version
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_WINDOWS_VER',
|
||||||
|
'name': _("Windows version"),
|
||||||
|
'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': ['11', '10', '7', 'XP'],
|
||||||
|
'default': '10'
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. DLL Overrides
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'WINEDLLOVERRIDES',
|
||||||
|
'name': _("DLL Overrides"),
|
||||||
|
'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"),
|
||||||
|
'type': 'text',
|
||||||
|
'default': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# 6. Launch arguments
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'LAUNCH_PARAMETERS',
|
||||||
|
'name': _("Launch Arguments"),
|
||||||
|
'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"),
|
||||||
|
'type': 'text',
|
||||||
|
'default': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# 7. CPU cores limit
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_WINE_CPU_TOPOLOGY',
|
||||||
|
'name': _("CPU Cores Limit"),
|
||||||
|
'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': [disabled_text] + logical_core_options,
|
||||||
|
'default': disabled_text
|
||||||
|
})
|
||||||
|
|
||||||
|
# 8. OpenGL version
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_MESA_GL_VERSION_OVERRIDE',
|
||||||
|
'name': _("OpenGL Version"),
|
||||||
|
'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'],
|
||||||
|
'default': disabled_text
|
||||||
|
})
|
||||||
|
|
||||||
|
# 9. VKD3D feature level
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_VKD3D_FEATURE_LEVEL',
|
||||||
|
'name': _("VKD3D Feature Level"),
|
||||||
|
'description': _("You can set a forced feature level VKD3D for games on DirectX12"),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'],
|
||||||
|
'default': disabled_text
|
||||||
|
})
|
||||||
|
|
||||||
|
# 10. Locale
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_LOCALE_SELECT',
|
||||||
|
'name': _("Locale"),
|
||||||
|
'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': [disabled_text] + locale_options,
|
||||||
|
'default': disabled_text
|
||||||
|
})
|
||||||
|
|
||||||
|
# 11. Present mode
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_MESA_VK_WSI_PRESENT_MODE',
|
||||||
|
'name': _("Window Mode"),
|
||||||
|
'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'],
|
||||||
|
'default': disabled_text
|
||||||
|
})
|
||||||
|
|
||||||
|
# 12. AMD Vulkan driver
|
||||||
|
amd_options = [disabled_text] + amd_vulkan_drivers if is_amd and amd_vulkan_drivers else [disabled_text]
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_AMD_VULKAN_USE',
|
||||||
|
'name': _("AMD Vulkan Driver"),
|
||||||
|
'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': amd_options,
|
||||||
|
'default': disabled_text
|
||||||
|
})
|
||||||
|
|
||||||
|
# 13. NUMA node
|
||||||
|
numa_ids = sorted(numa_nodes.keys())
|
||||||
|
numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text]
|
||||||
|
advanced_settings.append({
|
||||||
|
'key': 'PW_CPU_NUMA_NODE_INDEX',
|
||||||
|
'name': _("NUMA Node"),
|
||||||
|
'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."),
|
||||||
|
'type': 'combo',
|
||||||
|
'options': numa_options,
|
||||||
|
'default': disabled_text
|
||||||
|
})
|
||||||
|
|
||||||
|
return advanced_settings
|
||||||
|
|
||||||
|
|
||||||
|
# Keys that should be recognized as advanced settings
|
||||||
|
ADVANCED_SETTING_KEYS = [
|
||||||
|
'PW_WINE_USE',
|
||||||
|
'PW_PREFIX_NAME',
|
||||||
|
'PW_VULKAN_USE',
|
||||||
|
'PW_WINDOWS_VER',
|
||||||
|
'WINEDLLOVERRIDES',
|
||||||
|
'LAUNCH_PARAMETERS',
|
||||||
|
'PW_WINE_CPU_TOPOLOGY',
|
||||||
|
'PW_MESA_GL_VERSION_OVERRIDE',
|
||||||
|
'PW_VKD3D_FEATURE_LEVEL',
|
||||||
|
'PW_LOCALE_SELECT',
|
||||||
|
'PW_MESA_VK_WSI_PRESENT_MODE',
|
||||||
|
'PW_AMD_VULKAN_USE',
|
||||||
|
'PW_CPU_NUMA_NODE_INDEX',
|
||||||
|
]
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import functools
|
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -13,7 +12,7 @@ from portprotonqt.logger import get_logger
|
|||||||
from portprotonqt.localization import get_steam_language
|
from portprotonqt.localization import get_steam_language
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
from portprotonqt.dialogs import generate_thumbnail
|
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
|
from collections.abc import Callable
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -23,6 +22,7 @@ import requests
|
|||||||
import random
|
import random
|
||||||
import base64
|
import base64
|
||||||
import glob
|
import glob
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -261,21 +261,58 @@ def remove_duplicates(candidates):
|
|||||||
"""
|
"""
|
||||||
return list(dict.fromkeys(candidates))
|
return list(dict.fromkeys(candidates))
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=256)
|
# Simple TTL cache for exiftool data with max entries to control memory usage
|
||||||
|
_EXIFTOOL_CACHE = {}
|
||||||
|
_CACHE_MAX_ENTRIES = 64 # Limit cache size to control memory
|
||||||
|
_CACHE_TTL = 300 # 5 minutes TTL
|
||||||
|
|
||||||
def get_exiftool_data(game_exe):
|
def get_exiftool_data(game_exe):
|
||||||
"""Retrieves metadata using exiftool."""
|
"""Retrieves metadata using exiftool with TTL-based caching."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Clean up expired entries periodically
|
||||||
|
if len(_EXIFTOOL_CACHE) > _CACHE_MAX_ENTRIES // 2: # Clean when half full
|
||||||
|
# Remove expired entries
|
||||||
|
expired_keys = [
|
||||||
|
key for key, (data, timestamp) in _EXIFTOOL_CACHE.items()
|
||||||
|
if current_time - timestamp > _CACHE_TTL
|
||||||
|
]
|
||||||
|
for key in expired_keys:
|
||||||
|
del _EXIFTOOL_CACHE[key]
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
if game_exe in _EXIFTOOL_CACHE:
|
||||||
|
data, timestamp = _EXIFTOOL_CACHE[game_exe]
|
||||||
|
if current_time - timestamp <= _CACHE_TTL:
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
# Entry expired, remove it
|
||||||
|
del _EXIFTOOL_CACHE[game_exe]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["exiftool", "-j", game_exe],
|
["exiftool", "-j", game_exe],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False
|
check=False,
|
||||||
|
timeout=10 # Add timeout to prevent hanging
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
|
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
|
||||||
return {}
|
return {}
|
||||||
meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
|
meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
|
||||||
return meta_data_list[0] if meta_data_list else {}
|
result = meta_data_list[0] if meta_data_list else {}
|
||||||
|
|
||||||
|
# Add to cache if we have a reasonable result
|
||||||
|
if result and len(_EXIFTOOL_CACHE) < _CACHE_MAX_ENTRIES:
|
||||||
|
_EXIFTOOL_CACHE[game_exe] = (result, current_time)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error(f"exiftool timed out for {game_exe}")
|
||||||
|
return {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
|
logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
|
||||||
return {}
|
return {}
|
||||||
@@ -322,6 +359,17 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
|||||||
logger.info("Deleted archive: %s", cache_tar)
|
logger.info("Deleted archive: %s", cache_tar)
|
||||||
# Delete all cached app detail files (steam_app_*.json)
|
# Delete all cached app detail files (steam_app_*.json)
|
||||||
delete_cached_app_files(cache_dir, "steam_app_*.json")
|
delete_cached_app_files(cache_dir, "steam_app_*.json")
|
||||||
|
|
||||||
|
# Build the new index in the background and atomically update the cache
|
||||||
|
new_index = build_index(data) if isinstance(data, list) else {}
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Atomically update the cache
|
||||||
|
with _STEAM_APPS_LOCK:
|
||||||
|
_STEAM_APPS_CACHE['data'] = data if isinstance(data, list) else []
|
||||||
|
_STEAM_APPS_CACHE['index'] = new_index
|
||||||
|
_STEAM_APPS_CACHE['timestamp'] = current_time
|
||||||
|
|
||||||
steam_apps = data if isinstance(data, list) else []
|
steam_apps = data if isinstance(data, list) else []
|
||||||
logger.info("Loaded %d apps from archive", len(steam_apps))
|
logger.info("Loaded %d apps from archive", len(steam_apps))
|
||||||
callback(steam_apps)
|
callback(steam_apps)
|
||||||
@@ -372,25 +420,31 @@ def build_index(steam_apps):
|
|||||||
return steam_apps_index
|
return steam_apps_index
|
||||||
logger.info("Building Steam apps index")
|
logger.info("Building Steam apps index")
|
||||||
for app in steam_apps:
|
for app in steam_apps:
|
||||||
normalized = app["normalized_name"]
|
normalized = app.get("normalized_name", "")
|
||||||
|
if normalized: # Only add if normalized_name exists
|
||||||
steam_apps_index[normalized] = app
|
steam_apps_index[normalized] = app
|
||||||
return steam_apps_index
|
return steam_apps_index
|
||||||
|
|
||||||
def search_app(candidate, steam_apps_index):
|
def search_app(candidate, steam_apps_index):
|
||||||
"""
|
"""
|
||||||
Searches for an application by candidate: tries exact match first, then substring match.
|
Searches for an application by candidate: tries exact match first, then partial match.
|
||||||
"""
|
"""
|
||||||
candidate_norm = normalize_name(candidate)
|
candidate_norm = normalize_name(candidate)
|
||||||
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
|
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||||
|
|
||||||
|
# Exact match first (O(1) lookup)
|
||||||
if candidate_norm in steam_apps_index:
|
if candidate_norm in steam_apps_index:
|
||||||
logger.info("Found exact match: '%s'", candidate_norm)
|
logger.info("Found exact match: '%s'", candidate_norm)
|
||||||
return steam_apps_index[candidate_norm]
|
return steam_apps_index[candidate_norm]
|
||||||
|
|
||||||
|
# If no exact match, try partial matching
|
||||||
for name_norm, app in steam_apps_index.items():
|
for name_norm, app in steam_apps_index.items():
|
||||||
if candidate_norm in name_norm:
|
if candidate_norm in name_norm:
|
||||||
ratio = len(candidate_norm) / len(name_norm)
|
ratio = len(candidate_norm) / len(name_norm)
|
||||||
if ratio > 0.8:
|
if ratio > 0.8:
|
||||||
logger.info("Found partial match: candidate '%s' in '%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
|
return app
|
||||||
|
|
||||||
logger.info("No app found for candidate '%s'", candidate_norm)
|
logger.info("No app found for candidate '%s'", candidate_norm)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -411,6 +465,52 @@ def save_app_details(app_id, data):
|
|||||||
with open(cache_file, "wb") as f:
|
with open(cache_file, "wb") as f:
|
||||||
f.write(orjson.dumps(data))
|
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=10)
|
||||||
|
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 requests.exceptions.Timeout:
|
||||||
|
logger.warning(f"SGDB request timed out for {game_name}")
|
||||||
|
return ""
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.warning(f"SGDB request error for {game_name}: {e}")
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unexpected error while fetching SGDB cover for {game_name}: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def check_url_exists(url: str) -> bool:
|
||||||
|
"""Check whether a URL returns HTTP 200."""
|
||||||
|
try:
|
||||||
|
r = requests.head(url, timeout=5)
|
||||||
|
return r.status_code == 200
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning(f"URL check timed out for: {url}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.warning(f"Request error when checking URL {url}: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unexpected error when checking URL {url}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
|
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
|
||||||
"""
|
"""
|
||||||
Asynchronously fetches detailed app info from Steam API.
|
Asynchronously fetches detailed app info from Steam API.
|
||||||
@@ -484,6 +584,16 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
|||||||
if os.path.exists(cache_tar):
|
if os.path.exists(cache_tar):
|
||||||
os.remove(cache_tar)
|
os.remove(cache_tar)
|
||||||
logger.info("Deleted archive: %s", cache_tar)
|
logger.info("Deleted archive: %s", cache_tar)
|
||||||
|
# Build the new index in the background and atomically update the cache
|
||||||
|
new_index = build_weanticheatyet_index(data) if isinstance(data, list) else {}
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Atomically update the cache
|
||||||
|
with _ANTICHEAT_LOCK:
|
||||||
|
_ANTICHEAT_CACHE['data'] = data if isinstance(data, list) else []
|
||||||
|
_ANTICHEAT_CACHE['index'] = new_index
|
||||||
|
_ANTICHEAT_CACHE['timestamp'] = current_time
|
||||||
|
|
||||||
anti_cheat_data = data or []
|
anti_cheat_data = data or []
|
||||||
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
|
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
|
||||||
callback(anti_cheat_data)
|
callback(anti_cheat_data)
|
||||||
@@ -530,17 +640,25 @@ def build_weanticheatyet_index(anti_cheat_data):
|
|||||||
return anti_cheat_index
|
return anti_cheat_index
|
||||||
logger.info("Building WeAntiCheatYet data index")
|
logger.info("Building WeAntiCheatYet data index")
|
||||||
for entry in anti_cheat_data:
|
for entry in anti_cheat_data:
|
||||||
normalized = entry["normalized_name"]
|
normalized = entry.get("normalized_name", "")
|
||||||
|
if normalized: # Only add if normalized_name exists
|
||||||
anti_cheat_index[normalized] = entry
|
anti_cheat_index[normalized] = entry
|
||||||
return anti_cheat_index
|
return anti_cheat_index
|
||||||
|
|
||||||
def search_anticheat_status(candidate, anti_cheat_index):
|
def search_anticheat_status(candidate, anti_cheat_index):
|
||||||
|
"""
|
||||||
|
Searches for anti-cheat status by candidate: tries exact match first, then partial match.
|
||||||
|
"""
|
||||||
candidate_norm = normalize_name(candidate)
|
candidate_norm = normalize_name(candidate)
|
||||||
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
|
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||||
|
|
||||||
|
# Exact match first (O(1) lookup)
|
||||||
if candidate_norm in anti_cheat_index:
|
if candidate_norm in anti_cheat_index:
|
||||||
status = anti_cheat_index[candidate_norm]["status"]
|
status = anti_cheat_index[candidate_norm]["status"]
|
||||||
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
|
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
# If no exact match, try partial matching
|
||||||
for name_norm, entry in anti_cheat_index.items():
|
for name_norm, entry in anti_cheat_index.items():
|
||||||
if candidate_norm in name_norm:
|
if candidate_norm in name_norm:
|
||||||
ratio = len(candidate_norm) / len(name_norm)
|
ratio = len(candidate_norm) / len(name_norm)
|
||||||
@@ -548,20 +666,122 @@ def search_anticheat_status(candidate, anti_cheat_index):
|
|||||||
status = entry["status"]
|
status = entry["status"]
|
||||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%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
|
return status
|
||||||
|
|
||||||
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
|
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
# Cache for WeAntiCheatYet data with timestamp for expiration
|
||||||
|
_ANTICHEAT_CACHE = {
|
||||||
|
'data': None,
|
||||||
|
'index': None,
|
||||||
|
'timestamp': 0
|
||||||
|
}
|
||||||
|
_ANTICHEAT_LOCK = threading.RLock() # Use RLock to allow reentrant calls
|
||||||
|
|
||||||
|
# Use a class to track loading state instead of dynamic function attributes
|
||||||
|
class AntiCheatDataLoader:
|
||||||
|
def __init__(self):
|
||||||
|
self._loading = False
|
||||||
|
self._pending_callbacks = []
|
||||||
|
|
||||||
|
def get_anticheat_data_and_index_async(self, callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||||
|
"""
|
||||||
|
Asynchronously loads and caches anti-cheat data and their index.
|
||||||
|
Calls the callback with (anti_cheat_data, anti_cheat_index).
|
||||||
|
Implements proper cache expiration and thread safety with single index building.
|
||||||
|
"""
|
||||||
|
cache_duration = CACHE_DURATION
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
with _ANTICHEAT_LOCK:
|
||||||
|
# Check if we have valid cached data
|
||||||
|
if (_ANTICHEAT_CACHE['data'] is not None and
|
||||||
|
_ANTICHEAT_CACHE['index'] is not None and
|
||||||
|
current_time - _ANTICHEAT_CACHE['timestamp'] < cache_duration):
|
||||||
|
callback((_ANTICHEAT_CACHE['data'], _ANTICHEAT_CACHE['index']))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there's already a loading operation in progress
|
||||||
|
if self._loading:
|
||||||
|
# Add this callback to the pending list to be called when loading completes
|
||||||
|
self._pending_callbacks.append(callback)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mark that loading is in progress
|
||||||
|
self._loading = True
|
||||||
|
self._pending_callbacks = []
|
||||||
|
|
||||||
|
def on_anticheat_data(anti_cheat_data: list):
|
||||||
|
current_time = time.time()
|
||||||
|
with _ANTICHEAT_LOCK:
|
||||||
|
# Only update cache if data is valid
|
||||||
|
if anti_cheat_data:
|
||||||
|
_ANTICHEAT_CACHE['data'] = anti_cheat_data
|
||||||
|
_ANTICHEAT_CACHE['index'] = build_weanticheatyet_index(anti_cheat_data)
|
||||||
|
_ANTICHEAT_CACHE['timestamp'] = current_time
|
||||||
|
cached_data = (_ANTICHEAT_CACHE['data'], _ANTICHEAT_CACHE['index'])
|
||||||
|
else:
|
||||||
|
# If loading failed, clear the cache to force reload on next attempt
|
||||||
|
_ANTICHEAT_CACHE['data'] = None
|
||||||
|
_ANTICHEAT_CACHE['index'] = None
|
||||||
|
_ANTICHEAT_CACHE['timestamp'] = 0
|
||||||
|
cached_data = (None, None)
|
||||||
|
|
||||||
|
# Mark loading as complete
|
||||||
|
self._loading = False
|
||||||
|
pending_callbacks = self._pending_callbacks
|
||||||
|
self._pending_callbacks = []
|
||||||
|
|
||||||
|
# Call the original callback
|
||||||
|
callback(cached_data)
|
||||||
|
# Call any pending callbacks that accumulated during loading
|
||||||
|
for pending_callback in pending_callbacks:
|
||||||
|
pending_callback(cached_data)
|
||||||
|
|
||||||
|
load_weanticheatyet_data_async(on_anticheat_data)
|
||||||
|
|
||||||
|
# Create a global instance for the anti-cheat data loader
|
||||||
|
_anticheat_loader = AntiCheatDataLoader()
|
||||||
|
|
||||||
|
def get_anticheat_data_and_index_async(callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||||
|
"""
|
||||||
|
Asynchronously loads and caches anti-cheat data and their index.
|
||||||
|
Calls the callback with (anti_cheat_data, anti_cheat_index).
|
||||||
|
Implements proper cache expiration and thread safety with single index building.
|
||||||
|
"""
|
||||||
|
_anticheat_loader.get_anticheat_data_and_index_async(callback)
|
||||||
|
|
||||||
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
|
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
|
||||||
"""
|
"""
|
||||||
Asynchronously retrieves WeAntiCheatYet status for a game by name.
|
Asynchronously retrieves WeAntiCheatYet status for a game by name.
|
||||||
Calls the callback with the status string or empty string if not found.
|
Calls the callback with the status string or empty string if not found.
|
||||||
"""
|
"""
|
||||||
def on_anticheat_data(anti_cheat_data: list):
|
def on_anticheat_data_and_index(data_and_index: tuple[list | None, dict | None]):
|
||||||
anti_cheat_index = build_weanticheatyet_index(anti_cheat_data)
|
anti_cheat_data, anti_cheat_index = data_and_index
|
||||||
|
if anti_cheat_data and anti_cheat_index:
|
||||||
status = search_anticheat_status(game_name, anti_cheat_index)
|
status = search_anticheat_status(game_name, anti_cheat_index)
|
||||||
|
else:
|
||||||
|
status = ""
|
||||||
callback(status)
|
callback(status)
|
||||||
|
|
||||||
load_weanticheatyet_data_async(on_anticheat_data)
|
get_anticheat_data_and_index_async(on_anticheat_data_and_index)
|
||||||
|
|
||||||
|
def clear_steam_api_caches():
|
||||||
|
"""Clears all cached data to force reload from files."""
|
||||||
|
global _STEAM_APPS_CACHE, _ANTICHEAT_CACHE
|
||||||
|
with _STEAM_APPS_LOCK:
|
||||||
|
_STEAM_APPS_CACHE = {
|
||||||
|
'data': None,
|
||||||
|
'index': None,
|
||||||
|
'timestamp': 0
|
||||||
|
}
|
||||||
|
with _ANTICHEAT_LOCK:
|
||||||
|
_ANTICHEAT_CACHE = {
|
||||||
|
'data': None,
|
||||||
|
'index': None,
|
||||||
|
'timestamp': 0
|
||||||
|
}
|
||||||
|
logger.info("Cleared Steam API caches")
|
||||||
|
|
||||||
def load_protondb_status(appid):
|
def load_protondb_status(appid):
|
||||||
"""Loads cached ProtonDB data for a game by appid if not outdated."""
|
"""Loads cached ProtonDB data for a game by appid if not outdated."""
|
||||||
@@ -629,6 +849,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None])
|
|||||||
title = decode_text(app_info.get("name", ""))
|
title = decode_text(app_info.get("name", ""))
|
||||||
description = decode_text(app_info.get("short_description", ""))
|
description = decode_text(app_info.get("short_description", ""))
|
||||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
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_protondb_tier(tier: str):
|
||||||
def on_anticheat_status(anticheat_status: str):
|
def on_anticheat_status(anticheat_status: str):
|
||||||
@@ -708,9 +933,30 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
|||||||
candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True)
|
candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True)
|
||||||
logger.info("Sorted candidates: %s", candidates_ordered)
|
logger.info("Sorted candidates: %s", candidates_ordered)
|
||||||
|
|
||||||
def on_steam_apps(steam_apps: list):
|
def on_steam_apps_and_index(data_and_index: tuple[list | None, dict | None]):
|
||||||
steam_apps_index = build_index(steam_apps)
|
steam_apps, steam_apps_index = data_and_index
|
||||||
matching_app = None
|
matching_app = None
|
||||||
|
if not steam_apps or not steam_apps_index:
|
||||||
|
# Handle case where data loading failed
|
||||||
|
game_name = desktop_name or exe_name
|
||||||
|
cover = fetch_sgdb_cover(game_name) or ""
|
||||||
|
logger.info("Using SGDB cover for non-Steam game due to data loading failure: %s", game_name)
|
||||||
|
|
||||||
|
def on_anticheat_status(anticheat_status: str):
|
||||||
|
callback({
|
||||||
|
"appid": "",
|
||||||
|
"name": decode_text(game_name),
|
||||||
|
"description": "",
|
||||||
|
"cover": cover,
|
||||||
|
"controller_support": "",
|
||||||
|
"protondb_tier": "",
|
||||||
|
"steam_game": "false",
|
||||||
|
"anticheat_status": anticheat_status
|
||||||
|
})
|
||||||
|
|
||||||
|
get_weanticheatyet_status_async(game_name, on_anticheat_status)
|
||||||
|
return
|
||||||
|
|
||||||
for candidate in candidates_ordered:
|
for candidate in candidates_ordered:
|
||||||
if not candidate:
|
if not candidate:
|
||||||
continue
|
continue
|
||||||
@@ -722,12 +968,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
|||||||
game_name = desktop_name or exe_name.capitalize()
|
game_name = desktop_name or exe_name.capitalize()
|
||||||
|
|
||||||
if not matching_app:
|
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):
|
def on_anticheat_status(anticheat_status: str):
|
||||||
callback({
|
callback({
|
||||||
"appid": "",
|
"appid": "",
|
||||||
"name": decode_text(game_name),
|
"name": decode_text(game_name),
|
||||||
"description": "",
|
"description": "",
|
||||||
"cover": "",
|
"cover": cover,
|
||||||
"controller_support": "",
|
"controller_support": "",
|
||||||
"protondb_tier": "",
|
"protondb_tier": "",
|
||||||
"steam_game": "false",
|
"steam_game": "false",
|
||||||
@@ -758,6 +1007,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
|||||||
title = decode_text(app_info.get("name", game_name))
|
title = decode_text(app_info.get("name", game_name))
|
||||||
description = decode_text(app_info.get("short_description", ""))
|
description = decode_text(app_info.get("short_description", ""))
|
||||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
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", "")
|
controller_support = app_info.get("controller_support", "")
|
||||||
|
|
||||||
def on_protondb_tier(tier: str):
|
def on_protondb_tier(tier: str):
|
||||||
@@ -779,32 +1033,89 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
|||||||
|
|
||||||
fetch_app_info_async(appid, on_app_info)
|
fetch_app_info_async(appid, on_app_info)
|
||||||
|
|
||||||
load_steam_apps_async(on_steam_apps)
|
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||||
|
|
||||||
_STEAM_APPS = None
|
# Cache for Steam apps data with timestamp for expiration
|
||||||
_STEAM_APPS_INDEX = None
|
_STEAM_APPS_CACHE = {
|
||||||
_STEAM_APPS_LOCK = threading.Lock()
|
'data': None,
|
||||||
|
'index': None,
|
||||||
|
'timestamp': 0
|
||||||
|
}
|
||||||
|
_STEAM_APPS_LOCK = threading.RLock() # Use RLock to allow reentrant calls
|
||||||
|
|
||||||
def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]):
|
# Use a class to track loading state instead of dynamic function attributes
|
||||||
|
class SteamAppsLoader:
|
||||||
|
def __init__(self):
|
||||||
|
self._loading = False
|
||||||
|
self._pending_callbacks = []
|
||||||
|
|
||||||
|
def get_steam_apps_and_index_async(self, callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||||
"""
|
"""
|
||||||
Asynchronously loads and caches Steam apps and their index.
|
Asynchronously loads and caches Steam apps and their index.
|
||||||
Calls the callback with (steam_apps, steam_apps_index).
|
Calls the callback with (steam_apps, steam_apps_index).
|
||||||
|
Implements proper cache expiration and thread safety with single index building.
|
||||||
"""
|
"""
|
||||||
global _STEAM_APPS, _STEAM_APPS_INDEX
|
cache_duration = CACHE_DURATION
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
with _STEAM_APPS_LOCK:
|
with _STEAM_APPS_LOCK:
|
||||||
if _STEAM_APPS is not None and _STEAM_APPS_INDEX is not None:
|
# Check if we have valid cached data
|
||||||
callback((_STEAM_APPS, _STEAM_APPS_INDEX))
|
if (_STEAM_APPS_CACHE['data'] is not None and
|
||||||
|
_STEAM_APPS_CACHE['index'] is not None and
|
||||||
|
current_time - _STEAM_APPS_CACHE['timestamp'] < cache_duration):
|
||||||
|
callback((_STEAM_APPS_CACHE['data'], _STEAM_APPS_CACHE['index']))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check if there's already a loading operation in progress
|
||||||
|
if self._loading:
|
||||||
|
# Add this callback to the pending list to be called when loading completes
|
||||||
|
self._pending_callbacks.append(callback)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mark that loading is in progress
|
||||||
|
self._loading = True
|
||||||
|
self._pending_callbacks = []
|
||||||
|
|
||||||
def on_steam_apps(steam_apps: list):
|
def on_steam_apps(steam_apps: list):
|
||||||
global _STEAM_APPS, _STEAM_APPS_INDEX
|
current_time = time.time()
|
||||||
with _STEAM_APPS_LOCK:
|
with _STEAM_APPS_LOCK:
|
||||||
_STEAM_APPS = steam_apps
|
# Only update cache if data is valid
|
||||||
_STEAM_APPS_INDEX = build_index(steam_apps)
|
if steam_apps:
|
||||||
callback((_STEAM_APPS, _STEAM_APPS_INDEX))
|
_STEAM_APPS_CACHE['data'] = steam_apps
|
||||||
|
_STEAM_APPS_CACHE['index'] = build_index(steam_apps)
|
||||||
|
_STEAM_APPS_CACHE['timestamp'] = current_time
|
||||||
|
cached_data = (_STEAM_APPS_CACHE['data'], _STEAM_APPS_CACHE['index'])
|
||||||
|
else:
|
||||||
|
# If loading failed, clear the cache to force reload on next attempt
|
||||||
|
_STEAM_APPS_CACHE['data'] = None
|
||||||
|
_STEAM_APPS_CACHE['index'] = None
|
||||||
|
_STEAM_APPS_CACHE['timestamp'] = 0
|
||||||
|
cached_data = (None, None)
|
||||||
|
|
||||||
|
# Mark loading as complete
|
||||||
|
self._loading = False
|
||||||
|
pending_callbacks = self._pending_callbacks
|
||||||
|
self._pending_callbacks = []
|
||||||
|
|
||||||
|
# Call the original callback
|
||||||
|
callback(cached_data)
|
||||||
|
# Call any pending callbacks that accumulated during loading
|
||||||
|
for pending_callback in pending_callbacks:
|
||||||
|
pending_callback(cached_data)
|
||||||
|
|
||||||
load_steam_apps_async(on_steam_apps)
|
load_steam_apps_async(on_steam_apps)
|
||||||
|
|
||||||
|
# Create a global instance for the Steam apps loader
|
||||||
|
_steam_apps_loader = SteamAppsLoader()
|
||||||
|
|
||||||
|
def get_steam_apps_and_index_async(callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||||
|
"""
|
||||||
|
Asynchronously loads and caches Steam apps and their index.
|
||||||
|
Calls the callback with (steam_apps, steam_apps_index).
|
||||||
|
Implements proper cache expiration and thread safety with single index building.
|
||||||
|
"""
|
||||||
|
_steam_apps_loader.get_steam_apps_and_index_async(callback)
|
||||||
|
|
||||||
def enable_steam_cef() -> tuple[bool, str]:
|
def enable_steam_cef() -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Checks and enables Steam CEF remote debugging if necessary.
|
Checks and enables Steam CEF remote debugging if necessary.
|
||||||
@@ -957,7 +1268,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}")
|
return (False, f"Executable file not found: {exe_path}")
|
||||||
|
|
||||||
portproton_dir = get_portproton_location()
|
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")
|
logger.error("PortProton directory not found")
|
||||||
return (False, "PortProton directory not found")
|
return (False, "PortProton directory not found")
|
||||||
|
|
||||||
@@ -966,17 +1278,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())
|
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
|
||||||
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
|
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):
|
if not os.path.exists(script_path):
|
||||||
script_content = f"""#!/usr/bin/env bash
|
script_content = f"""#!/usr/bin/env bash
|
||||||
export LD_PRELOAD=
|
export LD_PRELOAD=
|
||||||
export START_FROM_STEAM=1
|
export START_FROM_STEAM=1
|
||||||
"{start_sh_path}" "{exe_path}" "$@"
|
"{start_sh}" "{exe_path}" "$@"
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(script_path, "w", encoding="utf-8") as f:
|
with open(script_path, "w", encoding="utf-8") as f:
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
import ast
|
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
|
from portprotonqt.theme_security import check_theme_safety, is_safe_image_file
|
||||||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
|
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
|
||||||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
|
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
|
||||||
|
from portprotonqt.localization import get_screenshot_caption
|
||||||
|
|
||||||
|
# Icon caching for performance optimization
|
||||||
|
_icon_cache = {}
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -15,57 +19,6 @@ THEMES_DIRS = [
|
|||||||
]
|
]
|
||||||
_loaded_theme = None
|
_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():
|
def list_themes():
|
||||||
"""
|
"""
|
||||||
@@ -83,20 +36,36 @@ def list_themes():
|
|||||||
def load_theme_screenshots(theme_name):
|
def load_theme_screenshots(theme_name):
|
||||||
"""
|
"""
|
||||||
Загружает все скриншоты из папки "screenshots", расположенной в папке темы.
|
Загружает все скриншоты из папки "screenshots", расположенной в папке темы.
|
||||||
Возвращает список кортежей (pixmap, filename).
|
Возвращает список кортежей (pixmap, caption), где caption - это перевод названия скриншота.
|
||||||
Если папка отсутствует или пуста, возвращается пустой список.
|
Если папка отсутствует или пуста, возвращается пустой список.
|
||||||
"""
|
"""
|
||||||
screenshots = []
|
screenshots = []
|
||||||
|
|
||||||
|
# Find the metainfo file for the theme
|
||||||
|
metainfo_file = None
|
||||||
|
for themes_dir in THEMES_DIRS:
|
||||||
|
theme_folder = os.path.join(themes_dir, theme_name)
|
||||||
|
temp_metainfo_file = os.path.join(theme_folder, "metainfo.ini")
|
||||||
|
if os.path.exists(temp_metainfo_file):
|
||||||
|
metainfo_file = temp_metainfo_file
|
||||||
|
break
|
||||||
|
|
||||||
for themes_dir in THEMES_DIRS:
|
for themes_dir in THEMES_DIRS:
|
||||||
theme_folder = os.path.join(themes_dir, theme_name)
|
theme_folder = os.path.join(themes_dir, theme_name)
|
||||||
screenshots_folder = os.path.join(theme_folder, "images", "screenshots")
|
screenshots_folder = os.path.join(theme_folder, "images", "screenshots")
|
||||||
if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder):
|
if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder):
|
||||||
for file in os.listdir(screenshots_folder):
|
for file in os.listdir(screenshots_folder):
|
||||||
screenshot_path = os.path.join(screenshots_folder, file)
|
screenshot_path = os.path.join(screenshots_folder, file)
|
||||||
if os.path.isfile(screenshot_path):
|
if os.path.isfile(screenshot_path) and is_safe_image_file(screenshot_path):
|
||||||
pixmap = QPixmap(screenshot_path)
|
pixmap = QPixmap(screenshot_path)
|
||||||
if not pixmap.isNull():
|
if not pixmap.isNull():
|
||||||
screenshots.append((pixmap, file))
|
# Get the base filename without extension
|
||||||
|
base_filename = os.path.splitext(file)[0]
|
||||||
|
|
||||||
|
# Get translated caption using localization function
|
||||||
|
caption = get_screenshot_caption(base_filename, metainfo_file)
|
||||||
|
|
||||||
|
screenshots.append((pixmap, caption))
|
||||||
return screenshots
|
return screenshots
|
||||||
|
|
||||||
def load_theme_fonts(theme_name):
|
def load_theme_fonts(theme_name):
|
||||||
@@ -108,7 +77,20 @@ def load_theme_fonts(theme_name):
|
|||||||
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
|
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def load_fonts_delayed():
|
||||||
|
global _loaded_theme
|
||||||
|
try:
|
||||||
|
# Only remove fonts if this is a theme change (not initial load)
|
||||||
|
current_loaded_theme = _loaded_theme # Capture the current value
|
||||||
|
if current_loaded_theme is not None and current_loaded_theme != theme_name:
|
||||||
|
# Run font removal in the GUI thread with delay
|
||||||
QFontDatabase.removeAllApplicationFonts()
|
QFontDatabase.removeAllApplicationFonts()
|
||||||
|
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
start_time = time.time()
|
||||||
|
timeout = 3 # Reduced timeout to 3 seconds for faster loading
|
||||||
|
|
||||||
fonts_folder = None
|
fonts_folder = None
|
||||||
if theme_name == "standart":
|
if theme_name == "standart":
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -125,8 +107,19 @@ def load_theme_fonts(theme_name):
|
|||||||
logger.error(f"Fonts folder not found for theme '{theme_name}'")
|
logger.error(f"Fonts folder not found for theme '{theme_name}'")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
font_files = []
|
||||||
for filename in os.listdir(fonts_folder):
|
for filename in os.listdir(fonts_folder):
|
||||||
if filename.lower().endswith((".ttf", ".otf")):
|
if filename.lower().endswith((".ttf", ".otf")):
|
||||||
|
font_files.append(filename)
|
||||||
|
|
||||||
|
# Limit number of fonts loaded to prevent too much blocking
|
||||||
|
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
|
||||||
|
|
||||||
|
for filename in font_files:
|
||||||
|
if time.time() - start_time > timeout:
|
||||||
|
logger.warning(f"Font loading timed out for theme '{theme_name}' after loading {len(font_files)} fonts")
|
||||||
|
break
|
||||||
|
|
||||||
font_path = os.path.join(fonts_folder, filename)
|
font_path = os.path.join(fonts_folder, filename)
|
||||||
font_id = QFontDatabase.addApplicationFont(font_path)
|
font_id = QFontDatabase.addApplicationFont(font_path)
|
||||||
if font_id != -1:
|
if font_id != -1:
|
||||||
@@ -135,7 +128,14 @@ def load_theme_fonts(theme_name):
|
|||||||
else:
|
else:
|
||||||
logger.error(f"Error loading font: {filename}")
|
logger.error(f"Error loading font: {filename}")
|
||||||
|
|
||||||
|
# Update the global variable in the main thread
|
||||||
_loaded_theme = theme_name
|
_loaded_theme = theme_name
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
|
||||||
|
|
||||||
|
# Use QTimer to delay font loading until after the UI is rendered
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
QTimer.singleShot(100, load_fonts_delayed) # Delay font loading by 100ms
|
||||||
|
|
||||||
class ThemeWrapper:
|
class ThemeWrapper:
|
||||||
"""
|
"""
|
||||||
@@ -232,6 +232,14 @@ class ThemeManager:
|
|||||||
а если файл не найден, то из стандартной темы.
|
а если файл не найден, то из стандартной темы.
|
||||||
Если as_path=True, возвращает путь к иконке вместо QIcon.
|
Если as_path=True, возвращает путь к иконке вместо QIcon.
|
||||||
"""
|
"""
|
||||||
|
# Create cache key
|
||||||
|
cache_key = f"{icon_name}_{theme_name or self.current_theme_name}_{as_path}"
|
||||||
|
|
||||||
|
# Check if we already have this icon cached
|
||||||
|
if cache_key in _icon_cache:
|
||||||
|
logger.debug(f"Using cached icon for {icon_name}")
|
||||||
|
return _icon_cache[cache_key]
|
||||||
|
|
||||||
icon_path = None
|
icon_path = None
|
||||||
theme_name = theme_name or self.current_theme_name
|
theme_name = theme_name or self.current_theme_name
|
||||||
supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
|
supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
|
||||||
@@ -246,14 +254,14 @@ class ThemeManager:
|
|||||||
# Если передано имя с расширением, проверяем только этот файл
|
# Если передано имя с расширением, проверяем только этот файл
|
||||||
if has_extension:
|
if has_extension:
|
||||||
candidate = os.path.join(icons_folder, str(base_name))
|
candidate = os.path.join(icons_folder, str(base_name))
|
||||||
if os.path.exists(candidate):
|
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||||
icon_path = candidate
|
icon_path = candidate
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# Проверяем все поддерживаемые расширения
|
# Проверяем все поддерживаемые расширения
|
||||||
for ext in supported_extensions:
|
for ext in supported_extensions:
|
||||||
candidate = os.path.join(icons_folder, str(base_name) + str(ext))
|
candidate = os.path.join(icons_folder, str(base_name) + str(ext))
|
||||||
if os.path.exists(candidate):
|
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||||
icon_path = candidate
|
icon_path = candidate
|
||||||
break
|
break
|
||||||
if icon_path:
|
if icon_path:
|
||||||
@@ -267,24 +275,32 @@ class ThemeManager:
|
|||||||
# Аналогично проверяем в стандартной теме
|
# Аналогично проверяем в стандартной теме
|
||||||
if has_extension:
|
if has_extension:
|
||||||
icon_path = os.path.join(standard_icons_folder, base_name)
|
icon_path = os.path.join(standard_icons_folder, base_name)
|
||||||
if not os.path.exists(icon_path):
|
if not os.path.exists(icon_path) or not is_safe_image_file(icon_path):
|
||||||
icon_path = None
|
icon_path = None
|
||||||
else:
|
else:
|
||||||
for ext in supported_extensions:
|
for ext in supported_extensions:
|
||||||
candidate = os.path.join(standard_icons_folder, base_name + ext)
|
candidate = os.path.join(standard_icons_folder, base_name + ext)
|
||||||
if os.path.exists(candidate):
|
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||||
icon_path = candidate
|
icon_path = candidate
|
||||||
break
|
break
|
||||||
|
|
||||||
# Если иконка всё равно не найдена
|
# Если иконка всё равно не найдена
|
||||||
if not icon_path or not os.path.exists(icon_path):
|
if not icon_path or not os.path.exists(icon_path):
|
||||||
logger.error(f"Warning: icon '{icon_name}' not found")
|
logger.error(f"Warning: icon '{icon_name}' not found")
|
||||||
return QIcon() if not as_path else None
|
result = QIcon() if not as_path else None
|
||||||
|
# Cache the result even if it's None
|
||||||
|
_icon_cache[cache_key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
if as_path:
|
if as_path:
|
||||||
|
# Cache the path
|
||||||
|
_icon_cache[cache_key] = icon_path
|
||||||
return icon_path
|
return icon_path
|
||||||
|
|
||||||
return QIcon(icon_path)
|
# Create QIcon and cache it
|
||||||
|
icon = QIcon(icon_path)
|
||||||
|
_icon_cache[cache_key] = icon
|
||||||
|
return icon
|
||||||
|
|
||||||
def get_theme_image(self, image_name, theme_name=None):
|
def get_theme_image(self, image_name, theme_name=None):
|
||||||
"""
|
"""
|
||||||
@@ -307,13 +323,13 @@ class ThemeManager:
|
|||||||
|
|
||||||
if has_extension:
|
if has_extension:
|
||||||
candidate = os.path.join(images_folder, str(base_name))
|
candidate = os.path.join(images_folder, str(base_name))
|
||||||
if os.path.exists(candidate):
|
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||||
image_path = candidate
|
image_path = candidate
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
for ext in supported_extensions:
|
for ext in supported_extensions:
|
||||||
candidate = os.path.join(images_folder, str(base_name) + str(ext))
|
candidate = os.path.join(images_folder, str(base_name) + str(ext))
|
||||||
if os.path.exists(candidate):
|
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||||
image_path = candidate
|
image_path = candidate
|
||||||
break
|
break
|
||||||
if image_path:
|
if image_path:
|
||||||
@@ -326,12 +342,12 @@ class ThemeManager:
|
|||||||
|
|
||||||
if has_extension:
|
if has_extension:
|
||||||
image_path = os.path.join(standard_images_folder, base_name)
|
image_path = os.path.join(standard_images_folder, base_name)
|
||||||
if not os.path.exists(image_path):
|
if not os.path.exists(image_path) or not is_safe_image_file(image_path):
|
||||||
image_path = None
|
image_path = None
|
||||||
else:
|
else:
|
||||||
for ext in supported_extensions:
|
for ext in supported_extensions:
|
||||||
candidate = os.path.join(standard_images_folder, base_name + ext)
|
candidate = os.path.join(standard_images_folder, base_name + ext)
|
||||||
if os.path.exists(candidate):
|
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||||
image_path = candidate
|
image_path = candidate
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
444
portprotonqt/theme_security.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
"""
|
||||||
|
Theme security module for PortProtonQt.
|
||||||
|
Provides enhanced security checks for theme files to prevent malicious code execution.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
from portprotonqt.logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeSecurityChecker:
|
||||||
|
"""
|
||||||
|
Enhanced security checker for theme files.
|
||||||
|
Identifies and blocks various attack vectors in theme Python files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Basic forbidden modules that could allow dangerous operations
|
||||||
|
FORBIDDEN_MODULES = {
|
||||||
|
# File system operations
|
||||||
|
"os", "shutil", "pathlib", "glob", "tempfile", "filecmp", "fileinput",
|
||||||
|
"linecache", "io", "mmap", "fnmatch", "difflib",
|
||||||
|
|
||||||
|
# Process and system operations
|
||||||
|
"subprocess", "sys", "ctypes", "cffi", "platform", "resource", "signal",
|
||||||
|
"multiprocessing", "concurrent", "threading", "asyncio", "select",
|
||||||
|
"selectors", "queue", "sched", "contextvars",
|
||||||
|
|
||||||
|
# Network operations
|
||||||
|
"socket", "urllib", "urllib2", "urllib.request", "urllib.parse",
|
||||||
|
"urllib.error", "urllib.robotparser", "http", "http.client",
|
||||||
|
"http.cookies", "http.cookiejar", "ftplib", "telnetlib", "smtplib",
|
||||||
|
"poplib", "imaplib", "nntplib", "socketserver", "xmlrpc", "xmlrpc.client",
|
||||||
|
"xmlrpc.server", "ipaddress", "webbrowser", "ssl", "uuid",
|
||||||
|
|
||||||
|
# Code execution and dynamic imports
|
||||||
|
"code", "codeop", "compileall", "py_compile", "runpy", "zipimport",
|
||||||
|
"pkgutil", "pkg_resources", "importlib", "importlib.util",
|
||||||
|
"importlib.import_module", "importlib.resources", "importlib.metadata",
|
||||||
|
"builtins", "exec", "eval", "__import__", "compile", "execfile",
|
||||||
|
"imp", "importlib.machinery", "importlib.abc", "importlib.load_module",
|
||||||
|
"importlib.reload", "imp.load_source", "imp.load_compiled", "imp.find_module",
|
||||||
|
"imp.get_suffixes", "imp.init_builtin", "imp.init_frozen", "imp.is_builtin",
|
||||||
|
"imp.is_frozen", "imp.lock_held", "imp.lock", "imp.reload", "imp.load_module",
|
||||||
|
|
||||||
|
# Data serialization and code execution
|
||||||
|
"pickle", "marshal", "shelve", "json", "yaml", "configparser", "binascii", "base64",
|
||||||
|
|
||||||
|
# Databases and storage
|
||||||
|
"sqlite3", "dbapi2", "sqlite_web", "dataset", "records", "tinydb",
|
||||||
|
|
||||||
|
# Cryptography and security
|
||||||
|
"hashlib", "hmac", "secrets", "crypt", "cryptography",
|
||||||
|
|
||||||
|
# External libraries that could be dangerous
|
||||||
|
"requests", "aiohttp", "selenium", "paramiko", "fabric", "docker",
|
||||||
|
"boto", "boto3", "pymongo", "pymysql", "psycopg2", "redis", "pika",
|
||||||
|
"kafka", "celery", "rq", "playwright", "mechanize", "scrapy",
|
||||||
|
"beautifulsoup4", "lxml", "html5lib", "pyautogui",
|
||||||
|
"keyboard", "mouse", "pynput", "psutil", "wmi", "pywin32",
|
||||||
|
|
||||||
|
# GUI and UI libraries that could be used for malicious purposes
|
||||||
|
"tkinter", "PyQt4", "PyQt5", "PyQt6", "PySide", "PySide2", "PySide6",
|
||||||
|
"kivy", "kivymd", "wx", "wxPython", "pygame", "flask", "django",
|
||||||
|
"fastapi", "tornado", "bottle", "cherrypy", "falcon", "sanic",
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forbidden functions that could allow dangerous operations
|
||||||
|
FORBIDDEN_FUNCTIONS = {
|
||||||
|
# Code execution
|
||||||
|
"exec", "eval", "compile", "execfile", "__import__",
|
||||||
|
|
||||||
|
# Import-related functions that allow dynamic imports
|
||||||
|
"importlib.import_module", "importlib.util", "importlib.resources",
|
||||||
|
"importlib.metadata", "builtins.__import__", "builtins.eval",
|
||||||
|
"builtins.exec", "builtins.compile", "builtins.open",
|
||||||
|
|
||||||
|
# File system operations
|
||||||
|
"open", "file", "os.open", "os.fdopen", "io.open", "tempfile.mktemp",
|
||||||
|
"tempfile.mkdtemp", "tempfile.NamedTemporaryFile", "tempfile.SpooledTemporaryFile",
|
||||||
|
|
||||||
|
# System operations
|
||||||
|
"os.system", "os.popen", "os.spawnl", "os.spawnle", "os.spawnlp",
|
||||||
|
"os.spawnlpe", "os.spawnv", "os.spawnve", "os.spawnvp", "os.spawnvpe",
|
||||||
|
"os.startfile", "os.execv", "os.execve", "os.execl", "os.execle", "os.execlp",
|
||||||
|
"os.execlpe", "subprocess.run", "subprocess.call",
|
||||||
|
"subprocess.check_call", "subprocess.check_output", "subprocess.Popen",
|
||||||
|
|
||||||
|
# Network operations
|
||||||
|
"socket.socket", "socket.create_connection", "urllib.request.urlopen",
|
||||||
|
"urllib.request.Request", "requests.get", "requests.post", "requests.put",
|
||||||
|
"requests.delete", "requests.patch", "requests.head", "requests.options",
|
||||||
|
"aiohttp.ClientSession", "http.client.HTTPConnection", "http.client.HTTPSConnection",
|
||||||
|
|
||||||
|
# Reflection and introspection that could be dangerous
|
||||||
|
"getattr", "setattr", "hasattr", "delattr", "globals", "locals", "vars",
|
||||||
|
"dir", "type", "id", "object", "issubclass", "isinstance", "callable",
|
||||||
|
"iter", "next", "reversed", "slice", "sorted", "filter", "map", "reduce",
|
||||||
|
|
||||||
|
# Input functions
|
||||||
|
"input", "raw_input",
|
||||||
|
|
||||||
|
# Built-in functions that could be dangerous in certain contexts
|
||||||
|
"breakpoint", "quit", "exit", "copyright", "credits", "license", "help",
|
||||||
|
|
||||||
|
# Dynamic attribute access that could be dangerous
|
||||||
|
"operator.attrgetter", "operator.itemgetter", "operator.methodcaller",
|
||||||
|
|
||||||
|
"apply", "buffer", "coerce", "intern", "long", "unichr",
|
||||||
|
"unicode", "xrange", "cmp", "reload", "basestring",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forbidden attributes that could be dangerous
|
||||||
|
FORBIDDEN_ATTRIBUTES = {
|
||||||
|
# Special methods and attributes that could be used for code execution
|
||||||
|
"__class__", "__dict__", "__module__", "__subclasses__", "__bases__",
|
||||||
|
"__mro__", "__call__", "__func__", "__self__", "__code__", "__closure__",
|
||||||
|
"__globals__", "__name__", "__file__", "__path__", "__package__",
|
||||||
|
"__loader__", "__spec__", "__builtins__", "__import__", "__new__",
|
||||||
|
"__init__", "__del__", "__repr__", "__str__", "__bytes__", "__format__",
|
||||||
|
"__lt__", "__le__", "__eq__", "__ne__", "__gt__", "__ge__", "__hash__",
|
||||||
|
"__bool__", "__dir__", "__delattr__", "__getattribute__",
|
||||||
|
"__setattr__", "__delete__", "__set__", "__get__", "__set_name__",
|
||||||
|
"__prepare__", "__init_subclass__", "__instancecheck__", "__subclasscheck__",
|
||||||
|
"__subclasshook__", "__class_getitem__", "__annotations__", "__weakref__",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.has_errors = False
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
def check_theme_safety(self, theme_file: str) -> tuple[bool, list[str]]:
|
||||||
|
"""
|
||||||
|
Enhanced security check for theme files.
|
||||||
|
Returns (is_safe, list_of_errors).
|
||||||
|
"""
|
||||||
|
self.has_errors = False
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(theme_file, encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for syntax errors first
|
||||||
|
try:
|
||||||
|
tree = ast.parse(content)
|
||||||
|
except SyntaxError as e:
|
||||||
|
self.errors.append(f"Syntax error in file {theme_file}: {e}")
|
||||||
|
self.has_errors = True
|
||||||
|
return not self.has_errors, self.errors
|
||||||
|
|
||||||
|
# Walk through the AST and check for dangerous patterns
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
self._check_node_safety(node, theme_file)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.errors.append(f"Failed to check theme safety for {theme_file}: {e}")
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
return not self.has_errors, self.errors
|
||||||
|
|
||||||
|
def _check_node_safety(self, node, theme_file: str):
|
||||||
|
"""Check individual AST nodes for security issues."""
|
||||||
|
# Check for forbidden imports
|
||||||
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||||
|
for alias in node.names:
|
||||||
|
module_name = alias.name
|
||||||
|
# Handle from ... import ... cases
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
module_name = node.module
|
||||||
|
|
||||||
|
# Check if the module is in the forbidden list
|
||||||
|
if module_name in self.FORBIDDEN_MODULES:
|
||||||
|
error_msg = f"Forbidden module '{module_name}' found in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
# Also check submodules (e.g., "os.path" should trigger on "os")
|
||||||
|
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||||
|
if module_name.startswith(forbidden_module + "."):
|
||||||
|
error_msg = f"Forbidden submodule '{module_name}' found in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for forbidden function calls
|
||||||
|
elif isinstance(node, ast.Call):
|
||||||
|
# Check for direct function calls (e.g., eval(), exec())
|
||||||
|
if isinstance(node.func, ast.Name):
|
||||||
|
if node.func.id in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden function '{node.func.id}' found in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
# Check for method calls (e.g., os.system(), requests.get())
|
||||||
|
elif isinstance(node.func, ast.Attribute):
|
||||||
|
# Get the full function path (e.g., "os.system")
|
||||||
|
full_func_name = self._get_attribute_path(node.func)
|
||||||
|
if full_func_name in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden function '{full_func_name}' found in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
# Check just the attribute name
|
||||||
|
elif node.func.attr in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden method '{node.func.attr}' called in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
# Check for import expressions that might be used dynamically
|
||||||
|
elif isinstance(node, ast.Expr):
|
||||||
|
# Check if the expression is a call to an import-related function
|
||||||
|
if isinstance(node.value, ast.Call):
|
||||||
|
if isinstance(node.value.func, ast.Name):
|
||||||
|
if node.value.func.id in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden function '{node.value.func.id}' found in expression in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
elif isinstance(node.value.func, ast.Attribute):
|
||||||
|
full_func_name = self._get_attribute_path(node.value.func)
|
||||||
|
if full_func_name in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden function '{full_func_name}' found in expression in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
# Check for forbidden attributes
|
||||||
|
elif isinstance(node, ast.Attribute):
|
||||||
|
if node.attr in self.FORBIDDEN_ATTRIBUTES:
|
||||||
|
error_msg = f"Forbidden attribute access '{node.attr}' found in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
# Check for dangerous expressions (like accessing builtins)
|
||||||
|
elif isinstance(node, ast.Name):
|
||||||
|
if node.id in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden function '{node.id}' found in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
# Check for potentially dangerous f-strings that might execute code
|
||||||
|
elif isinstance(node, ast.FormattedValue):
|
||||||
|
# Check if the format value contains dangerous expressions
|
||||||
|
if hasattr(node, 'value'):
|
||||||
|
# Recursively check the value for dangerous patterns
|
||||||
|
if isinstance(node.value, ast.Call):
|
||||||
|
func_name = self._get_attribute_path(node.value.func)
|
||||||
|
if func_name in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden function '{func_name}' found in f-string in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
elif isinstance(node.value, ast.Attribute) and node.value.attr in self.FORBIDDEN_ATTRIBUTES:
|
||||||
|
error_msg = f"Forbidden attribute access '{node.value.attr}' found in f-string in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
elif isinstance(node.value, ast.Name) and node.value.id in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden function '{node.value.id}' found in f-string in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
# Recursively check nested expressions in f-strings
|
||||||
|
elif isinstance(node.value, (ast.BinOp, ast.UnaryOp, ast.BoolOp)):
|
||||||
|
# Check for complex expressions that might contain dangerous operations
|
||||||
|
self._check_node_safety(node.value, theme_file)
|
||||||
|
# Check for nested function calls that might be dangerous
|
||||||
|
elif isinstance(node.value, ast.Subscript):
|
||||||
|
# Check if we're accessing something potentially dangerous
|
||||||
|
if hasattr(node.value, 'value') and isinstance(node.value.value, ast.Call):
|
||||||
|
func_name = self._get_attribute_path(node.value.value.func)
|
||||||
|
if func_name in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Forbidden function '{func_name}' found in f-string subscript in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
# Check for string concatenation attacks (e.g., "im" + "port", "exec", etc.)
|
||||||
|
elif isinstance(node, ast.BinOp):
|
||||||
|
# Check for string concatenations that might be used to obfuscate dangerous code
|
||||||
|
if isinstance(node.op, ast.Add): # String concatenation with +
|
||||||
|
left_val = self._get_constant_value(node.left)
|
||||||
|
right_val = self._get_constant_value(node.right)
|
||||||
|
|
||||||
|
if left_val is not None and right_val is not None:
|
||||||
|
concatenated = str(left_val) + str(right_val)
|
||||||
|
# Check if concatenated string forms a dangerous module/function name
|
||||||
|
if concatenated in self.FORBIDDEN_MODULES or concatenated in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
error_msg = f"Potential string concatenation attack detected: '{concatenated}' in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
# Also check if it's a substring of forbidden items
|
||||||
|
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||||
|
if concatenated in forbidden_module or forbidden_module in concatenated:
|
||||||
|
error_msg = f"Potential string concatenation attack detected: '{concatenated}' matches forbidden module '{forbidden_module}' in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
if concatenated in forbidden_func or forbidden_func in concatenated:
|
||||||
|
error_msg = f"Potential string concatenation attack detected: '{concatenated}' matches forbidden function '{forbidden_func}' in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
# Check for common obfuscation techniques
|
||||||
|
elif isinstance(node, ast.Call):
|
||||||
|
if isinstance(node.func, ast.Name) and node.func.id in ['eval', 'exec']:
|
||||||
|
# Check if eval/exec is being called with obfuscated content
|
||||||
|
if len(node.args) > 0:
|
||||||
|
first_arg = node.args[0]
|
||||||
|
arg_value = self._get_constant_value(first_arg)
|
||||||
|
if arg_value:
|
||||||
|
# Check if eval/exec argument contains dangerous content
|
||||||
|
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
if forbidden_func in str(arg_value):
|
||||||
|
error_msg = f"Potential obfuscated code execution detected: '{forbidden_func}' found in eval/exec argument in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
|
||||||
|
# Check for character code arrays (another obfuscation method)
|
||||||
|
elif isinstance(node, ast.List) or isinstance(node, ast.Tuple):
|
||||||
|
# Check if it's a list of character codes that might be converted to dangerous strings
|
||||||
|
if all(isinstance(elt, (ast.Num, ast.Constant)) and isinstance(self._get_constant_value(elt), int) for elt in node.elts):
|
||||||
|
# This might be an array of ASCII codes
|
||||||
|
try:
|
||||||
|
char_codes = [self._get_constant_value(elt) for elt in node.elts if self._get_constant_value(elt) is not None]
|
||||||
|
# Filter to only include actual integers for character codes
|
||||||
|
int_char_codes = [code for code in char_codes if isinstance(code, int)]
|
||||||
|
if int_char_codes and all(isinstance(code, int) and 32 <= code <= 126 for code in int_char_codes): # Printable ASCII range
|
||||||
|
decoded_str = ''.join(chr(code) for code in int_char_codes)
|
||||||
|
# Check if decoded string contains dangerous content
|
||||||
|
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||||
|
if forbidden_module in decoded_str:
|
||||||
|
error_msg = f"Potential character code obfuscation detected: '{forbidden_module}' found in decoded array in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||||
|
if forbidden_func in decoded_str:
|
||||||
|
error_msg = f"Potential character code obfuscation detected: '{forbidden_func}' found in decoded array in file {theme_file}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
self.has_errors = True
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
# If conversion fails, continue
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _get_attribute_path(self, attr_node):
|
||||||
|
"""Extract the full attribute path from an AST node (e.g., 'os.path.join')."""
|
||||||
|
if isinstance(attr_node, ast.Name):
|
||||||
|
return attr_node.id
|
||||||
|
elif isinstance(attr_node, ast.Attribute):
|
||||||
|
parent_path = self._get_attribute_path(attr_node.value)
|
||||||
|
return f"{parent_path}.{attr_node.attr}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _get_constant_value(self, node):
|
||||||
|
"""Extract the constant value from an AST node if it's a constant."""
|
||||||
|
if isinstance(node, ast.Str): # Python < 3.8
|
||||||
|
return node.s
|
||||||
|
elif isinstance(node, ast.Constant): # Python 3.8+
|
||||||
|
return node.value
|
||||||
|
elif isinstance(node, ast.Num): # Python < 3.8 for numbers
|
||||||
|
return node.n
|
||||||
|
elif isinstance(node, ast.Bytes): # For bytes
|
||||||
|
return node.s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_theme_safety(theme_file: str) -> bool:
|
||||||
|
"""
|
||||||
|
Convenience function to check theme safety.
|
||||||
|
Returns True if the theme is safe, False otherwise.
|
||||||
|
"""
|
||||||
|
checker = ThemeSecurityChecker()
|
||||||
|
is_safe, errors = checker.check_theme_safety(theme_file)
|
||||||
|
|
||||||
|
for error in errors:
|
||||||
|
logger.error(error)
|
||||||
|
|
||||||
|
return is_safe
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_image_file(file_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an image file is safe to load by verifying its extension and basic file properties.
|
||||||
|
This helps prevent loading malicious files that might be disguised as images.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check file extension first
|
||||||
|
safe_extensions = {'.png', '.jpg', '.jpeg', '.svg', '.bmp', '.gif', '.webp', '.ico'}
|
||||||
|
_, ext = os.path.splitext(file_path.lower())
|
||||||
|
|
||||||
|
if ext not in safe_extensions:
|
||||||
|
logger.warning(f"Unsafe image file extension for {file_path}: {ext}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check file size (prevent loading extremely large files)
|
||||||
|
try:
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
# Limit to 50MB to prevent memory exhaustion attacks
|
||||||
|
if file_size > 50 * 1024 * 1024: # 50MB
|
||||||
|
logger.warning(f"Image file too large ({file_size} bytes): {file_path}")
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
logger.error(f"Could not get file size for {file_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# For security, we can also check the file's magic bytes (first few bytes)
|
||||||
|
# to ensure it's actually an image file and not a disguised executable
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
header = f.read(32) # Read first 32 bytes
|
||||||
|
|
||||||
|
# Check for common image file signatures (magic bytes)
|
||||||
|
if ext == '.png':
|
||||||
|
# PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if not header.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||||
|
logger.warning(f"File {file_path} does not have PNG signature")
|
||||||
|
return False
|
||||||
|
elif ext in ['.jpg', '.jpeg']:
|
||||||
|
# JPEG signature: FF D8 FF
|
||||||
|
if not header.startswith(b'\xff\xd8\xff'):
|
||||||
|
logger.warning(f"File {file_path} does not have JPEG signature")
|
||||||
|
return False
|
||||||
|
elif ext == '.gif':
|
||||||
|
# GIF signature: 47 49 46 38 (GIF8)
|
||||||
|
if not header.startswith(b'GIF8'):
|
||||||
|
logger.warning(f"File {file_path} does not have GIF signature")
|
||||||
|
return False
|
||||||
|
elif ext == '.bmp':
|
||||||
|
# BMP signature: 42 4D (BM)
|
||||||
|
if not header.startswith(b'BM'):
|
||||||
|
logger.warning(f"File {file_path} does not have BMP signature")
|
||||||
|
return False
|
||||||
|
# SVG is text-based, so we just check if it contains XML-like structure
|
||||||
|
elif ext == '.svg':
|
||||||
|
try:
|
||||||
|
header_str = header.decode('utf-8', errors='ignore')
|
||||||
|
# Basic check for SVG XML structure
|
||||||
|
if not ('<svg' in header_str or '<?xml' in header_str):
|
||||||
|
logger.warning(f"File {file_path} does not appear to be a valid SVG")
|
||||||
|
return False
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
logger.warning(f"SVG file {file_path} contains invalid UTF-8")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking image file signature for {file_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
1
portprotonqt/themes/standart/images/icons/settings.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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.0005 1c-0.38761 0-0.77522 0.0327-1.1588 0.0979-0.16351 0.0281-0.30273 0.13627-0.37209 0.28935l-0.39088 0.86264c-0.49378 0.16682-0.96454 0.39759-1.4007 0.68616 2.5e-4 0-0.90672-0.2272-0.90672-0.2272-0.161-0.0403-0.33098 3e-3 -0.45442 0.11569-0.57867 0.5285-1.0672 1.1514-1.4451 1.8432-0.0804 0.14721-0.0841 0.32549-0.01 0.47628l0.41938 0.84865c-0.17954 0.49666-0.29567 1.0147-0.346 1.5417l-0.73995 0.57946c-0.13121 0.10289-0.20407 0.26514-0.19431 0.4335 0.0453 0.78981 0.21961 1.5666 0.51558 2.2983 0.0631 0.15587 0.1978 0.27003 0.36005 0.30467l0.91397 0.19559c0.26993 0.45234 0.59572 0.86802 0.96931 1.2363l-0.0161 0.94973c-3e-3 0.16861 0.0766 0.32755 0.21183 0.42484 0.63551 0.45642 1.3414 0.80207 2.0884 1.0229 0.15926 0.0471 0.33077 0.0109 0.45872-0.0963l0.72016-0.60485c0.51582 0.0674 1.0384 0.0674 1.5544 0l0.72016 0.60485c0.12796 0.10722 0.29946 0.14343 0.45872 0.0963 0.74693-0.22083 1.4528-0.56648 2.0883-1.0229 0.13521-0.0973 0.21465-0.25623 0.21189-0.42484l-0.0161-0.94973c0.37359-0.36829 0.69939-0.78372 0.96932-1.2363l0.91396-0.19559c0.16226-0.0347 0.29695-0.1488 0.36005-0.30467 0.29597-0.73174 0.47026-1.5085 0.51558-2.2983 0.01-0.16836-0.0631-0.33061-0.1943-0.4335l-0.73996-0.57946c-0.0501-0.52671-0.16652-1.045-0.34606-1.5417l0.41944-0.84865c0.0746-0.15079 0.0709-0.32907-0.01-0.47628-0.37785-0.69176-0.86638-1.3147-1.445-1.8432-0.12345-0.11258-0.29343-0.15594-0.45443-0.11569l-0.90697 0.2272c-0.43594-0.28857-0.9067-0.51908-1.4005-0.68616l-0.39088-0.86264c-0.0694-0.15308-0.20858-0.26132-0.37209-0.28935-0.38361-0.0653-0.77121-0.0979-1.1588-0.0979zm0 4.1365a2.8152 2.8635 0 0 1 2.8152 2.8636 2.8152 2.8635 0 0 1-2.8152 2.8635 2.8152 2.8635 0 0 1-2.8152-2.8635 2.8152 2.8635 0 0 1 2.8152-2.8636z" fill="#fff" stroke-width=".25254"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
1
portprotonqt/themes/standart/images/key_+.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m24 9.185c-2.0302 0-3.7027 1.6725-3.7027 3.7027v7.4096h-7.4096c-2.0302 0-3.7027 1.6725-3.7027 3.7027s1.6725 3.7027 3.7027 3.7027h7.4096v7.4096c0 2.0302 1.6725 3.7027 3.7027 3.7027s3.7027-1.6725 3.7027-3.7027v-7.4096h7.4096c2.0302 0 3.7027-1.6725 3.7027-3.7027s-1.6725-3.7027-3.7027-3.7027h-7.4096v-7.4096c0-2.0302-1.6725-3.7027-3.7027-3.7027zm0 2.9613c0.41396 0 0.74137 0.32742 0.74137 0.74137v10.371h10.371c0.41396 0 0.74137 0.32742 0.74137 0.74137s-0.32742 0.74137-0.74137 0.74137h-10.371v10.371c0 0.41396-0.32742 0.74137-0.74137 0.74137s-0.74137-0.32742-0.74137-0.74137v-10.371h-10.371c-0.41396 0-0.74137-0.32742-0.74137-0.74137s0.32742-0.74137 0.74137-0.74137h10.371v-10.371c0-0.41396 0.32742-0.74137 0.74137-0.74137z" fill="#3f424d" stop-color="#000000" stroke-width="1.0662"/><path d="m24 11.494c1.1462 0 2.0844 0.93819 2.0844 2.0844v8.3375h8.3375c1.1462 0 2.0844 0.93819 2.0844 2.0844s-0.93819 2.0844-2.0844 2.0844h-8.3375v8.3375c0 1.1462-0.93819 2.0844-2.0844 2.0844s-2.0844-0.93819-2.0844-2.0844v-8.3375h-8.3375c-1.1462 0-2.0844-0.93819-2.0844-2.0844s0.93819-2.0844 2.0844-2.0844h8.3375v-8.3375c0-1.1462 0.93819-2.0844 2.0844-2.0844z" fill="#fff" stop-color="#000000" stroke-width="0"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
portprotonqt/themes/standart/images/key_f5.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m15.222 18.538h8.5002v1.8684h-5.8744v2.6763h5.3189v1.8684h-5.3189v4.511h-2.6258zm13.6 11.092q-1.1614 0-3.4842-0.18515v-2.0367q2.0872 0.38714 3.1476 0.38714 0.92576 0 1.3466-0.20198 0.4208-0.21882 0.4208-0.67328v-1.6327q0-0.45446-0.30298-0.63962-0.30298-0.20198-0.99309-0.20198h-3.3664v-5.908h6.9011v1.8852h-4.4436v2.2218h1.7169q0.97626 0 1.902 0.3703 0.50496 0.21882 0.80794 0.67328t0.30298 1.0604v2.2892q0 0.65645-0.25248 1.1782-0.25248 0.50496-0.63962 0.77427-0.33664 0.25248-0.90893 0.40397-0.55546 0.15149-1.0772 0.20198-0.60595 0.03366-1.0772 0.03366z" fill="#3f424d" stroke-width="0" aria-label="F5"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
portprotonqt/themes/standart/images/ps_ps.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m20.375 10.836v21.898l4.4513 1.4106v-18.447c0-1.1417 0.63761-1.8498 1.4528-1.5795 1.2195 0.33732 1.2205 1.7172 1.2205 2.2383v7.34c0.69194 0.30705 1.3479 0.46456 1.9511 0.46456 0.90817 0 1.6684-0.36594 2.2003-1.06 0.57517-0.75032 0.87844-1.8745 0.87844-3.2519 0-3.9851-1.3499-5.7267-5.562-7.1711-1.3341-0.45192-4.3372-1.3851-6.5925-1.8413zm-1.6344 13.688-6.8797 2.441c-0.02379 0.0087-1.7358 0.57835-2.724 1.3092-0.35029 0.25948-0.50605 0.57737-0.44766 0.89955 0.11028 0.60112 0.89455 1.164 2.099 1.5035 2.5191 0.83248 5.1622 1.0445 7.7159 0.62504l0.23228-0.03801v-1.9131l-2.0863 0.75596c-0.96438 0.34597-2.458 0.42428-3.2646 0.16049-0.5795-0.19028-0.70734-0.49524-0.70951-0.71795-0.0043-0.26596 0.17441-0.64535 1.022-0.95023l5.0426-1.8033zm13.269 1.1529c-0.59973-0.0075-1.1869 0.016-1.7442 0.07603-2.1515 0.23785-3.7118 0.78326-3.7291 0.78975l-0.09714 0.0338v2.3692l5.0299-1.7695c0.96439-0.34597 2.4538-0.42428 3.2604-0.16048 0.5795 0.19028 0.70734 0.49523 0.70951 0.71795 0.0021 0.26596-0.17234 0.64329-1.0178 0.94601l-7.9819 2.8465v2.2594l10.706-3.8432c0.01297-0.0043 1.421-0.53074 1.968-1.2205 0.19245-0.24218 0.25306-0.4904 0.17737-0.73907-0.09298-0.30488-0.49051-0.89192-2.0863-1.3979-1.4887-0.56436-3.3954-0.88519-5.1946-0.908z" fill="#3f424d" stroke-width="2.1623"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
portprotonqt/themes/standart/images/ps_square.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg>
|
||||||
|
After Width: | Height: | Size: 682 B |
|
After Width: | Height: | Size: 232 KiB |
BIN
portprotonqt/themes/standart/images/screenshots/context_menu.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
portprotonqt/themes/standart/images/screenshots/game_card.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
portprotonqt/themes/standart/images/screenshots/library.jpg
Normal file
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 61 KiB |
BIN
portprotonqt/themes/standart/images/screenshots/themes.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
1
portprotonqt/themes/standart/images/xbox_xbox.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m22.671 37.279c-2.0532-0.1967-4.1317-0.93396-5.9172-2.099-1.4962-0.97578-1.8338-1.3776-1.8338-2.1783 0-1.6086 1.7688-4.4266 4.7953-7.6383 1.7183-1.8246 4.1122-3.962 4.3709-3.9044 0.50369 0.11245 4.5276 4.0376 6.0343 5.8857 2.3821 2.9211 3.4769 5.3138 2.9205 6.3804-0.42284 0.81067-3.0472 2.3955-4.9749 3.0042-1.5891 0.50183-3.6761 0.71433-5.395 0.54984zm-9.772-5.9498c-1.2434-1.9076-1.8716-3.7854-2.1746-6.5015-0.10037-0.89679-0.06505-1.4095 0.22706-3.2507 0.36336-2.2923 1.6697-4.947 3.2402-6.5795 0.66849-0.69389 0.72796-0.71247 1.5433-0.43678 0.98817 0.33455 2.0445 1.0644 3.6832 2.5463l0.95719 0.8655-0.52351 0.64123c-2.4249 2.9769-4.9842 7.1991-5.9476 9.8105-0.52351 1.4188-0.73416 2.8437-0.50802 3.4369 0.15179 0.40084 0.01239 0.25153-0.49873-0.53095zm21.824 0.32433c0.12298-0.59972-0.03253-1.7006-0.39651-2.8115-0.78837-2.4054-3.4242-6.88-5.8445-9.9226l-0.76204-0.95781 0.82461-0.75677c1.0761-0.98817 1.8233-1.5798 2.63-2.0826 0.63596-0.39651 1.5451-0.74748 1.9361-0.74748 0.24069 0 1.0892 0.88285 1.7738 1.8431 1.0607 1.4869 1.8407 3.2929 2.2359 5.1701 0.25556 1.2143 0.27694 3.8102 0.0412 5.0214-0.19516 0.99375-0.60405 2.2818-1.0006 3.1556-0.30048 0.65455-1.0408 1.9262-1.3661 2.34-0.16728 0.21281-0.16728 0.2125-0.07435-0.24658zm-11.832-17.733c-1.117-0.56688-2.84-1.1756-3.7916-1.3398-0.33331-0.05731-0.90236-0.08983-1.2639-0.07125-0.78558 0.03965-0.75058-0.0012 0.50895-0.59631 1.047-0.4947 1.9206-0.78558 3.107-1.0346 1.3336-0.28034 3.8412-0.28344 5.1537-0.0068 1.4172 0.29893 3.0866 0.92002 4.027 1.4993l0.28003 0.17161-0.64123-0.03222c-1.275-0.06443-3.133 0.45072-5.128 1.4209-0.60158 0.29304-1.1245 0.52661-1.1629 0.52042-0.0381-0.0074-0.52847-0.24627-1.0904-0.53126z" fill="#3f424d" stroke-width=".30977"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
1
portprotonqt/themes/standart/images/xbox_y.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.438 26.092-7.6218-12.616h5.7406l4.4433 8.238 4.4109-8.238h5.7731l-7.6866 12.552v8.4974h-5.0595z" fill="#3f424d" stroke-width="1.0811" aria-label="Y"/></svg>
|
||||||
|
After Width: | Height: | Size: 559 B |
@@ -1,5 +1,23 @@
|
|||||||
[Metainfo]
|
[Metainfo]
|
||||||
author = Dervart
|
author = Dervart
|
||||||
author_link =
|
author_link =
|
||||||
description = Стандартная тема PortProtonQt (тёмный вариант)
|
name_en = Clean Dark
|
||||||
name = Clean Dark
|
name_ru = Чистая темная
|
||||||
|
description_en = Standard PortProtonQt theme (dark variant)
|
||||||
|
description_ru = Стандартная тема PortProtonQt (тёмный вариант)
|
||||||
|
|
||||||
|
[Screenshots]
|
||||||
|
auto_installs_en = Auto-installs
|
||||||
|
auto_installs_ru = Автоустановки
|
||||||
|
library_en = Library
|
||||||
|
library_ru = Библиотека
|
||||||
|
game_card_en = Game Card
|
||||||
|
game_card_ru = Карточка
|
||||||
|
context_menu_en = Context Menu
|
||||||
|
context_menu_ru = Контекстное меню
|
||||||
|
portproton_settings_en = PortProton Settings
|
||||||
|
portproton_settings_ru = Настройки PortProton
|
||||||
|
wine_settings_en = Wine Settings
|
||||||
|
wine_settings_ru = Настройки Wine
|
||||||
|
themes_en = Themes
|
||||||
|
themes_ru = Темы
|
||||||
|
|||||||