70 Commits

Author SHA1 Message Date
0a4284191b merge upstream 2025-10-04 16:59:34 +00:00
c8c45dda06 chore(readme): drop Those Awesome Guys
All checks were successful
Code check / Check code (push) Successful in 1m8s
renovate / renovate (push) Successful in 1m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-04 20:54:57 +05:00
3f9f794e6f hint icons
All checks were successful
Code check / Check code (pull_request) Successful in 1m21s
2025-10-04 22:12:10 +07:00
ba9d8b76d8 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:31:01 +05:00
e99c71c1f8 feat: optimize search
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:29:18 +05:00
d05f2fccd6 Preloader for load games
All checks were successful
Code check / Check code (pull_request) Successful in 1m23s
2025-10-01 23:59:12 +03:00
baec62d1cb chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:52 +05:00
cb76961e4f feat: optimize add and remove game
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:37 +05:00
Gitea Actions
081cd07253 chore: update steam apps list 2025-10-01T00:01:41Z 2025-10-01 00:01:42 +00:00
b5efee29ea chore: cleanup MainWindow class
All checks were successful
Code check / Check code (push) Successful in 1m8s
Fetch Data / build (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-30 15:43:11 +05:00
69360f7e7e touchscreen scrolling
All checks were successful
Code check / Check code (pull_request) Successful in 1m49s
Code check / Check code (push) Successful in 1m5s
2025-09-28 16:04:11 +03:00
Renovate Bot
39712f0591 chore(deps): update https://gitea.com/actions/setup-node action to v5
All checks were successful
Code check / Check code (push) Successful in 1m6s
2025-09-28 07:43:25 +00:00
Renovate Bot
60b508af18 chore(deps): update https://gitea.com/actions/checkout action to v5
Some checks failed
Code check / Check code (pull_request) Successful in 1m18s
Code check / Check code (push) Has been cancelled
2025-09-28 07:40:23 +00:00
Renovate Bot
b6637b4163 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to dd5721b
All checks were successful
Code check / Check code (push) Successful in 1m11s
2025-09-28 07:34:37 +00:00
Renovate Bot
6d9eed42f8 chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.13.2
All checks were successful
Code check / Check code (pull_request) Successful in 1m5s
Code check / Check code (push) Successful in 1m3s
2025-09-28 00:01:38 +00:00
7372e3b7f5 chore: added zstd comp to appimage
All checks were successful
Code check / Check code (push) Successful in 1m4s
renovate / renovate (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-27 17:52:32 +05:00
e0d5bd7993 chore: update appimage fork
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-27 07:11:11 +00:00
Renovate Bot
12f8067af1 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 2098143
All checks were successful
Code check / Check code (push) Successful in 1m6s
2025-09-24 17:38:45 +00:00
Renovate Bot
716a813ca9 chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.8.22
Some checks failed
Code check / Check code (push) Has been cancelled
Code check / Check code (pull_request) Successful in 1m11s
2025-09-24 17:37:21 +00:00
c62cc6853f chore(check-translation): disable untill yaspeller fixed
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:31:19 +05:00
2e018b4690 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:27:15 +05:00
ad5b25f713 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:17:29 +05:00
3fb8201305 feat(file explorer): added ThumbnailLoader class
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:14:13 +05:00
04d8302d6c chore(logs): start translate
All checks were successful
Code check / Check code (push) Successful in 2m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 21:05:58 +05:00
Renovate Bot
f868b21178 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 06348c5
All checks were successful
Code check / Check code (push) Successful in 1m8s
2025-09-23 12:10:57 +00:00
Renovate Bot
ebe25b41d8 chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.8.20
All checks were successful
Code check / Check code (pull_request) Successful in 1m8s
Code check / Check code (push) Successful in 1m5s
2025-09-23 12:07:17 +00:00
Renovate Bot
fae6cad52d chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to edaa35b
All checks were successful
Code check / Check code (push) Successful in 1m15s
2025-09-23 07:14:54 +00:00
Renovate Bot
42bce11ada chore(deps): update archlinux:base-devel docker digest to 0589aa8
Some checks failed
Code check / Check code (pull_request) Successful in 1m2s
Code check / Check code (push) Has been cancelled
2025-09-23 07:12:08 +00:00
f088c01768 chore(renovate): validate and fix renovate.json configuration
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 12:10:19 +05:00
e7eee85ed4 feat(dev-scripts): regenerate uv.lock on bump ver
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 11:58:10 +05:00
ecfe252ae3 v0.1.6
Some checks failed
Code check / Check code (push) Successful in 1m16s
Build AppImage, Arch and Fedora Packages / Build AppImage (push) Successful in 2m28s
Build AppImage, Arch and Fedora Packages / Build Arch Package (push) Successful in 1m23s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (41) (push) Successful in 2m4s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (42) (push) Successful in 47s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (43) (push) Successful in 2m56s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (rawhide) (push) Successful in 58s
Build AppImage, Arch and Fedora Packages / Create and Publish Release (push) Failing after 35s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 11:45:36 +05:00
1ad19bff6a chore: hide legendary login
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 10:51:33 +05:00
98f07a9792 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:20:58 +05:00
d5c53ed1aa feat(completion): added --debug-level
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:19:40 +05:00
5a2ab36b60 feat(cli): added --debug-level= argument
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:14:51 +05:00
8e25c04f56 chore(logs): start rework
All checks were successful
Code check / Check code (push) Successful in 1m22s
renovate / renovate (push) Successful in 24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-18 17:29:38 +05:00
f249b01dc6 chore(readme): fix logo path
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-17 12:53:38 +05:00
9f32afe6a3 fix: dialog navigation on gamepad
All checks were successful
Code check / Check code (push) Successful in 2m11s
renovate / renovate (push) Successful in 25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 21:30:57 +05:00
f475e6e0b2 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 2m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 17:03:21 +05:00
43a7c37e91 feat: use mouse extra button to back
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 17:01:43 +05:00
f1cf0ffd68 fix ecodes again meh
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 16:40:10 +05:00
70ed3abcb5 fix add game dialog navigation on keyboard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 16:28:19 +05:00
f061b1597e chore(changelog): update
All checks were successful
Check Translations / check-translations (push) Successful in 43s
Code check / Check code (push) Successful in 1m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 12:17:57 +05:00
0f37a8fc6f fix: disable input manager if window is not focused
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 12:15:12 +05:00
850bc57a16 chore: added prompts license to readme
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:55:52 +05:00
0dcc3ea13f chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:51:38 +05:00
1c82b34e36 feat: added ps controllers hint
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:46:48 +05:00
a8c4ae6f7b chore: clean icons
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 10:24:34 +05:00
dd4f658b66 feat: rework createControlHintsWidget
All checks were successful
Code check / Check code (push) Successful in 1m37s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-12 15:07:25 +05:00
bff6b7fd34 chore(build): update setuptools
All checks were successful
Code check / Check code (push) Successful in 1m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-09 10:59:41 +05:00
1e191bbba3 chore(build): added fedora 43 build
All checks were successful
Code check / Check code (push) Successful in 1m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-09 10:07:52 +05:00
4356e653b8 feat: added control hint
All checks were successful
Code check / Check code (push) Successful in 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 20:48:03 +05:00
4fc95511f1 docs(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 18:47:23 +05:00
4d4e14ea52 fix: Prevent fullscreen toggle on 'Select' button press during game launch
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 18:45:30 +05:00
c39f5ad83b chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:58:15 +05:00
f3325ca35f feat(theme-manager): implement singleton and caching for improved theme handling
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:54:25 +05:00
50645066dd chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:30:16 +05:00
7945dd8980 fix(input_manager): exclude ASRock LED controller from gamepad detection
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:28:34 +05:00
59c38f9c57 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m15s
renovate / renovate (push) Successful in 28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-06 12:44:43 +05:00
a2d5d28884 fix(cache): add cleanup of related cache files on JSON updates
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-06 12:43:22 +05:00
16af4b410a chore(renovate): disable almost python-version update
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-03 19:17:13 +05:00
e8e42b5a86 chore(renovate): disable python-version update
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-03 19:08:58 +05:00
d16e2cdf43 chore(renovate): dont update github-runners
All checks were successful
Code check / Check code (push) Successful in 1m44s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 22:56:03 +05:00
Renovate Bot
b60fd0d593 chore(deps): pin dependencies
All checks were successful
Code check / Check code (pull_request) Successful in 2m16s
Code check / Check code (push) Successful in 1m36s
2025-09-02 17:31:21 +00:00
d93f23fe8c chore(renovate): added GITHUB_TOKEN
All checks were successful
Code check / Check code (push) Successful in 1m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 22:28:10 +05:00
5423ada8f1 fix(theme-security): check standart theme too
All checks were successful
Code check / Check code (push) Successful in 1m12s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 17:05:35 +05:00
2547c7c78d chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 00:11:35 +05:00
2e93073446 feat(theme-security): add theme safety checks and unify loading via ThemeManager
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-01 23:58:38 +05:00
Gitea Actions
9657ff20d3 chore: update steam apps list 2025-09-01T15:10:40Z 2025-09-01 15:10:40 +00:00
849333c283 feat(dev-scripts): add import and function safety checks to theme pre-commit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-01 11:42:06 +05:00
82 changed files with 26586 additions and 3064 deletions

View File

@@ -12,17 +12,27 @@ jobs:
name: Build AppImage name: Build AppImage
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
- name: Install tools - name: Upgrade pip toolchain
run: | run: |
pip3 install git+https://github.com/Boria138/appimage-builder.git python3 -m pip install --upgrade \
pip3 install uv 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: |
@@ -42,7 +52,7 @@ jobs:
strategy: strategy:
matrix: matrix:
fedora_version: [41, 42, rawhide] fedora_version: [41, 42, 43, rawhide]
container: container:
image: fedora:${{ matrix.fedora_version }} image: fedora:${{ matrix.fedora_version }}
@@ -63,7 +73,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@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Copy fedora.spec - name: Copy fedora.spec
run: | run: |
@@ -84,7 +94,7 @@ 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 image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host
@@ -124,7 +134,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@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- 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

View File

@@ -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.5 VERSION: 0.1.6
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -23,12 +23,22 @@ jobs:
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
- name: Install tools - name: Upgrade pip toolchain
run: | run: |
pip3 install git+https://github.com/Boria138/appimage-builder.git python3 -m pip install --upgrade \
pip3 install uv 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: |
@@ -99,7 +109,7 @@ jobs:
strategy: strategy:
matrix: matrix:
fedora_version: [41, 42, rawhide] fedora_version: [41, 42, 43, rawhide]
container: container:
image: fedora:${{ matrix.fedora_version }} image: fedora:${{ matrix.fedora_version }}

View File

@@ -1,4 +1,4 @@
name: Check Translations name: Check Translations (disabled until yaspeller is fixed)
run-name: Check spelling in translation files run-name: Check spelling in translation files
on: on:
push: push:
@@ -12,13 +12,14 @@ 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@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python - name: Set up Python
uses: https://gitea.com/actions/setup-python@v5 uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"

View File

@@ -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@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -63,7 +63,7 @@ jobs:
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'
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install required dependencies - name: Install required dependencies
run: | run: |
@@ -115,7 +115,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@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Copy fedora-git.spec - name: Copy fedora-git.spec
run: | run: |
@@ -138,7 +138,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 image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host
@@ -178,7 +178,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@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- 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

View File

@@ -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@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Node.js - name: Set up Node.js
uses: https://gitea.com/actions/setup-node@v4 uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: 20 node-version: 20

View File

@@ -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@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python - name: Set up Python
uses: https://gitea.com/actions/setup-python@v5 uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"

View File

@@ -8,12 +8,12 @@ on:
jobs: jobs:
renovate: renovate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest container: ghcr.io/renovatebot/renovate:latest@sha256:dd5721b9a686a40d81687643e4b71b82a0ca31fb653fd727538af69104fd388d
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Node.js - name: Set up Node.js
uses: https://gitea.com/actions/setup-node@v4 uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
node-version: 20 node-version: 20
@@ -35,3 +35,4 @@ jobs:
RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js" RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
LOG_LEVEL: "debug" LOG_LEVEL: "debug"
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}

View File

@@ -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.9 rev: 0.8.22
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.12.8 rev: v0.13.2
hooks: hooks:
- id: ruff-check - id: ruff-check

View File

@@ -3,6 +3,45 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- Возможность скроллинга библиотеки мышью или пальцем
### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
### Contributors
---
## [0.1.6] - 2025-09-23
### Added
- Кэширование шрифтов в load_theme_fonts для предотвращения повторной загрузки
- Проверка безопасности в theme_manager.py для всех сторонних тем, с проверкой на запрещённые модули и функции (подробности см. в коде theme_manager под полями FORBIDDEN_MODULES и FORBIDDEN_FUNCTIONS)
- Фильтрация ASRock LED контроллера, чтобы предотвратить его обнаружение как геймпада
- Подсказки по управлению в интерфейсе
- Поддержка боковой кнопки мыши, которая теперь работает как кнопка "назад"
- Аргумент cli --debug-level для указания уровня дебага
### Changed
- Управления с геймпада теперь перехватывается только если окно в фокусе
### Fixed
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
### Contributors
- @wmigor (Igor Akulov)
---
## [0.1.5] - 2025-08-31 ## [0.1.5] - 2025-08-31
### Added ### Added

View File

@@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64"> <img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1> <h1 align="center">PortProtonQt</h1>
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p> <p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
</div> </div>
@@ -54,7 +54,6 @@ PortProtonQt использует код и зависимости от след
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE). - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE). - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md). - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
Полный текст лицензий см. в файле [LICENSE](LICENSE). Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING] > [!WARNING]

View File

@@ -1,16 +1,11 @@
version: 1 version: 1
script: script:
# 1) чистим старый AppDir
- rm -rf AppDir || true - rm -rf AppDir || true
# 2) создаём структуру каталога
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages - mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
# 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
- uv venv - uv venv
- uv pip install --no-cache-dir ../ - uv pip install --no-cache-dir ../
# 4) копируем всё из .venv в AppDir
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr - cp -r share AppDir/usr
# 5) чистим от ненужных модулей и бинарников
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ - 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/{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*} - 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*}
@@ -19,7 +14,6 @@ script:
AppDir: AppDir:
path: ./AppDir path: ./AppDir
after_bundle: after_bundle:
# Документация, справка, примеры
- rm -rf $TARGET_APPDIR/usr/share/man || true - rm -rf $TARGET_APPDIR/usr/share/man || true
- rm -rf $TARGET_APPDIR/usr/share/doc || true - rm -rf $TARGET_APPDIR/usr/share/doc || true
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true - rm -rf $TARGET_APPDIR/usr/share/doc-base || true
@@ -35,17 +29,14 @@ AppDir:
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true - rm -rf $TARGET_APPDIR/usr/share/metainfo || true
- rm -rf $TARGET_APPDIR/usr/include || true - rm -rf $TARGET_APPDIR/usr/include || true
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || 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 \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
# Strip ELF бинарников (исключая Python extensions)
- "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 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 - find $TARGET_APPDIR -type d -empty -delete || true
app_info: app_info:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt icon: ru.linux_gaming.PortProtonQt
version: 0.1.5 version: 0.1.6
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:
@@ -64,15 +55,12 @@ AppDir:
- libimage-exiftool-perl - libimage-exiftool-perl
- xdg-utils - xdg-utils
exclude: exclude:
# Документация и man-страницы
- "*-doc" - "*-doc"
- "*-man" - "*-man"
- manpages - manpages
- mandb - mandb
# Статические библиотеки
- "*-dev" - "*-dev"
- "*-static" - "*-static"
# Дебаг-символы
- "*-dbg" - "*-dbg"
- "*-dbgsym" - "*-dbgsym"
runtime: runtime:
@@ -83,3 +71,4 @@ AppDir:
AppImage: AppImage:
sign-key: None sign-key: None
arch: x86_64 arch: x86_64
comp: zstd

View File

@@ -1,5 +1,5 @@
pkgname=portprotonqt pkgname=portprotonqt
pkgver=0.1.5 pkgver=0.1.6
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')

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.5 %global pypi_version 0.1.6
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1

View File

@@ -1,19 +1,30 @@
_portprotonqt() { _portprotonqt_completions() {
local cur prev local cur prev opts
_init_completion || return COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
case $prev in # Available options
--help|-h) opts="--fullscreen --debug-level --help -h"
return
# Debug level choices
debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
case "${prev}" in
--debug-level)
# Complete debug levels
COMPREPLY=( $(compgen -W "${debug_levels}" -- ${cur}) )
return 0
;;
*)
;; ;;
esac esac
if [[ "$cur" == -* ]]; then # Complete options
COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) ) if [[ ${cur} == -* ]]; then
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0 return 0
fi fi
return 0
} }
complete -F _portprotonqt portprotonqt complete -F _portprotonqt_completions portprotonqt

View File

@@ -217,7 +217,7 @@
}, },
{ {
"normalized_name": "watch_dogs 2", "normalized_name": "watch_dogs 2",
"status": "Broken" "status": "Running"
}, },
{ {
"normalized_name": "zero hour", "normalized_name": "zero hour",
@@ -1777,7 +1777,7 @@
}, },
{ {
"normalized_name": "supervive", "normalized_name": "supervive",
"status": "Denied" "status": "Running"
}, },
{ {
"normalized_name": "splitgate 2", "normalized_name": "splitgate 2",
@@ -4472,7 +4472,7 @@
"status": "Running" "status": "Running"
}, },
{ {
"normalized_name": "f1 25", "normalized_name": "battlefield 6",
"status": "Denied" "status": "Denied"
}, },
{ {
@@ -4482,5 +4482,65 @@
{ {
"normalized_name": "sword of justice", "normalized_name": "sword of justice",
"status": "Broken" "status": "Broken"
},
{
"normalized_name": "blade & soul neo",
"status": "Broken"
},
{
"normalized_name": "the finals (cn)",
"status": "Broken"
},
{
"normalized_name": "tom clancy's rainbow six siege x",
"status": "Denied"
},
{
"normalized_name": "dragonheir silent gods",
"status": "Broken"
},
{
"normalized_name": "the quinfall",
"status": "Running"
},
{
"normalized_name": "redmatch 2",
"status": "Broken"
},
{
"normalized_name": "blade & soul heroes",
"status": "Broken"
},
{
"normalized_name": "blue archive",
"status": "Running"
},
{
"normalized_name": "midnight murder club",
"status": "Broken"
},
{
"normalized_name": "dungeon done",
"status": "Broken"
},
{
"normalized_name": "project wraith",
"status": "Broken"
},
{
"normalized_name": "solo leveling arise",
"status": "Broken"
},
{
"normalized_name": "freedom wars",
"status": "Running"
},
{
"normalized_name": "open fortress",
"status": "Running"
},
{
"normalized_name": "no more room in hell 2",
"status": "Running"
} }
] ]

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,140 @@
[ [
{
"normalized_title": "dirt rally 2.0 game of the year",
"slug": "dirt-rally-2-0-game-of-the-year-edition"
},
{
"normalized_title": "deus ex human revolution directors cut",
"slug": "deus-ex-human-revolution-director-s-cut"
},
{
"normalized_title": "freelancer",
"slug": "freelancer"
},
{
"normalized_title": "everspace",
"slug": "everspace"
},
{
"normalized_title": "blades of time limited",
"slug": "blades-of-time-limited-edition"
},
{
"normalized_title": "chorus",
"slug": "chorus"
},
{
"normalized_title": "tom clancy's splinter cell pandora tomorrow",
"slug": "tom-clancys-splinter-cell-pandora-tomorrow"
},
{
"normalized_title": "the alters",
"slug": "the-alters"
},
{
"normalized_title": "hard reset redux",
"slug": "hard-reset-redux"
},
{
"normalized_title": "far cry 5",
"slug": "far-cry-5"
},
{
"normalized_title": "metal eden",
"slug": "metal-eden"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "old world",
"slug": "old-world"
},
{
"normalized_title": "witchfire",
"slug": "witchfire"
},
{
"normalized_title": "prototype",
"slug": "prototype"
},
{
"normalized_title": "mandragora whispers of the witch tree",
"slug": "mandragora-whispers-of-the-witch-tree"
},
{
"normalized_title": "grand theft auto v (gta 5)",
"slug": "grand-theft-auto-v-gta-5"
},
{
"normalized_title": "lifeless planet premier",
"slug": "lifeless-planet-premier-edition"
},
{
"normalized_title": "warcraft iii the frozen throne",
"slug": "warcraft-iii-the-frozen-throne"
},
{
"normalized_title": "star wars republic commando",
"slug": "star-wars-republic-commando"
},
{
"normalized_title": "hollow knight silksong",
"slug": "hollow-knight-silksong"
},
{
"normalized_title": "arma reforger",
"slug": "arma-reforger"
},
{
"normalized_title": "arma 3",
"slug": "arma-3"
},
{
"normalized_title": "astroneer",
"slug": "astroneer"
},
{
"normalized_title": "anno 2205",
"slug": "anno-2205"
},
{
"normalized_title": "anno 2070",
"slug": "anno-2070"
},
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{
"normalized_title": "ultrakill (early access)",
"slug": "ultrakill-early-access"
},
{
"normalized_title": "vintage story",
"slug": "vintage-story"
},
{
"normalized_title": "disco elysium the finul cut",
"slug": "disco-elysium-the-finul-cut"
},
{
"normalized_title": "warcraft iii reign of chaos",
"slug": "warcraft-iii-reign-of-chaos"
},
{
"normalized_title": "dying light",
"slug": "dying-light"
},
{
"normalized_title": "лихо одноглазое",
"slug": "liho-odnoglazoe"
},
{
"normalized_title": "indika",
"slug": "indika"
},
{ {
"normalized_title": "no sleep for kaname date from ai the somnium files", "normalized_title": "no sleep for kaname date from ai the somnium files",
"slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files" "slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
@@ -151,10 +287,6 @@
"normalized_title": "slitterhead", "normalized_title": "slitterhead",
"slug": "slitterhead" "slug": "slitterhead"
}, },
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{ {
"normalized_title": "crossout", "normalized_title": "crossout",
"slug": "crossout" "slug": "crossout"
@@ -235,10 +367,6 @@
"normalized_title": "cardlife creative survival", "normalized_title": "cardlife creative survival",
"slug": "cardlife-creative-survival" "slug": "cardlife-creative-survival"
}, },
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{ {
"normalized_title": "kompas 3d v24 / компас 3d v24 beta", "normalized_title": "kompas 3d v24 / компас 3d v24 beta",
"slug": "kompas-3d-v24-kompas-3d-v24-beta" "slug": "kompas-3d-v24-kompas-3d-v24-beta"

Binary file not shown.

View File

@@ -17,4 +17,6 @@ Generated-By:
start.sh start.sh
EGS EGS
Stop Game Stop Game
Fullscreen
Fulscreen
\t \t

View File

@@ -2,6 +2,7 @@
import argparse import argparse
import re import re
import subprocess
from pathlib import Path from pathlib import Path
from datetime import date from datetime import date
@@ -134,6 +135,12 @@ def main():
print(f"Updated version from {old} to {new} in {len(updated)} files:") print(f"Updated version from {old} to {new} in {len(updated)} files:")
for p in sorted(updated): for p in sorted(updated):
print(f" - {p}") print(f" - {p}")
try:
subprocess.run(["uv", "lock"], check=True)
print("Regenerated uv.lock")
except subprocess.CalledProcessError as e:
print(f"Failed to regenerate uv.lock: {e}")
else: else:
print(f"No occurrences of version {old} found in specified files.") print(f"No occurrences of version {old} found in specified files.")

View File

@@ -3,8 +3,9 @@
import sys import sys
from pathlib import Path from pathlib import Path
import re import re
import ast
# Запрещенные свойства # Запрещенные QSS-свойства
FORBIDDEN_PROPERTIES = { FORBIDDEN_PROPERTIES = {
"box-shadow", "box-shadow",
"backdrop-filter", "backdrop-filter",
@@ -12,15 +13,55 @@ 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: with open(qss_file, "r") 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}' on file {qss_file}") print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
has_errors = True has_errors = True
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, (ast.Import, ast.ImportFrom)):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
has_errors = True
except SyntaxError as e:
print(f"ERROR: Syntax error in file {qss_file}: {e}")
has_errors = True
return has_errors return has_errors
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -21,9 +21,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 203 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 203 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 203 of 203 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 of 193 |
--- ---

View File

@@ -21,9 +21,9 @@
| Локаль | Прогресс | Переведено | | Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 203 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 203 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 203 из 203 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 из 193 |
--- ---

View File

@@ -2,8 +2,9 @@ from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstra
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
from collections.abc import Callable from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -23,7 +24,8 @@ class SafeOpacityEffect(QGraphicsOpacityEffect):
class GameCardAnimations: class GameCardAnimations:
def __init__(self, game_card, theme=None): def __init__(self, game_card, theme=None):
self.game_card = game_card self.game_card = game_card
self.theme = theme if theme is not None else default_styles self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.thickness_anim: QPropertyAnimation | None = None self.thickness_anim: QPropertyAnimation | None = None
self.gradient_anim: QPropertyAnimation | None = None self.gradient_anim: QPropertyAnimation | None = None
self.scale_anim: QPropertyAnimation | None = None self.scale_anim: QPropertyAnimation | None = None
@@ -207,7 +209,7 @@ class GameCardAnimations:
def paint_border(self, painter: QPainter): def paint_border(self, painter: QPainter):
if not painter.isActive(): if not painter.isActive():
logger.warning("Painter is not active; skipping border paint") logger.debug("Painter is not active; skipping border paint")
return return
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen() pen = QPen()
@@ -232,7 +234,8 @@ class GameCardAnimations:
class DetailPageAnimations: class DetailPageAnimations:
def __init__(self, main_window, theme=None): def __init__(self, main_window, theme=None):
self.main_window = main_window self.main_window = main_window
self.theme = theme if theme is not None else default_styles self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.animations = main_window._animations if hasattr(main_window, '_animations') else {} self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable): def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
@@ -255,7 +258,7 @@ class DetailPageAnimations:
try: try:
detail_page.setGraphicsEffect(original_effect) # type: ignore detail_page.setGraphicsEffect(original_effect) # type: ignore
except RuntimeError: except RuntimeError:
logger.debug("Original effect already deleted") logger.warning("Original effect already deleted")
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)
@@ -314,7 +317,7 @@ class DetailPageAnimations:
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running: if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
animation.stop() animation.stop()
except RuntimeError: except RuntimeError:
logger.debug("Animation already deleted for page") logger.warning("Animation already deleted for page")
except Exception as e: except Exception as e:
logger.error(f"Error stopping existing animation: {e}", exc_info=True) logger.error(f"Error stopping existing animation: {e}", exc_info=True)
finally: finally:

View File

@@ -4,14 +4,12 @@ from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
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
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.cli import parse_args from portprotonqt.cli import parse_args
logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.5" __app_version__ = "0.1.6"
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
@@ -20,15 +18,21 @@ def main():
app.setApplicationName(__app_name__) app.setApplicationName(__app_name__)
app.setApplicationVersion(__app_version__) app.setApplicationVersion(__app_version__)
args = parse_args()
# Setup logger with specified debug level
setup_logger(args.debug_level)
# Reinitialize logger after setup to ensure it uses the new configuration
logger = get_logger(__name__)
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.error(f"Qt translations for {system_locale.name()} not found in {translations_path}") logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
args = parse_args()
window = MainWindow(app_name=__app_name__) window = MainWindow(app_name=__app_name__)

View File

@@ -1,16 +1,20 @@
import argparse import argparse
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def parse_args(): def parse_args():
""" """
Парсит аргументы командной строки. Parses command-line arguments.
""" """
parser = argparse.ArgumentParser(description="PortProtonQt CLI") parser = argparse.ArgumentParser(description="PortProtonQt CLI")
parser.add_argument( parser.add_argument(
"--fullscreen", "--fullscreen",
action="store_true", action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку" help="Launch the application in fullscreen mode and save this setting"
)
parser.add_argument(
"--debug-level",
choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
default='NOTSET',
help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)"
) )
return parser.parse_args() return parser.parse_args()

View File

@@ -7,7 +7,7 @@ logger = get_logger(__name__)
_portproton_location = None _portproton_location = None
# Пути к конфигурационным файлам # Paths to configuration files
CONFIG_FILE = os.path.join( CONFIG_FILE = os.path.join(
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")), os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
"PortProtonQt.conf" "PortProtonQt.conf"
@@ -18,17 +18,32 @@ PORTPROTON_CONFIG_FILE = os.path.join(
"PortProton.conf" "PortProton.conf"
) )
# Пути к папкам с темами # Paths to theme directories
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQt", "themes"), os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
] ]
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails."""
cp = configparser.ConfigParser()
if not os.path.exists(config_file):
logger.debug(f"Configuration file {config_file} not found")
return None
try:
cp.read(config_file, encoding="utf-8")
return cp
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.warning(f"Invalid configuration file format: {e}")
return None
except Exception as e:
logger.warning(f"Failed to read configuration file: {e}")
return None
def read_config(): def read_config():
""" """Reads the configuration file and returns a dictionary of parameters.
Читает конфигурационный файл и возвращает словарь параметров. Example line in config (no sections):
Пример строки в конфиге (без секций):
detail_level = detailed detail_level = detailed
""" """
config_dict = {} config_dict = {}
@@ -44,29 +59,17 @@ def read_config():
return config_dict return config_dict
def read_theme_from_config(): def read_theme_from_config():
"""Reads the theme from the [Appearance] section of the configuration file.
Returns 'standart' if the parameter is not set.
""" """
Читает из конфигурационного файла тему из секции [Appearance]. cp = read_config_safely(CONFIG_FILE)
Если параметр не задан, возвращает "standart". if cp is None:
""" return "standart"
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return "standart"
return cp.get("Appearance", "theme", fallback="standart") return cp.get("Appearance", "theme", fallback="standart")
def save_theme_to_config(theme_name): def save_theme_to_config(theme_name):
""" """Saves the selected theme name to the [Appearance] section of the configuration file."""
Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Appearance" not in cp: if "Appearance" not in cp:
cp["Appearance"] = {} cp["Appearance"] = {}
cp["Appearance"]["theme"] = theme_name cp["Appearance"]["theme"] = theme_name
@@ -74,34 +77,18 @@ def save_theme_to_config(theme_name):
cp.write(configfile) cp.write(configfile)
def read_time_config(): def read_time_config():
"""Reads time settings from the [Time] section of the configuration file.
If the section or parameter is missing, saves and returns 'detailed' as default.
""" """
Читает настройки времени из секции [Time] конфигурационного файла. cp = read_config_safely(CONFIG_FILE)
Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию. if cp is None or not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
""" save_time_config("detailed")
cp = configparser.ConfigParser() return "detailed"
if os.path.exists(CONFIG_FILE): return cp.get("Time", "detail_level", fallback="detailed").lower()
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_time_config("detailed")
return "detailed"
if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
save_time_config("detailed")
return "detailed"
return cp.get("Time", "detail_level", fallback="detailed").lower()
return "detailed"
def save_time_config(detail_level): def save_time_config(detail_level):
""" """Saves the time detail level to the [Time] section of the configuration file."""
Сохраняет настройку уровня детализации времени в секции [Time]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Time" not in cp: if "Time" not in cp:
cp["Time"] = {} cp["Time"] = {}
cp["Time"]["detail_level"] = detail_level cp["Time"]["detail_level"] = detail_level
@@ -109,48 +96,42 @@ def save_time_config(detail_level):
cp.write(configfile) cp.write(configfile)
def read_file_content(file_path): def read_file_content(file_path):
""" """Reads the content of a file and returns it as a string."""
Читает содержимое файла и возвращает его как строку.
"""
with open(file_path, encoding="utf-8") as f: with open(file_path, encoding="utf-8") as f:
return f.read().strip() return f.read().strip()
def get_portproton_location(): def get_portproton_location():
""" """Returns the path to the PortProton directory.
Возвращает путь к директории PortProton. Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
Сначала проверяется кэшированный путь. Если он отсутствует, проверяется If the path is invalid, uses the default directory.
наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
используется директория по умолчанию.
""" """
global _portproton_location global _portproton_location
if _portproton_location is not None: if _portproton_location is not None:
return _portproton_location return _portproton_location
# Попытка чтения пути из конфигурационного файла
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()
if location and os.path.isdir(location): if location and os.path.isdir(location):
_portproton_location = location _portproton_location = location
logger.info(f"Путь PortProton из конфигурации: {location}") logger.info(f"PortProton path from configuration: {location}")
return _portproton_location return _portproton_location
logger.warning(f"Недействительный путь в конфиге PortProton: {location}") logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
except (OSError, PermissionError) as e: except (OSError, PermissionError) as e:
logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}") logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_dir): if os.path.isdir(default_dir):
_portproton_location = default_dir _portproton_location = default_dir
logger.info(f"Используется директория flatpak PortProton: {default_dir}") logger.info(f"Using flatpak PortProton directory: {default_dir}")
return _portproton_location return _portproton_location
logger.warning("Конфигурация и директория flatpak PortProton не найдены") logger.warning("PortProton configuration and flatpak directory not found")
return None return None
def parse_desktop_entry(file_path): def parse_desktop_entry(file_path):
""" """Reads and parses a .desktop file using configparser.
Читает и парсит .desktop файл с помощью configparser. Returns None if the [Desktop Entry] section is missing.
Если секция [Desktop Entry] отсутствует, возвращается None.
""" """
cp = configparser.ConfigParser(interpolation=None) cp = configparser.ConfigParser(interpolation=None)
cp.read(file_path, encoding="utf-8") cp.read(file_path, encoding="utf-8")
@@ -159,9 +140,8 @@ def parse_desktop_entry(file_path):
return cp["Desktop Entry"] return cp["Desktop Entry"]
def load_theme_metainfo(theme_name): def load_theme_metainfo(theme_name):
""" """Loads theme metadata from metainfo.ini in the theme's root directory.
Загружает метаинформацию темы из файла metainfo.ini в корне папки темы. Expected fields: author, author_link, description, name.
Ожидаемые поля: author, author_link, description, name.
""" """
meta = {} meta = {}
for themes_dir in THEMES_DIRS: for themes_dir in THEMES_DIRS:
@@ -179,34 +159,18 @@ def load_theme_metainfo(theme_name):
return meta return meta
def read_card_size(): def read_card_size():
"""Reads the card size (width) from the [Cards] section.
Returns 250 if the parameter is not set.
""" """
Читает размер карточек (ширину) из секции [Cards], cp = read_config_safely(CONFIG_FILE)
Если параметр не задан, возвращает 250. if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
""" save_card_size(250)
cp = configparser.ConfigParser() return 250
if os.path.exists(CONFIG_FILE): return cp.getint("Cards", "card_width", fallback=250)
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_card_size(250)
return 250
if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
save_card_size(250)
return 250
return cp.getint("Cards", "card_width", fallback=250)
return 250
def save_card_size(card_width): def save_card_size(card_width):
""" """Saves the card size (width) to the [Cards] section."""
Сохраняет размер карточек (ширину) в секцию [Cards]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Cards" not in cp: if "Cards" not in cp:
cp["Cards"] = {} cp["Cards"] = {}
cp["Cards"]["card_width"] = str(card_width) cp["Cards"]["card_width"] = str(card_width)
@@ -214,34 +178,18 @@ def save_card_size(card_width):
cp.write(configfile) cp.write(configfile)
def read_sort_method(): def read_sort_method():
"""Reads the sort method from the [Games] section.
Returns 'last_launch' if the parameter is not set.
""" """
Читает метод сортировки из секции [Games]. cp = read_config_safely(CONFIG_FILE)
Если параметр не задан, возвращает last_launch. if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
""" save_sort_method("last_launch")
cp = configparser.ConfigParser() return "last_launch"
if os.path.exists(CONFIG_FILE): return cp.get("Games", "sort_method", fallback="last_launch").lower()
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_sort_method("last_launch")
return "last_launch"
if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
save_sort_method("last_launch")
return "last_launch"
return cp.get("Games", "sort_method", fallback="last_launch").lower()
return "last_launch"
def save_sort_method(sort_method): def save_sort_method(sort_method):
""" """Saves the sort method to the [Games] section."""
Сохраняет метод сортировки в секцию [Games]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Games" not in cp: if "Games" not in cp:
cp["Games"] = {} cp["Games"] = {}
cp["Games"]["sort_method"] = sort_method cp["Games"]["sort_method"] = sort_method
@@ -249,34 +197,18 @@ def save_sort_method(sort_method):
cp.write(configfile) cp.write(configfile)
def read_display_filter(): def read_display_filter():
"""Reads the display_filter parameter from the [Games] section.
Returns 'all' if the parameter is missing.
""" """
Читает параметр display_filter из секции [Games]. cp = read_config_safely(CONFIG_FILE)
Если параметр отсутствует, сохраняет и возвращает значение "all". if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
""" save_display_filter("all")
cp = configparser.ConfigParser() return "all"
if os.path.exists(CONFIG_FILE): return cp.get("Games", "display_filter", fallback="all").lower()
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
save_display_filter("all")
return "all"
if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
save_display_filter("all")
return "all"
return cp.get("Games", "display_filter", fallback="all").lower()
return "all"
def save_display_filter(filter_value): def save_display_filter(filter_value):
""" """Saves the display_filter parameter to the [Games] section."""
Сохраняет параметр display_filter в секцию [Games] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "Games" not in cp: if "Games" not in cp:
cp["Games"] = {} cp["Games"] = {}
cp["Games"]["display_filter"] = filter_value cp["Games"]["display_filter"] = filter_value
@@ -284,37 +216,23 @@ def save_display_filter(filter_value):
cp.write(configfile) cp.write(configfile)
def read_favorites(): def read_favorites():
"""Reads the list of favorite games from the [Favorites] section.
The list is stored as a quoted string with comma-separated names.
Returns an empty list if the section or parameter is missing.
""" """
Читает список избранных игр из секции [Favorites] конфигурационного файла. cp = read_config_safely(CONFIG_FILE)
Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми. if cp is None or not cp.has_section("Favorites") or not cp.has_option("Favorites", "games"):
Если секция или параметр отсутствуют, возвращает пустой список. return []
""" favs = cp.get("Favorites", "games", fallback="").strip()
cp = configparser.ConfigParser() if favs.startswith('"') and favs.endswith('"'):
if os.path.exists(CONFIG_FILE): favs = favs[1:-1]
try: return [s.strip() for s in favs.split(",") if s.strip()]
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
return []
if cp.has_section("Favorites") and cp.has_option("Favorites", "games"):
favs = cp.get("Favorites", "games", fallback="").strip()
# Если строка начинается и заканчивается кавычками, удаляем их
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [s.strip() for s in favs.split(",") if s.strip()]
return []
def save_favorites(favorites): def save_favorites(favorites):
"""Saves the list of favorite games to the [Favorites] section.
The list is stored as a quoted string with comma-separated names.
""" """
Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "Favorites" not in cp: if "Favorites" not in cp:
cp["Favorites"] = {} cp["Favorites"] = {}
fav_str = ", ".join(favorites) fav_str = ", ".join(favorites)
@@ -323,34 +241,18 @@ def save_favorites(favorites):
cp.write(configfile) cp.write(configfile)
def read_rumble_config(): def read_rumble_config():
"""Reads the gamepad rumble setting from the [Gamepad] section.
Returns False if the parameter is missing.
""" """
Читает настройку виброотдачи геймпада из секции [Gamepad]. cp = read_config_safely(CONFIG_FILE)
Если параметр отсутствует, сохраняет и возвращает False по умолчанию. if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
""" save_rumble_config(False)
cp = configparser.ConfigParser() return False
if os.path.exists(CONFIG_FILE): return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_rumble_config(False)
return False
if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
save_rumble_config(False)
return False
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
return False
def save_rumble_config(rumble_enabled): def save_rumble_config(rumble_enabled):
""" """Saves the gamepad rumble setting to the [Gamepad] section."""
Сохраняет настройку виброотдачи геймпада в секцию [Gamepad]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Gamepad" not in cp: if "Gamepad" not in cp:
cp["Gamepad"] = {} cp["Gamepad"] = {}
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled) cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
@@ -358,41 +260,28 @@ def save_rumble_config(rumble_enabled):
cp.write(configfile) cp.write(configfile)
def ensure_default_proxy_config(): def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file.
Creates it with empty values if missing.
""" """
Проверяет наличие секции [Proxy] в конфигурационном файле. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Если секция отсутствует, создаёт её с пустыми значениями. if "Proxy" not in cp:
""" cp.add_section("Proxy")
cp = configparser.ConfigParser() cp["Proxy"]["proxy_url"] = ""
if os.path.exists(CONFIG_FILE): cp["Proxy"]["proxy_user"] = ""
try: cp["Proxy"]["proxy_password"] = ""
cp.read(CONFIG_FILE, encoding="utf-8") with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
except Exception as e: cp.write(configfile)
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return
if not cp.has_section("Proxy"):
cp.add_section("Proxy")
cp["Proxy"]["proxy_url"] = ""
cp["Proxy"]["proxy_user"] = ""
cp["Proxy"]["proxy_password"] = ""
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_proxy_config(): def read_proxy_config():
""" """Reads proxy settings from the [Proxy] section.
Читает настройки прокси из секции [Proxy] конфигурационного файла. Returns an empty dict if proxy_url is not set or empty.
Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
""" """
ensure_default_proxy_config() ensure_default_proxy_config()
cp = configparser.ConfigParser() cp = read_config_safely(CONFIG_FILE)
try: if cp is None:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return {} return {}
proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip() proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
if proxy_url: if proxy_url:
# Если указаны логин и пароль, добавляем их к URL
proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip() proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip() proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password: if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
@@ -402,16 +291,10 @@ def read_proxy_config():
return {} return {}
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""): def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
"""Saves proxy settings to the [Proxy] section.
Creates the section if it does not exist.
""" """
Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Если секция отсутствует, создаёт её.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Proxy" not in cp: if "Proxy" not in cp:
cp["Proxy"] = {} cp["Proxy"] = {}
cp["Proxy"]["proxy_url"] = proxy_url cp["Proxy"]["proxy_url"] = proxy_url
@@ -421,34 +304,18 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
cp.write(configfile) cp.write(configfile)
def read_fullscreen_config(): def read_fullscreen_config():
"""Reads the fullscreen mode setting from the [Display] section.
Returns False if the parameter is missing.
""" """
Читает настройку полноэкранного режима приложения из секции [Display]. cp = read_config_safely(CONFIG_FILE)
Если параметр отсутствует, сохраняет и возвращает False по умолчанию. if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
""" save_fullscreen_config(False)
cp = configparser.ConfigParser() return False
if os.path.exists(CONFIG_FILE): return cp.getboolean("Display", "fullscreen", fallback=False)
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_fullscreen_config(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
save_fullscreen_config(False)
return False
return cp.getboolean("Display", "fullscreen", fallback=False)
return False
def save_fullscreen_config(fullscreen): def save_fullscreen_config(fullscreen):
""" """Saves the fullscreen mode setting to the [Display] section."""
Сохраняет настройку полноэкранного режима приложения в секцию [Display]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp: if "Display" not in cp:
cp["Display"] = {} cp["Display"] = {}
cp["Display"]["fullscreen"] = str(fullscreen) cp["Display"]["fullscreen"] = str(fullscreen)
@@ -456,33 +323,19 @@ def save_fullscreen_config(fullscreen):
cp.write(configfile) cp.write(configfile)
def read_window_geometry() -> tuple[int, int]: def read_window_geometry() -> tuple[int, int]:
"""Reads the window width and height from the [MainWindow] section.
Returns (0, 0) if the parameters are missing.
""" """
Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла. cp = read_config_safely(CONFIG_FILE)
Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0). if cp is None or not cp.has_section("MainWindow"):
""" return (0, 0)
cp = configparser.ConfigParser() width = cp.getint("MainWindow", "width", fallback=0)
if os.path.exists(CONFIG_FILE): height = cp.getint("MainWindow", "height", fallback=0)
try: return (width, height)
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return (0, 0)
if cp.has_section("MainWindow"):
width = cp.getint("MainWindow", "width", fallback=0)
height = cp.getint("MainWindow", "height", fallback=0)
return (width, height)
return (0, 0)
def save_window_geometry(width: int, height: int): def save_window_geometry(width: int, height: int):
""" """Saves the window width and height to the [MainWindow] section."""
Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "MainWindow" not in cp: if "MainWindow" not in cp:
cp["MainWindow"] = {} cp["MainWindow"] = {}
cp["MainWindow"]["width"] = str(width) cp["MainWindow"]["width"] = str(width)
@@ -491,59 +344,40 @@ def save_window_geometry(width: int, height: int):
cp.write(configfile) cp.write(configfile)
def reset_config(): def reset_config():
""" """Resets the configuration file by deleting it.
Сбрасывает конфигурационный файл, удаляя его. Subsequent reads will use default values.
После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
""" """
if os.path.exists(CONFIG_FILE): if os.path.exists(CONFIG_FILE):
try: try:
os.remove(CONFIG_FILE) os.remove(CONFIG_FILE)
logger.info("Конфигурационный файл %s удалён", CONFIG_FILE) logger.info("Configuration file %s deleted", CONFIG_FILE)
except Exception as e: except Exception as e:
logger.error("Ошибка при удалении конфигурационного файла: %s", e) logger.warning(f"Failed to delete configuration file: {e}")
def clear_cache(): def clear_cache():
""" """Clears the PortProtonQt cache by deleting the cache directory."""
Очищает кэш PortProtonQt, удаляя папку кэша.
"""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
if os.path.exists(cache_dir): if os.path.exists(cache_dir):
try: try:
shutil.rmtree(cache_dir) shutil.rmtree(cache_dir)
logger.info("Кэш PortProtonQt удалён: %s", cache_dir) logger.info("PortProtonQt cache deleted: %s", cache_dir)
except Exception as e: except Exception as e:
logger.error("Ошибка при удалении кэша: %s", e) logger.warning(f"Failed to delete cache: {e}")
def read_auto_fullscreen_gamepad(): def read_auto_fullscreen_gamepad():
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
Returns False if the parameter is missing.
""" """
Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [Display]. cp = read_config_safely(CONFIG_FILE)
Если параметр отсутствует, сохраняет и возвращает False по умолчанию. if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
""" save_auto_fullscreen_gamepad(False)
cp = configparser.ConfigParser() return False
if os.path.exists(CONFIG_FILE): return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_auto_fullscreen_gamepad(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
save_auto_fullscreen_gamepad(False)
return False
return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
return False
def save_auto_fullscreen_gamepad(auto_fullscreen): def save_auto_fullscreen_gamepad(auto_fullscreen):
""" """Saves the auto-fullscreen setting for gamepad to the [Display] section."""
Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [Display]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp: if "Display" not in cp:
cp["Display"] = {} cp["Display"] = {}
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen) cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
@@ -551,36 +385,23 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
cp.write(configfile) cp.write(configfile)
def read_favorite_folders(): def read_favorite_folders():
"""Reads the list of favorite folders from the [FavoritesFolders] section.
The list is stored as a quoted string with comma-separated paths.
Returns an empty list if the section or parameter is missing.
""" """
Читает список избранных папок из секции [FavoritesFolders] конфигурационного файла. cp = read_config_safely(CONFIG_FILE)
Список хранится как строка, заключённая в кавычки, с путями, разделёнными запятыми. if cp is None or not cp.has_section("FavoritesFolders") or not cp.has_option("FavoritesFolders", "folders"):
Если секция или параметр отсутствуют, возвращает пустой список. return []
""" favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
cp = configparser.ConfigParser() if favs.startswith('"') and favs.endswith('"'):
if os.path.exists(CONFIG_FILE): favs = favs[1:-1]
try: return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
return []
if cp.has_section("FavoritesFolders") and cp.has_option("FavoritesFolders", "folders"):
favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
return []
def save_favorite_folders(folders): def save_favorite_folders(folders):
"""Saves the list of favorite folders to the [FavoritesFolders] section.
The list is stored as a quoted string with comma-separated paths.
""" """
Сохраняет список избранных папок в секцию [FavoritesFolders] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Список сохраняется как строка, заключённая в двойные кавычки, где пути разделены запятыми.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "FavoritesFolders" not in cp: if "FavoritesFolders" not in cp:
cp["FavoritesFolders"] = {} cp["FavoritesFolders"] = {}
fav_str = ", ".join([os.path.normpath(folder) for folder in folders]) fav_str = ", ".join([os.path.normpath(folder) for folder in folders])

View File

@@ -4,7 +4,6 @@ import glob
import shutil import shutil
import subprocess import subprocess
import threading import threading
import logging
import orjson import orjson
import psutil import psutil
import signal import signal
@@ -17,8 +16,9 @@ from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_s
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
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.logger import get_logger
logger = logging.getLogger(__name__) logger = get_logger(__name__)
class ContextMenuSignals(QObject): class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads.""" """Signals for thread-safe UI updates from worker threads."""
@@ -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, update_game_grid_callback): def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
""" """
Initialize the ContextMenuManager. Initialize the ContextMenuManager.
@@ -45,7 +45,8 @@ class ContextMenuManager:
self.theme = theme self.theme = theme
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.load_games = load_games_callback self.load_games = load_games_callback
self.update_game_grid = update_game_grid_callback self.game_library_manager = game_library_manager
self.update_game_grid = game_library_manager.update_game_grid
self.legendary_path = os.path.join( self.legendary_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache", "legendary" "PortProtonQt", "legendary_cache", "legendary"
@@ -62,7 +63,7 @@ class ContextMenuManager:
self.parent.statusBar().showMessage, self.parent.statusBar().showMessage,
Qt.ConnectionType.QueuedConnection Qt.ConnectionType.QueuedConnection
) )
logger.debug("Connected show_status_message signal to statusBar") logger.debug("Connected show_status_message signal to status bar")
self.signals.show_warning_dialog.connect( self.signals.show_warning_dialog.connect(
self._show_warning_dialog, self._show_warning_dialog,
Qt.ConnectionType.QueuedConnection Qt.ConnectionType.QueuedConnection
@@ -74,28 +75,28 @@ class ContextMenuManager:
def _show_warning_dialog(self, title: str, message: str): def _show_warning_dialog(self, title: str, message: str):
"""Show a warning dialog in the main thread.""" """Show a warning dialog in the main thread."""
logger.debug("Showing warning dialog: %s - %s", title, message) logger.debug("Displaying warning dialog: %s - %s", title, message)
QMessageBox.warning(self.parent, title, message) QMessageBox.warning(self.parent, title, message)
def _show_info_dialog(self, title: str, message: str): def _show_info_dialog(self, title: str, message: str):
"""Show an info dialog in the main thread.""" """Show an info dialog in the main thread."""
logger.debug("Showing info dialog: %s - %s", title, message) logger.debug("Displaying info dialog: %s - %s", title, message)
QMessageBox.information(self.parent, title, message) QMessageBox.information(self.parent, title, message)
def _show_status_message(self, message: str, timeout: int = 3000): def _show_status_message(self, message: str, timeout: int = 3000):
"""Show a status message on the status bar if available.""" """Show a status message on the status bar if available."""
if self.parent.statusBar(): if self.parent.statusBar():
self.parent.statusBar().showMessage(message, timeout) self.parent.statusBar().showMessage(message, timeout)
logger.debug("Direct status message: %s", message) logger.debug("Displayed status message: %s", message)
else: else:
logger.warning("Status bar not available for message: %s", message) logger.warning("Status bar unavailable for message: %s", message)
def _check_portproton(self): def _check_portproton(self):
"""Check if PortProton is available.""" """Check if PortProton is available."""
if self.portproton_location is None: if self.portproton_location is None:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("PortProton is not found") _("PortProton directory not found")
) )
return False return False
return True return True
@@ -119,7 +120,7 @@ class ContextMenuManager:
installed_games = orjson.loads(f.read()) installed_games = orjson.loads(f.read())
return app_name in installed_games return app_name in installed_games
except (OSError, orjson.JSONDecodeError) as e: except (OSError, orjson.JSONDecodeError) as e:
logger.error("Failed to read installed.json: %s", e) logger.error("Error reading installed.json: %s", e)
return False return False
def _is_game_running(self, game_card) -> bool: def _is_game_running(self, game_card) -> bool:
@@ -155,7 +156,7 @@ class ContextMenuManager:
try: try:
item = file_explorer.file_list.itemAt(pos) item = file_explorer.file_list.itemAt(pos)
if not item: if not item:
logger.debug("No item selected at position %s", pos) logger.debug("No folder selected at position %s", pos)
return return
selected = item.text() selected = item.text()
if not selected.endswith("/"): if not selected.endswith("/"):
@@ -202,7 +203,7 @@ class ContextMenuManager:
global_pos = file_explorer.file_list.mapToGlobal(pos) global_pos = file_explorer.file_list.mapToGlobal(pos)
menu.exec(global_pos) menu.exec(global_pos)
except Exception as e: except Exception as e:
logger.error("Error showing folder context menu: %s", e) logger.error("Error displaying folder context menu: %s", e)
def toggle_favorite_folder(self, file_explorer, folder_path, add): def toggle_favorite_folder(self, file_explorer, folder_path, add):
"""Adds or removes a folder from favorites.""" """Adds or removes a folder from favorites."""
@@ -211,12 +212,12 @@ class ContextMenuManager:
if folder_path not in favorite_folders: if folder_path not in favorite_folders:
favorite_folders.append(folder_path) favorite_folders.append(folder_path)
save_favorite_folders(favorite_folders) save_favorite_folders(favorite_folders)
logger.info(f"Folder added to favorites: {folder_path}") logger.info("Added folder to favorites: %s", folder_path)
else: else:
if folder_path in favorite_folders: if folder_path in favorite_folders:
favorite_folders.remove(folder_path) favorite_folders.remove(folder_path)
save_favorite_folders(favorite_folders) save_favorite_folders(favorite_folders)
logger.info(f"Folder removed from favorites: {folder_path}") logger.info("Removed folder from favorites: %s", folder_path)
file_explorer.update_drives_list() file_explorer.update_drives_list()
def _get_safe_icon(self, icon_name: str) -> QIcon: def _get_safe_icon(self, icon_name: str) -> QIcon:
@@ -607,10 +608,10 @@ class ContextMenuManager:
exe_path = get_egs_executable(app_name, self.legendary_config_path) exe_path = get_egs_executable(app_name, self.legendary_config_path)
if exe_path and os.path.exists(exe_path): if exe_path and os.path.exists(exe_path):
if not generate_thumbnail(exe_path, icon_path, size=128): if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail from exe: {exe_path}") logger.error("Failed to generate thumbnail for EGS game: %s", exe_path)
icon_path = "" icon_path = ""
else: else:
logger.error(f"No executable found for EGS game: {app_name}") logger.error("No executable found for EGS game: %s", app_name)
icon_path = "" icon_path = ""
egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops") egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
@@ -750,7 +751,7 @@ Icon={icon_path}
if not exec_line: if not exec_line:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("No executable command in .desktop file for '{game_name}'").format(game_name=game_name) _("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name)
) )
return None return None
else: else:
@@ -762,7 +763,7 @@ Icon={icon_path}
except Exception as e: except Exception as e:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("Failed to read .desktop file: {error}").format(error=str(e)) _("Error reading .desktop file: {error}").format(error=str(e))
) )
return None return None
else: else:
@@ -784,7 +785,7 @@ Icon={icon_path}
try: try:
entry_exec_split = shlex.split(exec_line) entry_exec_split = shlex.split(exec_line)
if not entry_exec_split: if not entry_exec_split:
logger.debug("Invalid executable command for '%s': %s", game_name, exec_line) logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line)
return None return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3: if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2] exe_path = entry_exec_split[2]
@@ -793,11 +794,11 @@ Icon={icon_path}
else: else:
exe_path = entry_exec_split[-1] exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path): if not exe_path or not os.path.exists(exe_path):
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None") logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None")
return None return None
return exe_path return exe_path
except Exception as e: except Exception as e:
logger.debug("Failed to parse executable for '%s': %s", game_name, e) logger.debug("Error parsing executable for game '%s': %s", game_name, e)
return None return None
def _remove_file(self, file_path, error_message, success_message, game_name, location=""): def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
@@ -859,9 +860,16 @@ Icon={icon_path}
_("Failed to delete custom data: {error}").format(error=str(e)) _("Failed to delete custom data: {error}").format(error=str(e))
) )
# Reload games list and update grid self.update_game_grid = self.game_library_manager.remove_game_incremental
self.load_games() self.game_library_manager.remove_game_incremental(game_name, exec_line)
self.update_game_grid()
def add_game_incremental(self, game_data: tuple):
"""Add game after .desktop creation."""
if not self._check_portproton():
return
# Assume game_data is built from new .desktop (name, desc, cover, etc.)
self.game_library_manager.add_game_incremental(game_data)
self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0]))
def add_to_menu(self, game_name, exec_line): def add_to_menu(self, game_name, exec_line):
"""Copy the .desktop file to ~/.local/share/applications.""" """Copy the .desktop file to ~/.local/share/applications."""
@@ -936,7 +944,7 @@ Icon={icon_path}
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png") icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
if not os.path.exists(icon_path): if not os.path.exists(icon_path):
if not generate_thumbnail(exe_path, icon_path, size=128): if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail for {exe_path}") logger.error("Failed to generate thumbnail for game: %s", exe_path)
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip() desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
os.makedirs(desktop_dir, exist_ok=True) os.makedirs(desktop_dir, exist_ok=True)
@@ -1072,7 +1080,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name) exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path: if not exe_path:
return return
logger.debug("Adding '%s' to Steam", game_name) logger.debug("Adding game '%s' to Steam", game_name)
try: try:
success, message = add_to_steam(game_name, exec_line, cover_path) success, message = add_to_steam(game_name, exec_line, cover_path)
self.signals.show_info_dialog.emit( self.signals.show_info_dialog.emit(
@@ -1115,7 +1123,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name) exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path: if not exe_path:
return return
logger.debug("Removing non-EGS game '%s' from Steam", game_name) logger.debug("Removing game '%s' from Steam", game_name)
try: try:
success, message = remove_from_steam(game_name, exec_line) success, message = remove_from_steam(game_name, exec_line)
self.signals.show_info_dialog.emit( self.signals.show_info_dialog.emit(

View File

@@ -5,29 +5,29 @@ 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.
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота). nat_sizes: Array (N, 2) with natural sizes of elements (width, height).
rect_width: доступная ширина контейнера. rect_width: Available container width.
spacing: отступ между элементами (горизонтальный и вертикальный). spacing: Spacing between elements (horizontal and vertical).
max_scale: максимальный коэффициент масштабирования (например, 1.0). max_scale: Maximum scaling factor (e.g., 1.0).
Возвращает: Returns:
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height]. result: Array (N, 4), where each row contains [x, y, new_width, new_height].
total_height: итоговая высота всех рядов. total_height: Total height of all rows.
""" """
N = nat_sizes.shape[0] N = nat_sizes.shape[0]
result = np.zeros((N, 4), dtype=np.int32) result = np.zeros((N, 4), dtype=np.int32)
y = 0 y = 0
i = 0 i = 0
min_margin = 20 # Минимальный отступ по краям min_margin = 20 # Minimum margin on edges
# Определяем максимальное количество элементов в ряду и общий масштаб # Determine the maximum number of items per row and overall scale
max_items_per_row = 0 max_items_per_row = 0
global_scale = 1.0 global_scale = 1.0
max_row_x_start = min_margin # Начальная позиция x самого длинного ряда max_row_x_start = min_margin # Starting x position of the widest row
temp_i = 0 temp_i = 0
# Первый проход: находим максимальное количество элементов в ряду # First pass: Find the maximum number of items in a row
while temp_i < N: while temp_i < N:
sum_width = 0 sum_width = 0
count = 0 count = 0
@@ -42,23 +42,23 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
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
available_width = rect_width - spacing * (count - 1) - 2 * min_margin available_width = rect_width - spacing * (count - 1) - 2 * min_margin
desired_scale = available_width / sum_width if sum_width > 0 else 1.0 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 global_scale = desired_scale if desired_scale < max_scale else max_scale
# Сохраняем начальную позицию x для самого длинного ряда # 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 temp_i = temp_j
# Второй проход: размещаем элементы # Second pass: Place elements
while i < N: while i < N:
sum_width = 0 sum_width = 0
row_max_height = 0 row_max_height = 0
count = 0 count = 0
j = i j = i
# Подбираем количество элементов для текущего ряда # Determine the number of items for the current row
while j < N: while j < N:
w = nat_sizes[j, 0] w = nat_sizes[j, 0]
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin: if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
@@ -70,16 +70,16 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
row_max_height = h row_max_height = h
j += 1 j += 1
# Используем глобальный масштаб для всех рядов # Use global scale for all rows
scale = global_scale scale = global_scale
scaled_row_width = int(sum_width * scale) + spacing * (count - 1) scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
# Определяем начальную координату x # 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):
@@ -99,9 +99,9 @@ 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) # Отступы по краям self.setContentsMargins(20, 20, 20, 20) # Margins around the layout
self._spacing = 20 # Отступ для анимации и предотвращения перекрытий self._spacing = 20 # Spacing for animation and overlap prevention
self._max_scale = 1.0 # Отключено масштабирование в layout self._max_scale = 1.0 # Scaling disabled in layout
def addItem(self, item: QLayoutItem) -> None: def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item) self.itemList.append(item)

View File

@@ -4,15 +4,14 @@ import re
from typing import cast, TYPE_CHECKING from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller
) )
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
from icoextract import IconExtractor, IconExtractorError from icoextract import IconExtractor, IconExtractorError
from PIL import Image from PIL import Image
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
@@ -22,6 +21,7 @@ if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
logger = get_logger(__name__) logger = get_logger(__name__)
theme_manager = ThemeManager()
def generate_thumbnail(inputfile, outfile, size=128, force_resize=True): def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
""" """
@@ -94,8 +94,7 @@ class GameLaunchDialog(QDialog):
"""Modal dialog to indicate game launch progress, similar to Steam's launch dialog.""" """Modal dialog to indicate game launch progress, similar to Steam's launch dialog."""
def __init__(self, parent=None, game_name=None, theme=None, target_exe=None): def __init__(self, parent=None, game_name=None, theme=None, target_exe=None):
super().__init__(parent) super().__init__(parent)
self.theme = theme if theme else default_styles self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.theme_manager = ThemeManager()
self.game_name = game_name self.game_name = game_name
self.target_exe = target_exe # Store the target executable name self.target_exe = target_exe # Store the target executable name
self.setWindowTitle(_("Launching {0}").format(self.game_name)) self.setWindowTitle(_("Launching {0}").format(self.game_name))
@@ -123,7 +122,7 @@ class GameLaunchDialog(QDialog):
layout.addWidget(self.progress_bar) layout.addWidget(self.progress_bar)
# Cancel button # Cancel button
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel")) self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
layout.addWidget(self.cancel_button, alignment=Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.cancel_button, alignment=Qt.AlignmentFlag.AlignCenter)
@@ -173,17 +172,18 @@ class GameLaunchDialog(QDialog):
class FileExplorer(QDialog): class FileExplorer(QDialog):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False): def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent) super().__init__(parent)
self.theme = theme if theme else default_styles self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.theme_manager = ThemeManager()
self.file_signal = FileSelectedSignal() self.file_signal = FileSelectedSignal()
self.file_filter = file_filter # Store the file filter self.file_filter = file_filter # Store the file filter
self.directory_only = directory_only # Store the directory_only flag self.directory_only = directory_only # Store the directory_only flag
self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection
self.path_history = {} # Dictionary to store last selected item per directory self.path_history = {} # Dictionary to store last selected item per directory
self.initial_path = initial_path # Store initial path if provided self.initial_path = initial_path # Store initial path if provided
self.thumbnail_cache = {} # Cache for loaded thumbnails
self.pending_thumbnails = set() # Track files pending thumbnail loading
self.setup_ui() self.setup_ui()
# Настройки окна # Window settings
self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
@@ -210,8 +210,115 @@ class FileExplorer(QDialog):
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
self.update_file_list() self.update_file_list()
class ThumbnailLoader(QRunnable):
"""Class for asynchronous thumbnail loading in a separate thread."""
class Signals(QObject):
thumbnail_ready = Signal(str, QIcon) # Signal for ready thumbnail: file path and icon
def __init__(self, file_path, mime_type, size=64):
super().__init__()
self.file_path = file_path
self.mime_type = mime_type
self.size = size
self.signals = self.Signals()
@Slot()
def run(self):
"""Performs thumbnail loading in a background thread."""
try:
if self.mime_type.startswith("image/"):
pixmap = QPixmap(self.file_path)
if not pixmap.isNull():
scaled_pixmap = pixmap.scaled(self.size, self.size, Qt.AspectRatioMode.KeepAspectRatio)
self.signals.thumbnail_ready.emit(self.file_path, QIcon(scaled_pixmap))
else:
logger.warning("Failed to load image: %s", self.file_path)
elif self.file_path.lower().endswith(".exe"):
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
if generate_thumbnail(self.file_path, tmp.name, size=self.size):
pixmap = QPixmap(tmp.name)
if not pixmap.isNull():
self.signals.thumbnail_ready.emit(self.file_path, QIcon(pixmap))
os.unlink(tmp.name)
else:
logger.warning("Failed to generate thumbnail for .exe: %s", self.file_path)
except Exception as e:
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
def async_load_thumbnails(self, files, mime_db):
"""
Asynchronously loads thumbnails for a list of files.
Args:
files (list): List of file names to process.
mime_db (QMimeDatabase): QMimeDatabase instance for file type detection.
"""
thread_pool = QThreadPool.globalInstance()
thread_pool.setMaxThreadCount(4) # Limit the number of threads
for f in files:
file_path = os.path.join(self.current_path, f)
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
continue # Skip if already cached or pending
mime_type = mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/") or file_path.lower().endswith(".exe"):
self.pending_thumbnails.add(file_path)
loader = self.ThumbnailLoader(file_path, mime_type, size=64)
loader.signals.thumbnail_ready.connect(self.update_thumbnail)
thread_pool.start(loader)
@Slot(str, QIcon)
def update_thumbnail(self, file_path, icon):
"""
Updates the icon for a file list item after thumbnail loading.
Args:
file_path (str): Path to the file for which the thumbnail was loaded.
icon (QIcon): Loaded icon.
"""
try:
# Cache the thumbnail
self.thumbnail_cache[file_path] = icon
self.pending_thumbnails.discard(file_path)
# Update the item in the file list
file_name = os.path.basename(file_path)
for i in range(self.file_list.count()):
item = self.file_list.item(i)
if item.text() == file_name:
item.setIcon(icon)
break
except Exception as e:
logger.error("Error updating thumbnail for %s: %s", file_path, str(e))
def load_visible_thumbnails(self):
"""Load thumbnails only for visible items in the file list."""
try:
visible_range = self.file_list.count()
first_visible = max(0, self.file_list.indexAt(self.file_list.viewport().rect().topLeft()).row())
last_visible = min(visible_range - 1, self.file_list.indexAt(self.file_list.viewport().rect().bottomRight()).row() + 5)
files_to_load = []
for i in range(first_visible, last_visible + 1):
item = self.file_list.item(i)
if not item:
continue
file_name = item.text()
if file_name.endswith("/"):
continue # Skip directories
file_path = os.path.join(self.current_path, file_name)
if file_path not in self.thumbnail_cache and file_path not in self.pending_thumbnails:
files_to_load.append(file_name)
if files_to_load:
self.async_load_thumbnails(files_to_load, self.mime_db)
except Exception as e:
logger.error("Error loading visible thumbnails: %s", str(e))
def get_mounted_drives(self): def get_mounted_drives(self):
"""Получение списка смонтированных дисков из /proc/mounts, исключая системные пути""" """Retrieve a list of mounted drives from /proc/mounts, excluding system paths."""
mounted_drives = [] mounted_drives = []
try: try:
with open('/proc/mounts') as f: with open('/proc/mounts') as f:
@@ -220,20 +327,20 @@ class FileExplorer(QDialog):
if len(parts) < 2: if len(parts) < 2:
continue continue
mount_point = parts[1] mount_point = parts[1]
# Исключаем системные и временные пути, но сохраняем /run/media # Exclude system and temporary paths, but keep /run/media
if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
(mount_point.startswith('/run') and not mount_point.startswith('/run/media'))): (mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
continue continue
# Проверяем, является ли точка монтирования директорией и доступна ли она # Check if the mount point is a directory and accessible
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK): if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
mounted_drives.append(mount_point) mounted_drives.append(mount_point)
return sorted(mounted_drives) return sorted(mounted_drives)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении смонтированных дисков: {e}") logger.error(f"Error retrieving mounted drives: {e}")
return [] return []
def setup_ui(self): def setup_ui(self):
"""Настройка интерфейса""" """Set up the user interface."""
self.setWindowTitle(_("File Explorer")) self.setWindowTitle(_("File Explorer"))
self.setGeometry(100, 100, 600, 600) self.setGeometry(100, 100, 600, 600)
@@ -242,7 +349,7 @@ class FileExplorer(QDialog):
self.main_layout.setSpacing(10) self.main_layout.setSpacing(10)
self.setLayout(self.main_layout) self.setLayout(self.main_layout)
# Панель для смонтированных дисков и избранных папок # Panel for mounted drives and favorite folders
self.drives_layout = QHBoxLayout() self.drives_layout = QHBoxLayout()
self.drives_scroll = QScrollArea() self.drives_scroll = QScrollArea()
self.drives_scroll.setWidgetResizable(True) self.drives_scroll.setWidgetResizable(True)
@@ -255,25 +362,31 @@ class FileExplorer(QDialog):
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Путь # Path label
self.path_label = QLabel() self.path_label = QLabel()
self.path_label.setStyleSheet(self.theme.FILE_EXPLORER_PATH_LABEL_STYLE) self.path_label.setStyleSheet(self.theme.FILE_EXPLORER_PATH_LABEL_STYLE)
self.main_layout.addWidget(self.path_label) self.main_layout.addWidget(self.path_label)
# Список файлов # File list
self.file_list = QListWidget() self.file_list = QListWidget()
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE) self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
self.file_list.itemClicked.connect(self.handle_item_click) self.file_list.itemClicked.connect(self.handle_item_click)
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click) self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu) self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
self.file_list.setHorizontalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.file_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
QScroller.grabGesture(self.file_list.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.main_layout.addWidget(self.file_list) self.main_layout.addWidget(self.file_list)
# Кнопки # Connect scroll signal for lazy loading
self.file_list.verticalScrollBar().valueChanged.connect(self.load_visible_thumbnails)
# Buttons
self.button_layout = QHBoxLayout() self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10) self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Select"), icon=self.theme_manager.get_icon("apply")) self.select_button = AutoSizeButton(_("Select"), icon=theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel")) self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button) self.button_layout.addWidget(self.select_button)
@@ -291,40 +404,40 @@ class FileExplorer(QDialog):
logger.warning("ContextMenuManager not found in parent") logger.warning("ContextMenuManager not found in parent")
def move_selection(self, direction): def move_selection(self, direction):
"""Перемещение выбора по списку""" """Move selection in the list."""
current_row = self.file_list.currentRow() current_row = self.file_list.currentRow()
if direction < 0 and current_row > 0: # Вверх if direction < 0 and current_row > 0: # Up
self.file_list.setCurrentRow(current_row - 1) self.file_list.setCurrentRow(current_row - 1)
elif direction > 0 and current_row < self.file_list.count() - 1: # Вниз elif direction > 0 and current_row < self.file_list.count() - 1: # Down
self.file_list.setCurrentRow(current_row + 1) self.file_list.setCurrentRow(current_row + 1)
self.file_list.scrollToItem(self.file_list.currentItem()) self.file_list.scrollToItem(self.file_list.currentItem())
def handle_item_click(self, item): def handle_item_click(self, item):
"""Обработка одинарного клика мышью""" """Handle single mouse click."""
try: try:
self.file_list.setCurrentItem(item) self.file_list.setCurrentItem(item)
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент self.path_history[self.current_path] = item.text() # Save selected item
logger.debug("Selected item: %s", item.text()) logger.debug("Selected item: %s", item.text())
except Exception as e: except Exception as e:
logger.error("Error in handle_item_click: %s", e) logger.error("Error in handle_item_click: %s", e)
def handle_item_double_click(self, item): def handle_item_double_click(self, item):
"""Обработка двойного клика мышью по элементу списка""" """Handle double mouse click on a list item."""
try: try:
self.file_list.setCurrentItem(item) self.file_list.setCurrentItem(item)
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент self.path_history[self.current_path] = item.text() # Save selected item
selected = item.text() selected = item.text()
full_path = os.path.join(self.current_path, selected) full_path = os.path.join(self.current_path, selected)
if os.path.isdir(full_path): if os.path.isdir(full_path):
if selected == "../": if selected == "../":
# Переходим в родительскую директорию # Navigate to parent directory
self.previous_dir() self.previous_dir()
else: else:
# Открываем директорию # Open directory
self.current_path = os.path.normpath(full_path) self.current_path = os.path.normpath(full_path)
self.update_file_list() self.update_file_list()
elif not self.directory_only: elif not self.directory_only:
# Выбираем файл, если directory_only=False # Select file if directory_only=False
self.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept() self.accept()
else: else:
@@ -333,7 +446,7 @@ class FileExplorer(QDialog):
logger.error("Error in handle_item_double_click: %s", e) logger.error("Error in handle_item_double_click: %s", e)
def select_item(self): def select_item(self):
"""Обработка выбора файла/папки""" """Handle file/folder selection."""
if self.file_list.count() == 0: if self.file_list.count() == 0:
return return
@@ -342,30 +455,30 @@ class FileExplorer(QDialog):
if os.path.isdir(full_path): if os.path.isdir(full_path):
if self.directory_only: if self.directory_only:
# Подтверждаем выбор директории # Confirm directory selection
self.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept() self.accept()
else: else:
# Открываем директорию # Open directory
self.current_path = os.path.normpath(full_path) self.current_path = os.path.normpath(full_path)
self.update_file_list() self.update_file_list()
else: else:
if not self.directory_only: if not self.directory_only:
# Для файла отправляем нормализованный путь # Emit normalized path for file
self.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept() self.accept()
else: else:
logger.debug("Selected item is not a directory, ignoring: %s", full_path) logger.debug("Selected item is not a directory, ignoring: %s", full_path)
def previous_dir(self): def previous_dir(self):
"""Возврат к родительской директории""" """Navigate to parent directory."""
try: try:
if self.current_path == "/": if self.current_path == "/":
return # Уже в корне return # Already at root
# Нормализуем путь (убираем конечный слеш, если есть) # Normalize path (remove trailing slash if present)
normalized_path = os.path.normpath(self.current_path) normalized_path = os.path.normpath(self.current_path)
# Получаем родительскую директорию # Get parent directory
parent_dir = os.path.dirname(normalized_path) parent_dir = os.path.dirname(normalized_path)
if not parent_dir: if not parent_dir:
@@ -391,7 +504,7 @@ class FileExplorer(QDialog):
logger.error(f"Error ensuring button visible: {e}") logger.error(f"Error ensuring button visible: {e}")
def update_drives_list(self): def update_drives_list(self):
"""Обновление списка смонтированных дисков и избранных папок.""" """Update the list of mounted drives and favorite folders."""
for i in reversed(range(self.drives_layout.count())): for i in reversed(range(self.drives_layout.count())):
item = self.drives_layout.itemAt(i) item = self.drives_layout.itemAt(i)
if item and item.widget(): if item and item.widget():
@@ -403,112 +516,112 @@ class FileExplorer(QDialog):
drives = self.get_mounted_drives() drives = self.get_mounted_drives()
favorite_folders = read_favorite_folders() favorite_folders = read_favorite_folders()
# Добавляем смонтированные диски # Add mounted drives
for drive in drives: for drive in drives:
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point")) button = AutoSizeButton(drive_name, icon=theme_manager.get_icon("mount_point"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.clicked.connect(lambda checked, path=drive: self.change_drive(path)) button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
self.drives_layout.addWidget(button) self.drives_layout.addWidget(button)
self.drive_buttons.append(button) self.drive_buttons.append(button)
# Добавляем избранные папки # Add favorite folders
for folder in favorite_folders: for folder in favorite_folders:
folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
button = AutoSizeButton(folder_name, icon=self.theme_manager.get_icon("folder")) button = AutoSizeButton(folder_name, icon=theme_manager.get_icon("folder"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.clicked.connect(lambda checked, path=folder: self.change_drive(path)) button.clicked.connect(lambda checked, path=folder: self.change_drive(path))
self.drives_layout.addWidget(button) self.drives_layout.addWidget(button)
self.drive_buttons.append(button) self.drive_buttons.append(button)
# Добавляем растяжку, чтобы выровнять элементы # Add spacer to align elements
spacer = QWidget() spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.drives_layout.addWidget(spacer) self.drives_layout.addWidget(spacer)
def select_drive(self): def select_drive(self):
"""Обрабатывает выбор диска или избранной папки через геймпад.""" """Handle drive or favorite folder selection via gamepad."""
focused_widget = QApplication.focusWidget() focused_widget = QApplication.focusWidget()
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons: if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
drive_name = focused_widget.text().strip() # Удаляем пробелы drive_name = focused_widget.text().strip() # Remove whitespace
logger.debug(f"Выбрано имя: {drive_name}") logger.debug(f"Selected name: {drive_name}")
# Специальная обработка корневого каталога # Special handling for root directory
if drive_name == "/": if drive_name == "/":
if os.path.isdir("/") and os.access("/", os.R_OK): if os.path.isdir("/") and os.access("/", os.R_OK):
self.current_path = "/" self.current_path = "/"
self.update_file_list() self.update_file_list()
logger.info("Выбран корневой каталог: /") logger.info("Selected root directory")
return return
else: else:
logger.warning("Корневой каталог недоступен: недостаточно прав или ошибка пути") logger.warning("Root directory is inaccessible: insufficient permissions or path error")
return return
# Проверяем избранные папки # Check favorite folders
favorite_folders = read_favorite_folders() favorite_folders = read_favorite_folders()
logger.debug(f"Избранные папки: {favorite_folders}") logger.debug(f"Favorite folders: {favorite_folders}")
for folder in favorite_folders: for folder in favorite_folders:
folder_name = os.path.basename(os.path.normpath(folder)) or folder # Для корневых путей folder_name = os.path.basename(os.path.normpath(folder)) or folder # For root paths
if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK): if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
self.current_path = os.path.normpath(folder) self.current_path = os.path.normpath(folder)
self.update_file_list() self.update_file_list()
logger.info(f"Выбрана избранная папка: {self.current_path}") logger.info(f"Selected favorite folder: {self.current_path}")
return return
# Проверяем смонтированные диски # Check mounted drives
mounted_drives = self.get_mounted_drives() mounted_drives = self.get_mounted_drives()
logger.debug(f"Смонтированные диски: {mounted_drives}") logger.debug(f"Mounted drives: {mounted_drives}")
for drive in mounted_drives: for drive in mounted_drives:
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # Для корневых путей drive_basename = os.path.basename(os.path.normpath(drive)) or drive # For root paths
if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK): if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
self.current_path = os.path.normpath(drive) self.current_path = os.path.normpath(drive)
self.update_file_list() self.update_file_list()
logger.info(f"Выбран смонтированный диск: {self.current_path}") logger.info(f"Selected mounted drive: {self.current_path}")
return return
logger.warning(f"Путь недоступен: {drive_name}.") logger.warning(f"Path is inaccessible: {drive_name}.")
def change_drive(self, drive_path): def change_drive(self, drive_path):
"""Переход к выбранному диску""" """Navigate to the selected drive."""
if os.path.isdir(drive_path) and os.access(drive_path, os.R_OK): if os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
self.current_path = os.path.normpath(drive_path) self.current_path = os.path.normpath(drive_path)
self.update_file_list() self.update_file_list()
else: else:
logger.warning(f"Путь диска недоступен: {drive_path}") logger.warning(f"Drive path is inaccessible: {drive_path}")
def update_file_list(self): def update_file_list(self):
"""Обновление списка файлов с превью в виде иконок""" """Update the file list with asynchronous thumbnail loading."""
self.file_list.clear() self.file_list.clear()
self.thumbnail_cache.clear() # Clear cache when changing directories
self.pending_thumbnails.clear() # Clear pending thumbnails
try: try:
if self.current_path != "/": if self.current_path != "/":
item = QListWidgetItem("../") item = QListWidgetItem("../")
folder_icon = self.theme_manager.get_icon("folder") folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon): if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon) folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon): elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon folder_icon = QIcon()
item.setIcon(folder_icon) item.setIcon(folder_icon)
self.file_list.addItem(item) self.file_list.addItem(item)
items = os.listdir(self.current_path) items = os.listdir(self.current_path)
dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))] dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))]
# Добавляем директории # Add directories
for d in sorted(dirs): for d in sorted(dirs):
item = QListWidgetItem(f"{d}/") item = QListWidgetItem(f"{d}/")
folder_icon = self.theme_manager.get_icon("folder") folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon): if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon) folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon): elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon folder_icon = QIcon()
item.setIcon(folder_icon) item.setIcon(folder_icon)
self.file_list.addItem(item) self.file_list.addItem(item)
# Добавляем файлы только если directory_only=False # Add files only if directory_only=False
if not self.directory_only: if not self.directory_only:
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))] files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
if self.file_filter: if self.file_filter:
@@ -517,26 +630,14 @@ class FileExplorer(QDialog):
elif isinstance(self.file_filter, tuple): elif isinstance(self.file_filter, tuple):
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)] files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
# Add files to the list without immediate thumbnail loading
for f in sorted(files): for f in sorted(files):
item = QListWidgetItem(f) item = QListWidgetItem(f)
file_path = os.path.join(self.current_path, f)
mime_type = self.mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/"):
pixmap = QPixmap(file_path)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)))
elif file_path.lower().endswith(".exe"):
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
if generate_thumbnail(file_path, tmp.name, size=64):
pixmap = QPixmap(tmp.name)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap))
os.unlink(tmp.name)
self.file_list.addItem(item) self.file_list.addItem(item)
# Load thumbnails for visible items only
self.load_visible_thumbnails()
self.path_label.setText(_("Path: ") + self.current_path) self.path_label.setText(_("Path: ") + self.current_path)
# Restore last selected item for this directory # Restore last selected item for this directory
@@ -558,10 +659,10 @@ class FileExplorer(QDialog):
self.file_list.setAlternatingRowColors(True) self.file_list.setAlternatingRowColors(True)
except PermissionError: except PermissionError:
self.path_label.setText(f"Access denied: {self.current_path}") self.path_label.setText(_("Access denied: %s") % self.current_path)
def closeEvent(self, event): def closeEvent(self, event):
"""Закрытие окна""" """Handle window closing."""
try: try:
if self.input_manager: if self.input_manager:
self.input_manager.disable_file_explorer_mode() self.input_manager.disable_file_explorer_mode()
@@ -575,13 +676,13 @@ class FileExplorer(QDialog):
super().closeEvent(event) super().closeEvent(event)
def reject(self): def reject(self):
"""Закрытие диалога""" """Close the dialog."""
if self.input_manager: if self.input_manager:
self.input_manager.disable_file_explorer_mode() self.input_manager.disable_file_explorer_mode()
super().reject() super().reject()
def accept(self): def accept(self):
"""Принятие диалога""" """Accept the dialog."""
if self.input_manager: if self.input_manager:
self.input_manager.disable_file_explorer_mode() self.input_manager.disable_file_explorer_mode()
super().accept() super().accept()
@@ -590,8 +691,7 @@ class AddGameDialog(QDialog):
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None): def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
super().__init__(parent) super().__init__(parent)
from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт
self.theme = theme if theme else default_styles self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.theme_manager = ThemeManager()
self.edit_mode = edit_mode self.edit_mode = edit_mode
self.original_name = game_name self.original_name = game_name
self.last_exe_path = exe_path # Store last selected exe path self.last_exe_path = exe_path # Store last selected exe path
@@ -627,7 +727,7 @@ class AddGameDialog(QDialog):
if exe_path: if exe_path:
self.exeEdit.setText(exe_path) self.exeEdit.setText(exe_path)
exeBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search")) exeBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
exeBrowseButton.clicked.connect(self.browseExe) exeBrowseButton.clicked.connect(self.browseExe)
exeBrowseButton.setObjectName("exeBrowseButton") # Для поиска кнопки exeBrowseButton.setObjectName("exeBrowseButton") # Для поиска кнопки
@@ -649,7 +749,7 @@ class AddGameDialog(QDialog):
if cover_path: if cover_path:
self.coverEdit.setText(cover_path) self.coverEdit.setText(cover_path)
coverBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search")) coverBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
coverBrowseButton.clicked.connect(self.browseCover) coverBrowseButton.clicked.connect(self.browseCover)
coverBrowseButton.setObjectName("coverBrowseButton") # Для поиска кнопки coverBrowseButton.setObjectName("coverBrowseButton") # Для поиска кнопки
@@ -678,8 +778,8 @@ class AddGameDialog(QDialog):
# Dialog buttons # Dialog buttons
self.button_layout = QHBoxLayout() self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10) self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Apply"), icon=self.theme_manager.get_icon("apply")) self.select_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel")) self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button) self.button_layout.addWidget(self.select_button)

View File

@@ -2,12 +2,10 @@ from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import Signal, Property, Qt, QUrl from PySide6.QtCore import Signal, Property, Qt, QUrl
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 collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
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 from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.custom_widgets import ClickableLabel 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
@@ -56,7 +54,7 @@ class GameCard(QFrame):
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu) self.customContextMenuRequested.connect(self._show_context_menu)
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else default_styles self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.display_filter = read_display_filter() self.display_filter = read_display_filter()
self.current_theme_name = read_theme_from_config() self.current_theme_name = read_theme_from_config()

View File

@@ -0,0 +1,453 @@
from typing import Protocol
from portprotonqt.game_card import GameCard
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
from PySide6.QtCore import Qt, QTimer
from portprotonqt.custom_widgets import FlowLayout
from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from collections import deque
class MainWindowProtocol(Protocol):
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
def openGameDetailPage(
self,
name: str,
description: str,
cover_path: str | None = None,
appid: str = "",
exec_line: str = "",
controller_support: str = "",
last_launch: str = "",
formatted_playtime: str = "",
protondb_tier: str = "",
game_source: str = "",
anticheat_status: str = "",
) -> None: ...
def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ...
def on_slider_released(self) -> None: ...
# Required attributes
searchEdit: CustomLineEdit
_last_card_width: int
current_hovered_card: GameCard | None
current_focused_card: GameCard | None
class GameLibraryManager:
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
self.main_window = main_window
self.theme = theme
self.context_menu_manager: ContextMenuManager | None = context_menu_manager
self.games: list[tuple] = []
self.filtered_games: list[tuple] = []
self.game_card_cache = {}
self.pending_images = {}
self.card_width = read_card_size()
self.gamesListWidget: QWidget | None = None
self.gamesListLayout: FlowLayout | None = None
self.sizeSlider: QSlider | None = None
self._update_timer: QTimer | None = None
self._pending_update = False
self.pending_deletions = deque()
self.is_filtering = False
self.dirty = False
def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider."""
self.gamesLibraryWidget = QWidget()
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
layout = QVBoxLayout(self.gamesLibraryWidget)
layout.setSpacing(15)
# Search widget
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
layout.addWidget(searchWidget)
# Scroll area for game grid
scrollArea = QScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.gamesListWidget = QWidget()
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
layout.addWidget(scrollArea)
# Slider for card size
sliderLayout = QHBoxLayout()
sliderLayout.addStretch()
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250)
self.sizeSlider.setValue(self.card_width)
self.sizeSlider.setTickInterval(10)
self.sizeSlider.setFixedWidth(150)
self.sizeSlider.setToolTip(f"{self.card_width} px")
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout)
# Initialize update timer
self._update_timer = QTimer()
self._update_timer.setSingleShot(True)
self._update_timer.setInterval(100) # 100ms debounce
self._update_timer.timeout.connect(self._perform_update)
# Calculate initial card width
def calculate_card_width():
if self.gamesListLayout is None:
return
available_width = scrollArea.width() - 20
spacing = self.gamesListLayout._spacing
target_cards_per_row = 8
calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
calculated_width = max(200, min(calculated_width, 250))
QTimer.singleShot(0, calculate_card_width)
# Connect scroll event for lazy loading
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
return self.gamesLibraryWidget
def on_slider_released(self):
"""Handles slider release to update card size."""
if self.sizeSlider is None:
return
self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width)
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.update_game_grid()
def load_visible_images(self):
"""Loads images for visible game cards."""
if self.gamesListWidget is None:
return
visible_region = self.gamesListWidget.visibleRegion()
max_concurrent_loads = 5
loaded_count = 0
for card_key, card in self.game_card_cache.items():
if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads:
cover_path, width, height, callback = self.pending_images.pop(card_key)
load_pixmap_async(cover_path, width, height, callback)
loaded_count += 1
def _on_card_focused(self, game_name: str, is_focused: bool):
"""Handles card focus events."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_focused:
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card = None
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
self.main_window.current_focused_card = card
else:
if self.main_window.current_focused_card == card:
self.main_window.current_focused_card = None
def _on_card_hovered(self, game_name: str, is_hovered: bool):
"""Handles card hover events."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_hovered:
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card = card
else:
if self.main_window.current_hovered_card == card:
self.main_window.current_hovered_card = None
def _perform_update(self):
"""Performs the actual grid update."""
if not self._pending_update:
return
self._pending_update = False
self._update_game_grid_immediate()
def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False):
"""Schedules a game grid update with debouncing."""
if not is_filter:
if games_list is not None:
self.filtered_games = games_list
self.dirty = True # Full rebuild only for non-filter
self.is_filtering = is_filter
self._pending_update = True
if self._update_timer is not None:
self._update_timer.start()
else:
self._update_game_grid_immediate()
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
if self.gamesListLayout is None or self.gamesListWidget is None:
return
search_text = self.main_window.searchEdit.text().strip().lower()
if self.is_filtering:
# Filter mode: do not change layout, only hide/show cards
self._apply_filter_visibility(search_text)
else:
# Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games if self.filtered_games else self.games
favorites = read_favorites()
sort_method = read_sort_method()
# Batch layout updates (extended scope)
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Optimized sorting: Partition favorites first, then sort subgroups
def partition_sort_key(game):
name = game[0]
is_fav = name in favorites
fav_order = 0 if is_fav else 1
if sort_method == "playtime":
return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0)
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
else:
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
# Quick partition: Sort favorites and non-favorites separately, then merge
fav_games = [g for g in games_list if g[0] in favorites]
non_fav_games = [g for g in games_list if g[0] not in favorites]
sorted_fav = sorted(fav_games, key=partition_sort_key)
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
sorted_games = sorted_fav + sorted_non_fav
# Build set of current game keys for faster lookup
current_game_keys = {(game[0], game[4]) for game in sorted_games}
# Remove cards that no longer exist (batch)
cards_to_remove = []
for card_key in list(self.game_card_cache.keys()):
if card_key not in current_game_keys:
cards_to_remove.append(card_key)
for card_key in cards_to_remove:
card = self.game_card_cache.pop(card_key)
if self.gamesListLayout is not None:
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer
if card_key in self.pending_images:
del self.pending_images[card_key]
# Track current layout order (only if dirty/full update needed)
if self.dirty and self.gamesListLayout is not None:
current_layout_order = []
for i in range(self.gamesListLayout.count()):
item = self.gamesListLayout.itemAt(i)
if item is not None:
widget = item.widget()
if widget:
for key, card in self.game_card_cache.items():
if card == widget:
current_layout_order.append(key)
break
else:
current_layout_order = None # Skip reorg if not dirty
new_card_order = []
cards_to_add = []
for game_data in sorted_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
should_be_visible = not search_text or search_text in game_name.lower()
if game_key in self.game_card_cache:
card = self.game_card_cache[game_key]
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
new_card_order.append(game_key)
else:
if self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(should_be_visible)
new_card_order.append(game_key)
cards_to_add.append((game_key, card))
# Only reorganize if order changed AND dirty
if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order):
# Remove all widgets from layout (batch)
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
# Add widgets in new order (batch)
for game_key in new_card_order:
card = self.game_card_cache[game_key]
self.gamesListLayout.addWidget(card)
self.dirty = False # Reset flag
# Deferred deletions (run in timer to avoid stack overflow)
if self.pending_deletions:
QTimer.singleShot(0, lambda: self._flush_deletions())
# Load visible images for new cards only
if cards_to_add:
self.load_visible_images()
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
self.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str):
"""Applies visibility to cards based on search, without changing the layout."""
visible_count = 0
for game_key, card in self.game_card_cache.items():
game_name = card.name # Assume GameCard has 'name' attribute
should_be_visible = not search_text or search_text in game_name.lower()
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
if should_be_visible:
visible_count += 1
# Load image only for newly visible cards
if game_key in self.pending_images:
cover_path, width, height, callback = self.pending_images.pop(game_key)
load_pixmap_async(cover_path, width, height, callback)
# Force geometry update so FlowLayout accounts for hidden widgets
if self.gamesListLayout is not None:
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
# If search is empty, load images for visible ones
if not search_text:
self.load_visible_images()
def _create_game_card(self, game_data: tuple) -> GameCard:
"""Creates a new game card with all necessary connections."""
card = GameCard(
*game_data,
select_callback=self.main_window.openGameDetailPage,
theme=self.theme,
card_width=self.card_width,
context_menu_manager=self.context_menu_manager
)
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
if self.context_menu_manager:
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu)
card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop)
card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop)
card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
return card
def _flush_deletions(self):
"""Delete pending widgets off the main update cycle."""
for card in list(self.pending_deletions):
card.deleteLater()
self.pending_deletions.remove(card)
def clear_layout(self, layout):
"""Clears all widgets from the layout."""
if layout is None:
return
while layout.count():
child = layout.takeAt(0)
if child.widget():
widget = child.widget()
for key, card in list(self.game_card_cache.items()):
if card == widget:
del self.game_card_cache[key]
if key in self.pending_images:
del self.pending_images[key]
widget.deleteLater()
def set_games(self, games: list[tuple]):
"""Sets the games list and updates the filtered games."""
self.games = games
self.filtered_games = self.games
self.dirty = True # Full resort needed
self.update_game_grid()
def add_game_incremental(self, game_data: tuple):
"""Add a single game without full reload."""
self.games.append(game_data)
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
self.dirty = True
self.update_game_grid()
def remove_game_incremental(self, game_name: str, exec_line: str):
"""Remove a single game without full reload."""
key = (game_name, exec_line)
self.games = [g for g in self.games if (g[0], g[4]) != key]
self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key]
if key in self.game_card_cache and self.gamesListLayout is not None:
card = self.game_card_cache.pop(key)
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer deleteLater
if key in self.pending_images:
del self.pending_images[key]
self.dirty = True
self.update_game_grid()
def filter_games_delayed(self):
"""Filters games based on search text and updates the grid."""
self.update_game_grid(is_filter=True)

View File

@@ -3,7 +3,6 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.config_utils import read_theme_from_config from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
@@ -177,7 +176,8 @@ class FullscreenDialog(QDialog):
self.images = images self.images = images
self.current_index = current_index self.current_index = current_index
self.theme = theme if theme else default_styles self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog) self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
@@ -378,7 +378,8 @@ class ImageCarousel(QGraphicsView):
self.images = images # Список кортежей: (QPixmap, caption) self.images = images # Список кортежей: (QPixmap, caption)
self.image_items = [] self.image_items = []
self._animation = None self._animation = None
self.theme = theme if theme else default_styles self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.max_height = 300 # Default height for images self.max_height = 300 # Default height for images
self.init_ui() self.init_ui()
self.create_arrows() self.create_arrows()

View File

@@ -3,10 +3,11 @@ import threading
import os import os
from typing import Protocol, cast from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
from enum import Enum
from pyudev import Context, Monitor, MonitorObserver, Device from pyudev import Context, Monitor, MonitorObserver, Device
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
from PySide6.QtGui import QKeyEvent from PySide6.QtGui import QKeyEvent, QMouseEvent
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.image_utils import FullscreenDialog from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
@@ -31,6 +32,8 @@ class MainWindowProtocol(Protocol):
... ...
def on_slider_released(self) -> None: def on_slider_released(self) -> None:
... ...
def isActiveWindow(self) -> bool:
...
stackedWidget: QStackedWidget stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget] tabButtons: dict[int, QWidget]
gamesListWidget: QWidget gamesListWidget: QWidget
@@ -38,23 +41,29 @@ class MainWindowProtocol(Protocol):
current_exec_line: str | None current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None current_add_game_dialog: AddGameDialog | None
# Mapping of actions to evdev button codes, includes Xbox and PlayStation controllers # Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo Switch controllers
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c # https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c # https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo
BUTTONS = { BUTTONS = {
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS) 'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS) / B (Switch)
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS) 'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS) / A (Switch)
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS) 'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS) / Y (Switch)
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS) 'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS) / X (Switch)
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS) 'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS) / L (Switch)
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS) 'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS) / R (Switch)
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS) 'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS) / + (Switch)
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS) 'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS) / - (Switch)
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button 'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button / Home (Switch)
'increase_size': {ecodes.BTN_TR2}, # RT (Xbox) / R2 (PS) 'increase_size': {ecodes.ABS_RZ}, # RT (Xbox) / R2 (PS) / ZR (Switch)
'decrease_size': {ecodes.BTN_TL2}, # LT (Xbox) / L2 (PS) 'decrease_size': {ecodes.ABS_Z}, # LT (Xbox) / L2 (PS) / ZL (Switch)
} }
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
class InputManager(QObject): class InputManager(QObject):
""" """
Manages input from gamepads and keyboards for navigating the application interface. Manages input from gamepads and keyboards for navigating the application interface.
@@ -76,6 +85,7 @@ class InputManager(QObject):
super().__init__(cast(QObject, main_window)) super().__init__(cast(QObject, main_window))
self._parent = main_window self._parent = main_window
self._gamepad_handling_enabled = True self._gamepad_handling_enabled = True
self.gamepad_type = GamepadType.UNKNOWN
# Ensure attributes exist on main_window # Ensure attributes exist on main_window
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None) self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None) self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
@@ -132,6 +142,38 @@ class InputManager(QObject):
# Initialize evdev + hotplug # Initialize evdev + hotplug
self.init_gamepad() self.init_gamepad()
def detect_gamepad_type(self, device: InputDevice) -> GamepadType:
"""
Определяет тип геймпада по capabilities
"""
caps = device.capabilities()
keys = set(caps.get(ecodes.EV_KEY, []))
# Для EV_ABS вытаскиваем только коды (первый элемент кортежа)
abs_axes = {a if isinstance(a, int) else a[0] for a in caps.get(ecodes.EV_ABS, [])}
# Xbox layout
if {ecodes.BTN_SOUTH, ecodes.BTN_EAST, ecodes.BTN_NORTH, ecodes.BTN_WEST}.issubset(keys):
if {ecodes.ABS_X, ecodes.ABS_Y, ecodes.ABS_RX, ecodes.ABS_RY}.issubset(abs_axes):
self.gamepad_type = GamepadType.XBOX
return GamepadType.XBOX
# PlayStation layout
if ecodes.BTN_TOUCH in keys or (ecodes.BTN_DPAD_UP in keys and ecodes.BTN_EAST in keys):
self.gamepad_type = GamepadType.PLAYSTATION
logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
return GamepadType.PLAYSTATION
# Steam Controller / Deck (трекпады)
if any(a for a in abs_axes if a >= ecodes.ABS_MT_SLOT):
self.gamepad_type = GamepadType.XBOX
logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
return GamepadType.XBOX
# Fallback
self.gamepad_type = GamepadType.XBOX
return GamepadType.XBOX
def enable_file_explorer_mode(self, file_explorer): def enable_file_explorer_mode(self, file_explorer):
"""Настройка обработки геймпада для FileExplorer""" """Настройка обработки геймпада для FileExplorer"""
try: try:
@@ -404,17 +446,14 @@ class InputManager(QObject):
if not self._gamepad_handling_enabled: if not self._gamepad_handling_enabled:
return return
try: try:
# Ignore gamepad events if a game is launched
if getattr(self._parent, '_gameLaunched', False):
return
app = QApplication.instance() app = QApplication.instance()
if not app:
return
active = QApplication.activeWindow() active = QApplication.activeWindow()
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget() popup = QApplication.activePopupWidget()
modal_dialog = QApplication.activeModalWidget() modal_dialog = QApplication.activeModalWidget()
if not app or not active:
return
# Handle Guide button to open system overlay # Handle Guide button to open system overlay
if button_code in BUTTONS['guide']: if button_code in BUTTONS['guide']:
@@ -559,16 +598,13 @@ class InputManager(QObject):
if not self._gamepad_handling_enabled: if not self._gamepad_handling_enabled:
return return
try: try:
# Ignore gamepad events if a game is launched
if getattr(self._parent, '_gameLaunched', False):
return
app = QApplication.instance() app = QApplication.instance()
if not app:
return
active = QApplication.activeWindow() active = QApplication.activeWindow()
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget() popup = QApplication.activePopupWidget()
if not app or not active:
return
# Update D-pad state # Update D-pad state
if value != 0: if value != 0:
@@ -805,6 +841,20 @@ class InputManager(QObject):
if not app: if not app:
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
if event.type() == QEvent.Type.MouseButtonPress:
mouse_event = cast(QMouseEvent, event)
if mouse_event.button() == Qt.MouseButton.ExtraButton1:
# Handle ExtraButton1 as "back" action, similar to Escape
active_win = QApplication.activeWindow()
focused = QApplication.focusWidget()
if isinstance(focused, QLineEdit):
return False # Skip if in QLineEdit
if isinstance(active_win, QDialog):
active_win.reject()
return True
self._parent.goBackDetailPage(self._parent.currentDetailPage)
return True
# Ensure obj is a QObject # Ensure obj is a QObject
if not isinstance(obj, QObject): if not isinstance(obj, QObject):
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}") logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
@@ -1043,6 +1093,8 @@ class InputManager(QObject):
new_gamepad = self.find_gamepad() new_gamepad = self.find_gamepad()
if new_gamepad and new_gamepad != self.gamepad: if new_gamepad and new_gamepad != self.gamepad:
logger.info(f"Gamepad connected: {new_gamepad.name}") logger.info(f"Gamepad connected: {new_gamepad.name}")
self.detect_gamepad_type(new_gamepad)
logger.info(f"Detected gamepad type: {self.gamepad_type.value}")
self.stop_rumble() self.stop_rumble()
self.gamepad = new_gamepad self.gamepad = new_gamepad
if self.gamepad_thread: if self.gamepad_thread:
@@ -1061,6 +1113,10 @@ class InputManager(QObject):
try: try:
devices = [InputDevice(path) for path in list_devices()] devices = [InputDevice(path) for path in list_devices()]
for device in devices: for device in devices:
# Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2)
if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
logger.debug(f"Skipping ASRock LED controller: {device.name}")
continue
caps = device.capabilities() caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps: if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
return device return device
@@ -1079,6 +1135,13 @@ class InputManager(QObject):
if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS): if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS):
continue continue
now = time.time() now = time.time()
# Проверка фокуса: игнорируем события, если окно не в фокусе
app = QApplication.instance()
active = QApplication.activeWindow()
if not app or not active:
continue
if event.type == ecodes.EV_KEY and event.value == 1: if event.type == ecodes.EV_KEY and event.value == 1:
if event.code in BUTTONS['menu'] and not self._is_gamescope_session: if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen) self.toggle_fullscreen.emit(not self._is_fullscreen)
@@ -1131,5 +1194,7 @@ class InputManager(QObject):
self.gamepad_thread.join() self.gamepad_thread.join()
if self.gamepad: if self.gamepad:
self.gamepad.close() self.gamepad.close()
self.gamepad = None
self.gamepad_type = GamepadType.UNKNOWN
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True) logger.error(f"Error during cleanup: {e}", exc_info=True)

View File

@@ -9,7 +9,7 @@ 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-08-31 12:28+0500\n" "POT-Creation-Date: 2025-09-23 22:23+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"
@@ -23,7 +23,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton is not found" msgid "PortProton directory not found"
msgstr "" msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'" msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to read .desktop file: {error}" msgid "Error reading .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -264,6 +264,10 @@ msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game" msgid "Edit Game"
msgstr "" msgstr ""
@@ -360,6 +364,12 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -450,21 +460,6 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -474,28 +469,6 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -549,9 +522,6 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ 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-08-31 12:28+0500\n" "POT-Creation-Date: 2025-09-23 22:23+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"
@@ -23,7 +23,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton is not found" msgid "PortProton directory not found"
msgstr "" msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'" msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to read .desktop file: {error}" msgid "Error reading .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -264,6 +264,10 @@ msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game" msgid "Edit Game"
msgstr "" msgstr ""
@@ -360,6 +364,12 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -450,21 +460,6 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -474,28 +469,6 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -549,9 +522,6 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ 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-08-31 12:28+0500\n" "POT-Creation-Date: 2025-09-23 22:23+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"
@@ -21,7 +21,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton is not found" msgid "PortProton directory not found"
msgstr "" msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
@@ -153,7 +153,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'" msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -161,7 +161,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to read .desktop file: {error}" msgid "Error reading .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -262,6 +262,10 @@ msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game" msgid "Edit Game"
msgstr "" msgstr ""
@@ -358,6 +362,12 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -448,21 +458,6 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -472,28 +467,6 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -547,9 +520,6 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""

View File

@@ -9,23 +9,24 @@ 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-08-31 12:28+0500\n" "POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: 2025-08-31 12:28+0500\n" "PO-Revision-Date: 2025-09-23 22:23+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "Language: ru_RU\n"
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 "
"&& (n%100<10 || n%100>=20) ? 1 : 2);\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
"X-Generator: Poedit 3.6\n"
msgid "Error" msgid "Error"
msgstr "Ошибка" msgstr "Ошибка"
msgid "PortProton is not found" msgid "PortProton directory not found"
msgstr "PortProton не найден" msgstr "Не найден каталог PortProton"
msgid "Remove from Favorites" msgid "Remove from Favorites"
msgstr "Удалить из Избранного" msgstr "Удалить из Избранного"
@@ -86,11 +87,11 @@ msgstr "Успешно"
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"'{game_name}' was added to Steam. Please restart Steam for changes to " "'{game_name}' was added to Steam. Please restart Steam for changes to take "
"take effect." "effect."
msgstr "" msgstr ""
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите " "'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите Steam, "
"Steam, чтобы изменения вступили в силу." "чтобы изменения вступили в силу."
#, python-brace-format #, python-brace-format
msgid "Executable not found for game: {game_name}" msgid "Executable not found for game: {game_name}"
@@ -158,16 +159,16 @@ msgid "Menu"
msgstr "Меню" msgstr "Меню"
#, python-brace-format #, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'" msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда" msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'"
#, python-brace-format #, python-brace-format
msgid "Failed to parse .desktop file for '{game_name}'" msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "Не удалось разобрать файл .desktop для '{game_name}'" msgstr "Не удалось разобрать файл .desktop для '{game_name}'"
#, python-brace-format #, python-brace-format
msgid "Failed to read .desktop file: {error}" msgid "Error reading .desktop file: {error}"
msgstr "Не удалось прочитать файл .desktop: {error}" msgstr "Ошибка при чтении файла .desktop: {error}"
#, python-brace-format #, python-brace-format
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
@@ -178,11 +179,11 @@ msgstr "Подтвердите удаление"
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"Are you sure you want to delete '{game_name}'? This will remove the " "Are you sure you want to delete '{game_name}'? This will remove the .desktop "
".desktop file and custom data." "file and custom data."
msgstr "" msgstr ""
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению " "Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению файла ."
"файла .desktop и пользовательских данных." "desktop и пользовательских данных."
#, python-brace-format #, python-brace-format
msgid "Failed to delete .desktop file: {error}" msgid "Failed to delete .desktop file: {error}"
@@ -224,11 +225,11 @@ msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"'{game_name}' was removed from Steam. Please restart Steam for changes to" "'{game_name}' was removed from Steam. Please restart Steam for changes to take "
" take effect." "effect."
msgstr "" msgstr ""
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam," "'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam, чтобы "
" чтобы изменения вступили в силу." "изменения вступили в силу."
#, python-brace-format #, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}" msgid "Failed to remove game '{game_name}' from Steam: {error}"
@@ -271,6 +272,10 @@ msgstr "Выбрать"
msgid "Path: " msgid "Path: "
msgstr "Путь: " msgstr "Путь: "
#, python-format
msgid "Access denied: %s"
msgstr "Доступ запрещен: %s"
msgid "Edit Game" msgid "Edit Game"
msgstr "Редактировать игру" msgstr "Редактировать игру"
@@ -367,6 +372,12 @@ msgstr "Настройки PortProton"
msgid "Themes" msgid "Themes"
msgstr "Темы" msgstr "Темы"
msgid "Back"
msgstr "Назад"
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..." msgstr "Загрузка игр из Steam..."
@@ -457,21 +468,6 @@ msgstr "Тактильная отдача на геймпаде"
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "Тактильная отдача на геймпаде:" msgstr "Тактильная отдача на геймпаде:"
msgid "Open Legendary Login"
msgstr "Открыть браузер для входа в Legendary"
msgid "Legendary Authentication:"
msgstr "Авторизация в Legendary:"
msgid "Enter Legendary Authorization Code"
msgstr "Введите код авторизации Legendary"
msgid "Authorization Code:"
msgstr "Код авторизации:"
msgid "Submit Code"
msgstr "Отправить код"
msgid "Save Settings" msgid "Save Settings"
msgstr "Сохранить настройки" msgstr "Сохранить настройки"
@@ -481,35 +477,12 @@ msgstr "Сбросить настройки"
msgid "Clear Cache" msgid "Clear Cache"
msgstr "Очистить кэш" msgstr "Очистить кэш"
msgid "Opened Legendary login page in browser"
msgstr "Открытие страницы входа в Legendary в браузере"
msgid "Failed to open Legendary login page"
msgstr "Не удалось открыть страницу входа в Legendary"
msgid "Please enter an authorization code"
msgstr "Пожалуйста, введите код авторизации"
msgid "Successfully authenticated with Legendary"
msgstr "Успешная аутентификация в Legendary"
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr "Не удалось выполнить аутентификацию Legendary: {0}"
msgid "Legendary executable not found"
msgstr "Не найден исполняемый файл Legendary"
msgid "Unexpected error during authentication"
msgstr "Неожиданная ошибка при аутентификации"
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "Подтвердите удаление" msgstr "Подтвердите удаление"
msgid "Are you sure you want to reset all settings? This action cannot be undone." msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr "" msgstr ""
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя " "Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
"отменить."
msgid "Settings reset. Restarting..." msgid "Settings reset. Restarting..."
msgstr "Настройки сброшены. Перезапуск..." msgstr "Настройки сброшены. Перезапуск..."
@@ -558,9 +531,6 @@ msgstr "Тема '{0}' применена успешно"
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'" msgstr "Ошибка при применение темы '{0}'"
msgid "Back"
msgstr "Назад"
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "Последний запуск" msgstr "Последний запуск"
@@ -684,4 +654,3 @@ msgstr "Нет избранных"
msgid "No recent games" msgid "No recent games"
msgstr "Нет недавних игр" msgstr "Нет недавних игр"

View File

@@ -1,16 +1,34 @@
import logging import logging
def setup_logger(): def setup_logger(level='NOTSET'):
"""Настройка базовой конфигурации логирования.""" """Настройка базовой конфигурации логирования."""
logging.basicConfig( # Clear existing handlers to prevent duplicates
level=logging.INFO, root_logger = logging.getLogger()
format='[%(levelname)s] %(message)s', for handler in root_logger.handlers[:]:
handlers=[logging.StreamHandler()] root_logger.removeHandler(handler)
)
# Convert string level to logging level constant, map ALL to DEBUG
if level.upper() == 'ALL':
log_level = logging.DEBUG
else:
log_level = getattr(logging, level.upper(), logging.NOTSET)
# Configure logging with null handler if level is NOTSET
if log_level == logging.NOTSET:
logging.basicConfig(
level=logging.NOTSET,
handlers=[logging.NullHandler()]
)
else:
logging.basicConfig(
level=log_level,
format='[%(levelname)s] %(message)s',
handlers=[logging.StreamHandler()]
)
def get_logger(name): def get_logger(name):
"""Возвращает логгер для указанного модуля.""" """Возвращает логгер для указанного модуля."""
return logging.getLogger(name) return logging.getLogger(name)
# Инициализация логгера при импорте модуля # Инициализация логгера при импорте модуля (без логов по умолчанию)
setup_logger() setup_logger()

File diff suppressed because it is too large Load Diff

50
portprotonqt/preloader.py Normal file
View File

@@ -0,0 +1,50 @@
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

View File

@@ -22,6 +22,7 @@ import websocket
import requests import requests
import random import random
import base64 import base64
import glob
downloader = Downloader() downloader = Downloader()
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -44,14 +45,14 @@ def safe_vdf_load(path: str | Path) -> dict:
def decode_text(text: str) -> str: def decode_text(text: str) -> str:
""" """
Декодирует HTML-сущности в строке. Decodes HTML entities in a string.
Например, "&amp;quot;" преобразуется в '"'. For example, "&amp;quot;" is converted to '"'.
Остальные символы и HTML-теги остаются без изменений. Other characters and HTML tags remain unchanged.
""" """
return html.unescape(text) return html.unescape(text)
def get_cache_dir(): def get_cache_dir():
"""Возвращает путь к каталогу кэша, создаёт его при необходимости.""" """Returns the path to the cache directory, creating it if necessary."""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
os.makedirs(cache_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
@@ -64,7 +65,7 @@ STEAM_DATA_DIRS = (
) )
def get_steam_home(): def get_steam_home():
"""Возвращает путь к директории Steam, используя список возможных директорий.""" """Returns the path to the Steam directory using a list of possible directories."""
for dir_path in STEAM_DATA_DIRS: for dir_path in STEAM_DATA_DIRS:
expanded_path = Path(os.path.expanduser(dir_path)) expanded_path = Path(os.path.expanduser(dir_path))
if expanded_path.exists(): if expanded_path.exists():
@@ -72,7 +73,7 @@ def get_steam_home():
return None return None
def get_last_steam_user(steam_home: Path) -> dict | None: def get_last_steam_user(steam_home: Path) -> dict | None:
"""Возвращает данные последнего пользователя Steam из loginusers.vdf.""" """Returns data for the last Steam user from loginusers.vdf."""
loginusers_path = steam_home / "config/loginusers.vdf" loginusers_path = steam_home / "config/loginusers.vdf"
data = safe_vdf_load(loginusers_path) data = safe_vdf_load(loginusers_path)
if not data: if not data:
@@ -83,20 +84,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
try: try:
return {'SteamID': int(user_id)} return {'SteamID': int(user_id)}
except ValueError: except ValueError:
logger.error(f"Неверный формат SteamID: {user_id}") logger.error(f"Invalid SteamID format: {user_id}")
return None return None
logger.info("Не найден пользователь с MostRecent=1") logger.info("No user found with MostRecent=1")
return None return None
def convert_steam_id(steam_id: int) -> int: def convert_steam_id(steam_id: int) -> int:
""" """
Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число. Converts a signed 32-bit integer to an unsigned 32-bit integer.
Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения. Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values.
""" """
return steam_id & 0xFFFFFFFF return steam_id & 0xFFFFFFFF
def get_steam_libs(steam_dir: Path) -> set[Path]: def get_steam_libs(steam_dir: Path) -> set[Path]:
"""Возвращает набор директорий Steam libraryfolders.""" """Returns a set of Steam library folders."""
libs = set() libs = set()
libs_vdf = steam_dir / "steamapps/libraryfolders.vdf" libs_vdf = steam_dir / "steamapps/libraryfolders.vdf"
data = safe_vdf_load(libs_vdf) data = safe_vdf_load(libs_vdf)
@@ -112,7 +113,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]:
return libs return libs
def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]: def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]:
"""Возвращает данные о времени игры для последнего пользователя.""" """Returns playtime data for the last user."""
play_data: dict[int, tuple[int, int]] = {} play_data: dict[int, tuple[int, int]] = {}
if steam_home is None: if steam_home is None:
steam_home = get_steam_home() steam_home = get_steam_home()
@@ -132,14 +133,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
return play_data return play_data
if not last_user: if not last_user:
logger.info("Не удалось определить последнего пользователя Steam") logger.info("Could not identify the last Steam user")
return play_data return play_data
user_id = last_user['SteamID'] user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id) unsigned_id = convert_steam_id(user_id)
user_dir = userdata_dir / str(unsigned_id) user_dir = userdata_dir / str(unsigned_id)
if not user_dir.exists(): if not user_dir.exists():
logger.info(f"Директория пользователя {unsigned_id} не найдена") logger.info(f"User directory {unsigned_id} not found")
return play_data return play_data
localconfig = user_dir / "config/localconfig.vdf" localconfig = user_dir / "config/localconfig.vdf"
@@ -153,11 +154,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
playtime = int(info.get('Playtime', 0)) playtime = int(info.get('Playtime', 0))
play_data[appid] = (last_played, playtime) play_data[appid] = (last_played, playtime)
except ValueError: except ValueError:
logger.warning(f"Некорректные данные playtime для app {appid_str}") logger.warning(f"Invalid playtime data for app {appid_str}")
return play_data return play_data
def get_steam_installed_games() -> list[tuple[str, int, int, int]]: def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
"""Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec).""" """Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec)."""
games: list[tuple[str, int, int, int]] = [] games: list[tuple[str, int, int, int]] = []
steam_home = get_steam_home() steam_home = get_steam_home()
if steam_home is None or not steam_home.exists(): if steam_home is None or not steam_home.exists():
@@ -186,13 +187,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
def normalize_name(s): def normalize_name(s):
""" """
Приведение строки к нормальному виду: Normalizes a string by:
- перевод в нижний регистр, - converting to lowercase,
- удаление символов ™ и ®, - removing ™ and ® symbols,
- замена разделителей (-, :, ,) на пробел, - replacing separators (-, :, ,) with spaces,
- удаление лишних пробелов, - removing extra spaces,
- удаление суффиксов 'bin' или 'app' в конце строки, - removing 'bin' or 'app' suffixes,
- удаление ключевых слов типа 'ultimate', 'edition' и т.п. - removing keywords like 'ultimate', 'edition', etc.
""" """
s = s.lower() s = s.lower()
for ch in ["", "®"]: for ch in ["", "®"]:
@@ -210,12 +211,12 @@ def normalize_name(s):
def is_valid_candidate(candidate): def is_valid_candidate(candidate):
""" """
Проверяет, содержит ли кандидат запрещённые подстроки: Checks if a candidate contains forbidden substrings:
- win32 - win32
- win64 - win64
- gamelauncher - gamelauncher
Для проверки дополнительно используется строка без пробелов. Additionally checks the string without spaces.
Возвращает True, если кандидат допустим, иначе False. Returns True if the candidate is valid, otherwise False.
""" """
normalized_candidate = normalize_name(candidate) normalized_candidate = normalize_name(candidate)
normalized_no_space = normalized_candidate.replace(" ", "") normalized_no_space = normalized_candidate.replace(" ", "")
@@ -227,7 +228,7 @@ def is_valid_candidate(candidate):
def filter_candidates(candidates): def filter_candidates(candidates):
""" """
Фильтрует список кандидатов, отбрасывая недопустимые. Filters a list of candidates, discarding invalid ones.
""" """
valid = [] valid = []
dropped = [] dropped = []
@@ -237,18 +238,18 @@ def filter_candidates(candidates):
else: else:
dropped.append(cand) dropped.append(cand)
if dropped: if dropped:
logger.info("Отбрасываю кандидатов: %s", dropped) logger.info("Discarding candidates: %s", dropped)
return valid return valid
def remove_duplicates(candidates): def remove_duplicates(candidates):
""" """
Удаляет дубликаты из списка, сохраняя порядок. Removes duplicates from a list while preserving order.
""" """
return list(dict.fromkeys(candidates)) return list(dict.fromkeys(candidates))
@functools.lru_cache(maxsize=256) @functools.lru_cache(maxsize=256)
def get_exiftool_data(game_exe): def get_exiftool_data(game_exe):
"""Получает метаданные через exiftool""" """Retrieves metadata using exiftool."""
try: try:
proc = subprocess.run( proc = subprocess.run(
["exiftool", "-j", game_exe], ["exiftool", "-j", game_exe],
@@ -257,18 +258,28 @@ def get_exiftool_data(game_exe):
check=False check=False
) )
if proc.returncode != 0: if proc.returncode != 0:
logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}") logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
return {} 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 {} return meta_data_list[0] if meta_data_list else {}
except Exception as e: except Exception as e:
logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}") logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
return {} return {}
def delete_cached_app_files(cache_dir: str, pattern: str):
"""Deletes cached files matching the given pattern in the cache directory."""
try:
for file_path in glob.glob(os.path.join(cache_dir, pattern)):
os.remove(file_path)
logger.info(f"Deleted cached file: {file_path}")
except Exception as e:
logger.error(f"Failed to delete cached files matching {pattern}: {e}")
def load_steam_apps_async(callback: Callable[[list], None]): def load_steam_apps_async(callback: Callable[[list], None]):
""" """
Asynchronously loads the list of Steam applications, using cache if available. Asynchronously loads the list of Steam applications, using cache if available.
Calls the callback with the list of apps. Calls the callback with the list of apps.
Deletes cached app detail files when downloading a new steam_apps.json.
""" """
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "games_appid.tar.xz") cache_tar = os.path.join(cache_dir, "games_appid.tar.xz")
@@ -294,12 +305,14 @@ def load_steam_apps_async(callback: Callable[[list], None]):
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
if os.path.exists(cache_tar): if os.path.exists(cache_tar):
os.remove(cache_tar) os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar) logger.info("Deleted archive: %s", cache_tar)
# Delete all cached app detail files (steam_app_*.json)
delete_cached_app_files(cache_dir, "steam_app_*.json")
steam_apps = data if isinstance(data, list) else [] 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)
except Exception as e: except Exception as e:
logger.error("Error extracting Steam apps archive: %s", e) logger.error("Failed to extract Steam apps archive: %s", e)
callback([]) callback([])
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
@@ -309,37 +322,41 @@ def load_steam_apps_async(callback: Callable[[list], None]):
data = orjson.loads(f.read()) data = orjson.loads(f.read())
# Validate JSON structure # Validate JSON structure
if not isinstance(data, list): if not isinstance(data, list):
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json) logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure") raise ValueError("Invalid JSON structure")
# Validate each app entry # Validate each app entry
for app in data: for app in data:
if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app: if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json) logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json)
raise ValueError("Invalid app entry structure") raise ValueError("Invalid app entry structure")
steam_apps = data steam_apps = data
logger.info("Loaded %d apps from cache", len(steam_apps)) logger.info("Loaded %d apps from cache", len(steam_apps))
callback(steam_apps) callback(steam_apps)
except Exception as e: except Exception as e:
logger.error("Error reading or validating cached JSON %s: %s", cache_json, e) logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e)
# Attempt to re-download if cache is invalid or corrupted # Attempt to re-download if cache is invalid or corrupted
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
) )
# Delete cached app detail files before re-downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else: else:
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
) )
# Delete cached app detail files before downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
def build_index(steam_apps): def build_index(steam_apps):
""" """
Строит индекс приложений по полю normalized_name. Builds an index of applications by normalized_name field.
""" """
steam_apps_index = {} steam_apps_index = {}
if not steam_apps: if not steam_apps:
return steam_apps_index return steam_apps_index
logger.info("Построение индекса Steam приложений:") logger.info("Building Steam apps index")
for app in steam_apps: for app in steam_apps:
normalized = app["normalized_name"] normalized = app["normalized_name"]
steam_apps_index[normalized] = app steam_apps_index[normalized] = app
@@ -347,25 +364,24 @@ def build_index(steam_apps):
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.
""" """
candidate_norm = normalize_name(candidate) candidate_norm = normalize_name(candidate)
logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm) logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
if candidate_norm in steam_apps_index: if candidate_norm in steam_apps_index:
logger.info(" Найдено точное совпадение: '%s'", candidate_norm) logger.info("Found exact match: '%s'", candidate_norm)
return steam_apps_index[candidate_norm] return steam_apps_index[candidate_norm]
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(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)", logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
candidate_norm, name_norm, ratio)
return app return app
logger.info(" Приложение для кандидата '%s' не найдено", candidate_norm) logger.info("No app found for candidate '%s'", candidate_norm)
return None return None
def load_app_details(app_id): def load_app_details(app_id):
"""Загружает кэшированные данные для игры по appid, если они не устарели.""" """Loads cached game data by appid if not outdated."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
if os.path.exists(cache_file): if os.path.exists(cache_file):
@@ -375,7 +391,7 @@ def load_app_details(app_id):
return None return None
def save_app_details(app_id, data): def save_app_details(app_id, data):
"""Сохраняет данные по appid в файл кэша.""" """Saves appid data to a cache file."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
with open(cache_file, "wb") as f: with open(cache_file, "wb") as f:
@@ -418,7 +434,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
save_app_details(app_id, app_data) save_app_details(app_id, app_data)
callback(app_data) callback(app_data)
except Exception as e: except Exception as e:
logger.error("Error processing Steam app info for appid %s: %s", app_id, e) logger.error("Failed to process Steam app info for appid %s: %s", app_id, e)
callback(None) callback(None)
downloader.download_async(url, cache_file, timeout=5, callback=process_response) downloader.download_async(url, cache_file, timeout=5, callback=process_response)
@@ -427,6 +443,7 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
""" """
Asynchronously loads the list of WeAntiCheatYet data, using cache if available. Asynchronously loads the list of WeAntiCheatYet data, using cache if available.
Calls the callback with the list of anti-cheat data. Calls the callback with the list of anti-cheat data.
Deletes cached anti-cheat files when downloading a new anticheat_games.json.
""" """
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz") cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz")
@@ -452,12 +469,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
if os.path.exists(cache_tar): if os.path.exists(cache_tar):
os.remove(cache_tar) os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar) logger.info("Deleted archive: %s", cache_tar)
anti_cheat_data = data or [] 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)
except Exception as e: except Exception as e:
logger.error("Error extracting WeAntiCheatYet archive: %s", e) logger.error("Failed to extract WeAntiCheatYet archive: %s", e)
callback([]) callback([])
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
@@ -467,18 +484,18 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
data = orjson.loads(f.read()) data = orjson.loads(f.read())
# Validate JSON structure # Validate JSON structure
if not isinstance(data, list): if not isinstance(data, list):
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json) logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure") raise ValueError("Invalid JSON structure")
# Validate each anti-cheat entry # Validate each anti-cheat entry
for entry in data: for entry in data:
if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry: if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json) logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json)
raise ValueError("Invalid anti-cheat entry structure") raise ValueError("Invalid anti-cheat entry structure")
anti_cheat_data = data anti_cheat_data = data
logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data)) logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
callback(anti_cheat_data) callback(anti_cheat_data)
except Exception as e: except Exception as e:
logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e) logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e)
# Attempt to re-download if cache is invalid or corrupted # Attempt to re-download if cache is invalid or corrupted
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
@@ -492,12 +509,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
def build_weanticheatyet_index(anti_cheat_data): def build_weanticheatyet_index(anti_cheat_data):
""" """
Строит индекс античит-данных по полю normalized_name. Builds an index of anti-cheat data by normalized_name field.
""" """
anti_cheat_index = {} anti_cheat_index = {}
if not anti_cheat_data: if not anti_cheat_data:
return anti_cheat_index return anti_cheat_index
logger.info("Построение индекса WeAntiCheatYet данных:") logger.info("Building WeAntiCheatYet data index")
for entry in anti_cheat_data: for entry in anti_cheat_data:
normalized = entry["normalized_name"] normalized = entry["normalized_name"]
anti_cheat_index[normalized] = entry anti_cheat_index[normalized] = entry
@@ -505,20 +522,19 @@ def build_weanticheatyet_index(anti_cheat_data):
def search_anticheat_status(candidate, anti_cheat_index): def search_anticheat_status(candidate, anti_cheat_index):
candidate_norm = normalize_name(candidate) candidate_norm = normalize_name(candidate)
logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm) logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
if candidate_norm in anti_cheat_index: if candidate_norm in anti_cheat_index:
status = anti_cheat_index[candidate_norm]["status"] status = anti_cheat_index[candidate_norm]["status"]
logger.info(" Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status) logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
return status return status
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)
if ratio > 0.8: if ratio > 0.8:
status = entry["status"] status = entry["status"]
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'", logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
candidate_norm, name_norm, ratio, status)
return status return status
logger.info(" Античит-статус для кандидата '%s' не найден", candidate_norm) logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
return "" return ""
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]): def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
@@ -534,7 +550,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No
load_weanticheatyet_data_async(on_anticheat_data) load_weanticheatyet_data_async(on_anticheat_data)
def load_protondb_status(appid): def load_protondb_status(appid):
"""Загружает закешированные данные ProtonDB для игры по appid, если они не устарели.""" """Loads cached ProtonDB data for a game by appid if not outdated."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
if os.path.exists(cache_file): if os.path.exists(cache_file):
@@ -543,18 +559,18 @@ def load_protondb_status(appid):
with open(cache_file, "rb") as f: with open(cache_file, "rb") as f:
return orjson.loads(f.read()) return orjson.loads(f.read())
except Exception as e: except Exception as e:
logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e) logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e)
return None return None
def save_protondb_status(appid, data): def save_protondb_status(appid, data):
"""Сохраняет данные ProtonDB для игры по appid в файл кэша.""" """Saves ProtonDB data for a game by appid to a cache file."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
try: try:
with open(cache_file, "wb") as f: with open(cache_file, "wb") as f:
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
except Exception as e: except Exception as e:
logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e) logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e)
def get_protondb_tier_async(appid: int, callback: Callable[[str], None]): def get_protondb_tier_async(appid: int, callback: Callable[[str], None]):
""" """
@@ -642,7 +658,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
if game_exe.lower().endswith('.exe'): if game_exe.lower().endswith('.exe'):
break break
except Exception as e: except Exception as e:
logger.error("Error processing bat file %s: %s", game_exe, e) logger.error("Failed to process bat file %s: %s", game_exe, e)
else: else:
logger.error("Bat file not found: %s", game_exe) logger.error("Bat file not found: %s", game_exe)
@@ -777,55 +793,55 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
def enable_steam_cef() -> tuple[bool, str]: def enable_steam_cef() -> tuple[bool, str]:
""" """
Проверяет и при необходимости активирует режим удаленной отладки Steam CEF. Checks and enables Steam CEF remote debugging if necessary.
Создает файл .cef-enable-remote-debugging в директории Steam. Creates a .cef-enable-remote-debugging file in the Steam directory.
Steam необходимо перезапустить после первого создания этого файла. Steam must be restarted after the file is first created.
Возвращает кортеж: Returns a tuple:
- (True, "already_enabled") если уже было активно. - (True, "already_enabled") if already enabled.
- (True, "restart_needed") если было только что активировано и нужен перезапуск Steam. - (True, "restart_needed") if just enabled and Steam restart is needed.
- (False, "steam_not_found") если директория Steam не найдена. - (False, "steam_not_found") if Steam directory is not found.
""" """
steam_home = get_steam_home() steam_home = get_steam_home()
if not steam_home: if not steam_home:
return (False, "steam_not_found") return (False, "steam_not_found")
cef_flag_file = steam_home / ".cef-enable-remote-debugging" cef_flag_file = steam_home / ".cef-enable-remote-debugging"
logger.info(f"Проверка CEF флага: {cef_flag_file}") logger.info(f"Checking CEF flag: {cef_flag_file}")
if cef_flag_file.exists(): if cef_flag_file.exists():
logger.info("CEF Remote Debugging уже активирован.") logger.info("CEF Remote Debugging is already enabled")
return (True, "already_enabled") return (True, "already_enabled")
else: else:
try: try:
os.makedirs(cef_flag_file.parent, exist_ok=True) os.makedirs(cef_flag_file.parent, exist_ok=True)
cef_flag_file.touch() cef_flag_file.touch()
logger.info("CEF Remote Debugging активирован. Steam необходимо перезапустить.") logger.info("Enabled CEF Remote Debugging. Steam restart required")
return (True, "restart_needed") return (True, "restart_needed")
except Exception as e: except Exception as e:
logger.error(f"Не удалось создать CEF флаг {cef_flag_file}: {e}") logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}")
return (False, str(e)) return (False, str(e))
def call_steam_api(js_cmd: str, *args) -> dict | None: def call_steam_api(js_cmd: str, *args) -> dict | None:
""" """
Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging. Executes a JavaScript function in the Steam context via CEF Remote Debugging.
Args: Args:
js_cmd: Имя JS функции для вызова (напр. 'createShortcut'). js_cmd: Name of the JS function to call (e.g., 'createShortcut').
*args: Аргументы для передачи в JS функцию. *args: Arguments to pass to the JS function.
Returns: Returns:
Словарь с результатом выполнения или None в случае ошибки. Dictionary with the result or None if an error occurs.
""" """
status, message = enable_steam_cef() status, message = enable_steam_cef()
if not (status is True and message == "already_enabled"): if not (status is True and message == "already_enabled"):
if message == "restart_needed": if message == "restart_needed":
logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.") logger.warning("Steam CEF API is available but requires Steam restart for full activation")
elif message == "steam_not_found": elif message == "steam_not_found":
logger.error("Не удалось найти директорию Steam для проверки CEF API.") logger.error("Could not find Steam directory to check CEF API")
else: else:
logger.error(f"Steam CEF API недоступен или не готов: {message}") logger.error(f"Steam CEF API is unavailable or not ready: {message}")
return None return None
steam_debug_url = "http://localhost:8080/json" steam_debug_url = "http://localhost:8080/json"
@@ -836,10 +852,10 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
contexts = response.json() contexts = response.json()
ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None) ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
if not ws_url: if not ws_url:
logger.warning("Не удалось найти SharedJSContext. Steam запущен с флагом -cef-enable-remote-debugging?") logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?")
return None return None
except Exception as e: except Exception as e:
logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}") logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {e}")
return None return None
js_code = """ js_code = """
@@ -884,15 +900,15 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
response_data = orjson.loads(response_str) response_data = orjson.loads(response_str)
if "error" in response_data: if "error" in response_data:
logger.error(f"Ошибка выполнения JS в Steam: {response_data['error']['message']}") logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}")
return None return None
result = response_data.get('result', {}).get('result', {}) result = response_data.get('result', {}).get('result', {})
if result.get('type') == 'object' and result.get('subtype') == 'error': if result.get('type') == 'object' and result.get('subtype') == 'error':
logger.error(f"Ошибка выполнения JS в Steam: {result.get('description')}") logger.error(f"JavaScript execution error in Steam: {result.get('description')}")
return None return None
return result.get('value') return result.get('value')
except Exception as e: except Exception as e:
logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}") logger.error(f"WebSocket interaction error with Steam: {e}")
return None return None
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]: def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
@@ -969,24 +985,24 @@ export START_FROM_STEAM=1
else: else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True) success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if not success or not os.path.exists(generated_icon_path): if not success or not os.path.exists(generated_icon_path):
logger.warning(f"generate_thumbnail failed to create icon for {exe_path}") logger.warning(f"Failed to generate thumbnail for {exe_path}")
icon_path = "" icon_path = ""
else: else:
logger.info(f"Generated thumbnail: {generated_icon_path}") logger.info(f"Generated thumbnail: {generated_icon_path}")
icon_path = generated_icon_path icon_path = generated_icon_path
except Exception as e: except Exception as e:
logger.error(f"Error generating thumbnail for {exe_path}: {e}") logger.error(f"Failed to generate thumbnail for {exe_path}: {e}")
icon_path = "" icon_path = ""
steam_home = get_steam_home() steam_home = get_steam_home()
if not steam_home: if not steam_home:
logger.error("Steam home directory not found") logger.error("Steam home directory not found")
return (False, "Steam directory not found.") return (False, "Steam directory not found")
last_user = get_last_steam_user(steam_home) last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user: if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID") logger.error("Failed to retrieve Steam user ID")
return (False, "Failed to get Steam user ID.") return (False, "Failed to get Steam user ID")
userdata_dir = steam_home / "userdata" userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID'] user_id = last_user['SteamID']
@@ -999,7 +1015,7 @@ export START_FROM_STEAM=1
appid = None appid = None
was_api_used = False was_api_used = False
logger.info("Попытка добавления ярлыка через Steam CEF API...") logger.info("Attempting to add shortcut via Steam CEF API")
api_response = call_steam_api( api_response = call_steam_api(
"createShortcut", "createShortcut",
game_name, game_name,
@@ -1012,9 +1028,9 @@ export START_FROM_STEAM=1
if api_response and isinstance(api_response, dict) and 'id' in api_response: if api_response and isinstance(api_response, dict) and 'id' in api_response:
appid = api_response['id'] appid = api_response['id']
was_api_used = True was_api_used = True
logger.info(f"Ярлык успешно добавлен через API. AppID: {appid}") logger.info(f"Shortcut successfully added via API. AppID: {appid}")
else: else:
logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).") logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf")
backup_path = f"{steam_shortcuts_path}.backup" backup_path = f"{steam_shortcuts_path}.backup"
if os.path.exists(steam_shortcuts_path): if os.path.exists(steam_shortcuts_path):
try: try:
@@ -1088,7 +1104,7 @@ export START_FROM_STEAM=1
appid = None appid = None
if not appid: if not appid:
return (False, "Не удалось создать ярлык ни одним из способов.") return (False, "Failed to create shortcut using any method")
steam_appid = None steam_appid = None
@@ -1098,7 +1114,7 @@ export START_FROM_STEAM=1
if not steam_appid or not isinstance(steam_appid, int): if not steam_appid or not isinstance(steam_appid, int):
logger.info("No valid Steam appid found, skipping cover download") logger.info("No valid Steam appid found, skipping cover download")
return return
logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.") logger.info(f"Found Steam AppID {steam_appid} for cover download")
cover_types = [ cover_types = [
("p.jpg", "library_600x900_2x.jpg"), ("p.jpg", "library_600x900_2x.jpg"),
@@ -1115,15 +1131,15 @@ export START_FROM_STEAM=1
try: try:
with open(result_path, 'rb') as f: with open(result_path, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8') img_b64 = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"Применение обложки типа '{steam_name}' через API для AppID {appid}") logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
ext = Path(steam_name).suffix.lstrip('.') ext = Path(steam_name).suffix.lstrip('.')
call_steam_api("setGrid", appid, index, ext, img_b64) call_steam_api("setGrid", appid, index, ext, img_b64)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при применении обложки '{steam_name}' через API: {e}") logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
else: else:
logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}") logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
except Exception as e: except Exception as e:
logger.error(f"Error processing cover {steam_name} for appid {steam_appid}: {e}") logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
for i, (suffix, steam_name) in enumerate(cover_types): for i, (suffix, steam_name) in enumerate(cover_types):
cover_file = os.path.join(grid_dir, f"{appid}{suffix}") cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
@@ -1164,13 +1180,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
steam_home = get_steam_home() steam_home = get_steam_home()
if not steam_home: if not steam_home:
logger.error("Steam home directory not found") logger.error("Steam home directory not found")
return (False, "Steam directory not found.") return (False, "Steam directory not found")
# Get current Steam user ID # Get current Steam user ID
last_user = get_last_steam_user(steam_home) last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user: if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID") logger.error("Failed to retrieve Steam user ID")
return (False, "Failed to get Steam user ID.") return (False, "Failed to get Steam user ID")
userdata_dir = steam_home / "userdata" userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID'] user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id) unsigned_id = convert_steam_id(user_id)
@@ -1216,10 +1232,10 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
return (False, f"Game '{game_name}' not found in Steam") return (False, f"Game '{game_name}' not found in Steam")
api_response = call_steam_api("removeShortcut", appid) api_response = call_steam_api("removeShortcut", appid)
if api_response is not None: # API ответил, даже если ответ пустой if api_response is not None: # API responded, even if response is empty
logger.info(f"Ярлык для AppID {appid} успешно удален через API.") logger.info(f"Shortcut for AppID {appid} successfully removed via API")
else: else:
logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).") logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf")
# Create backup of shortcuts.vdf # Create backup of shortcuts.vdf
backup_path = f"{steam_shortcuts_path}.backup" backup_path = f"{steam_shortcuts_path}.backup"
@@ -1298,5 +1314,5 @@ def is_game_in_steam(game_name: str) -> bool:
if entry.get("AppName") == game_name: if entry.get("AppName") == game_name:
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error checking if game {game_name} is in Steam: {e}") logger.error(f"Failed to check if game {game_name} is in Steam: {e}")
return False return False

View File

@@ -1,9 +1,8 @@
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 PySide6.QtSvg import QSvgRenderer from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -14,6 +13,59 @@ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQt", "themes"), os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
] ]
_loaded_theme = None
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_theme_safety(theme_file: str) -> bool:
"""
Проверяет файл темы на наличие запрещённых модулей и функций.
Возвращает True, если файл безопасен, иначе False.
"""
has_errors = False
try:
with open(theme_file) as f:
content = f.read()
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, ast.Import | ast.ImportFrom):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
has_errors = True
except SyntaxError as e:
logger.error(f"Syntax error in file {theme_file}: {e}")
has_errors = True
except Exception as e:
logger.error(f"Failed to check theme safety for {theme_file}: {e}")
has_errors = True
return not has_errors
def list_themes(): def list_themes():
""" """
@@ -49,9 +101,13 @@ def load_theme_screenshots(theme_name):
def load_theme_fonts(theme_name): def load_theme_fonts(theme_name):
""" """
Загружает все шрифты выбранной темы. Загружает все шрифты выбранной темы, если они ещё не были загружены.
:param theme_name: Имя темы.
""" """
global _loaded_theme
if _loaded_theme == theme_name:
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
return
QFontDatabase.removeAllApplicationFonts() QFontDatabase.removeAllApplicationFonts()
fonts_folder = None fonts_folder = None
if theme_name == "standart": if theme_name == "standart":
@@ -66,7 +122,7 @@ def load_theme_fonts(theme_name):
break break
if not fonts_folder or not os.path.exists(fonts_folder): if not fonts_folder or not os.path.exists(fonts_folder):
logger.error(f"Папка fonts не найдена для темы '{theme_name}'") logger.error(f"Fonts folder not found for theme '{theme_name}'")
return return
for filename in os.listdir(fonts_folder): for filename in os.listdir(fonts_folder):
@@ -75,29 +131,11 @@ def load_theme_fonts(theme_name):
font_id = QFontDatabase.addApplicationFont(font_path) font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1: if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id) families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Шрифт {filename} успешно загружен: {families}") logger.info(f"Font {filename} successfully loaded: {families}")
else: else:
logger.error(f"Ошибка загрузки шрифта: {filename}") logger.error(f"Error loading font: {filename}")
def load_logo(): _loaded_theme = theme_name
logo_path = None
base_dir = os.path.dirname(os.path.abspath(__file__))
logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg")
file_extension = os.path.splitext(logo_path)[1].lower()
if file_extension == ".svg":
renderer = QSvgRenderer(logo_path)
if not renderer.isValid():
logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}")
return None
pixmap = QPixmap(128, 128)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return pixmap
class ThemeWrapper: class ThemeWrapper:
""" """
@@ -109,69 +147,83 @@ class ThemeWrapper:
self.custom_theme = custom_theme self.custom_theme = custom_theme
self.metainfo = metainfo or {} self.metainfo = metainfo or {}
self.screenshots = load_theme_screenshots(self.metainfo.get("name", "")) self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
self._default_theme = None # Lazy-loaded default theme
def __getattr__(self, name): def __getattr__(self, name):
if hasattr(self.custom_theme, name): if hasattr(self.custom_theme, name):
return getattr(self.custom_theme, name) return getattr(self.custom_theme, name)
import portprotonqt.themes.standart.styles as default_styles if self._default_theme is None:
return getattr(default_styles, name) self._default_theme = load_theme("standart") # Dynamically load standard theme
return getattr(self._default_theme, name)
def load_theme(theme_name): def load_theme(theme_name):
""" """
Динамически загружает модуль стилей выбранной темы и метаинформацию. Динамически загружает модуль стилей выбранной темы и метаинформацию.
Если выбрана стандартная тема, импортируется оригинальный styles.py. Все темы, включая стандартную, проходят проверку безопасности.
Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты. Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
""" """
if theme_name == "standart":
import portprotonqt.themes.standart.styles as default_styles
return default_styles
for themes_dir in THEMES_DIRS: for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name) theme_folder = os.path.join(themes_dir, theme_name)
styles_file = os.path.join(theme_folder, "styles.py") styles_file = os.path.join(theme_folder, "styles.py")
if os.path.exists(styles_file): if os.path.exists(styles_file):
# Проверяем безопасность темы перед загрузкой
if not check_theme_safety(styles_file):
logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'")
raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions")
spec = importlib.util.spec_from_file_location("theme_styles", styles_file) spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
continue continue
custom_theme = importlib.util.module_from_spec(spec) custom_theme = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_theme) spec.loader.exec_module(custom_theme)
if theme_name == "standart":
return custom_theme
meta = load_theme_metainfo(theme_name) meta = load_theme_metainfo(theme_name)
wrapper = ThemeWrapper(custom_theme, metainfo=meta) wrapper = ThemeWrapper(custom_theme, metainfo=meta)
wrapper.screenshots = load_theme_screenshots(theme_name) wrapper.screenshots = load_theme_screenshots(theme_name)
return wrapper return wrapper
raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'") raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
class ThemeManager: class ThemeManager:
""" """
Класс для управления темами приложения. Класс для управления темами приложения.
Реализует паттерн Singleton для единого экземпляра.
Позволяет получить список доступных тем, загрузить и применить выбранную тему.
""" """
def __init__(self): _instance = None
self.current_theme_name = None
self.current_theme_module = None
def get_available_themes(self): def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.current_theme_name = None
cls._instance.current_theme_module = None
return cls._instance
def get_available_themes(self) -> list:
"""Возвращает список доступных тем.""" """Возвращает список доступных тем."""
return list_themes() return list_themes()
def get_theme_logo(self): def apply_theme(self, theme_name: str):
"""Возвращает логотип для текущей или указанной темы.""" """
return load_logo() Применяет указанную тему, если она ещё не применена.
Возвращает модуль темы или обёртку.
"""
if self.current_theme_name == theme_name and self.current_theme_module is not None:
logger.debug(f"Theme '{theme_name}' is already applied, skipping")
return self.current_theme_module
try:
theme_module = load_theme(theme_name)
except FileNotFoundError:
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
theme_module = load_theme("standart")
theme_name = "standart"
save_theme_to_config("standart")
def apply_theme(self, theme_name):
"""
Применяет выбранную тему: загружает модуль стилей, шрифты и логотип.
Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации.
:param theme_name: Имя темы.
:return: Загруженный модуль темы (или обёртка).
"""
theme_module = load_theme(theme_name)
load_theme_fonts(theme_name) load_theme_fonts(theme_name)
self.current_theme_name = theme_name self.current_theme_name = theme_name
self.current_theme_module = theme_module self.current_theme_module = theme_module
save_theme_to_config(theme_name) save_theme_to_config(theme_name)
logger.info(f"Тема '{theme_name}' успешно применена") logger.info(f"Theme '{theme_name}' successfully applied")
return theme_module return theme_module
def get_icon(self, icon_name, theme_name=None, as_path=False): def get_icon(self, icon_name, theme_name=None, as_path=False):
@@ -226,7 +278,7 @@ class ThemeManager:
# Если иконка всё равно не найдена # Если иконка всё равно не найдена
if not icon_path or not os.path.exists(icon_path): if not icon_path or not os.path.exists(icon_path):
logger.error(f"Предупреждение: иконка '{icon_name}' не найдена") logger.error(f"Warning: icon '{icon_name}' not found")
return QIcon() if not as_path else None return QIcon() if not as_path else None
if as_path: if as_path:

View 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"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="48"
height="48"
version="1.1"
viewBox="0 0 48 48"
xml:space="preserve"
id="svg2"
sodipodi:docname="key_context.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs2" /><sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="8.6915209"
inkscape:cx="72.311855"
inkscape:cy="22.780823"
inkscape:window-width="2560"
inkscape:window-height="1406"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000"
d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z"
id="path10"
sodipodi:nodetypes="csccscc" /><path
fill="#000000"
d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z"
id="path2"
style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g
id="g15"
transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z"
id="path14" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z"
id="path15" /></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View 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="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg>

After

Width:  |  Height:  |  Size: 726 B

View 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="m6 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg>

After

Width:  |  Height:  |  Size: 823 B

View 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="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg>

After

Width:  |  Height:  |  Size: 857 B

View 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"><defs><style>.cls-1{fill:none;}</style></defs><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="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

After

Width:  |  Height:  |  Size: 865 B

View 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"><defs><style>.cls-1{fill:none;}</style></defs><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="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>

After

Width:  |  Height:  |  Size: 736 B

View 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="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></svg>

After

Width:  |  Height:  |  Size: 961 B

View 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="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></svg>

After

Width:  |  Height:  |  Size: 1015 B

View 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="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View 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="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View 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="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></svg>

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.8 KiB

View 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.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></svg>

After

Width:  |  Height:  |  Size: 600 B

View 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="m15.973 13.476h8.5299q3.0163 0 4.6379 0.45406 1.6541 0.42163 2.3352 1.3946 0.68109 0.94056 0.68109 2.6595v2.5946q0 0.87569-0.71353 1.6541-0.68109 0.77839-1.6216 1.0703v0.16216q1.2325 0.12973 2.2379 1.0703 1.0379 0.90812 1.0379 2.0433v3.2433q0 2.5622-2.0433 3.6325t-6.3244 1.0703h-8.7569zm8.5299 8.5623q1.2 0 1.7838-0.1946t0.77839-0.61623q0.22703-0.45406 0.22703-1.2973v-1.0379q0-0.74596-0.1946-1.1027-0.1946-0.3892-0.81082-0.55136-0.58379-0.16216-1.8811-0.16216h-3.373v4.9622zm0.12973 8.8866q1.8487 0 2.6271-0.42163t0.77839-1.3622v-1.6865q0-1.1676-0.61623-1.6541-0.58379-0.4865-2.1081-0.4865h-4.2812v5.6109z" fill="#3f424d" stroke-width=".67675" aria-label="B"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View 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="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m13.757 18h2.8844v9.9476h5.8983v2.0524h-8.7827zm10.724 0h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.0169-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="LB"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View 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="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m12.943 18h5.3991q2.4037 0 3.4761 0.59168 1.0724 0.57319 1.0724 1.9414v2.1079q0 1.0539-0.75809 1.6641-0.7396 0.59168-1.9784 0.75809l3.4761 4.9368h-3.3282l-3.1433-4.7149h-1.3313v4.7149h-2.8844zm5.3621 5.3806q1.0169 0 1.3867-0.25886 0.3698-0.27735 0.3698-1.0539v-0.85054q0-0.66564-0.40678-0.90601-0.38829-0.25886-1.3498-0.25886h-2.4777v3.3282zm6.9892-5.3806h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.017-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="RB"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View 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="m16.169 14.061c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395z" fill="#3f424d" fill-rule="evenodd" stroke-width=".19943"/></svg>

After

Width:  |  Height:  |  Size: 958 B

View 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="m12.75 14.492c-0.62128 0-1.1257 0.38721-1.1257 0.86456v12.1c0 0.47737 0.50442 0.86456 1.1257 0.86456h3.3753v-1.7274h-2.2496v-10.373h13.498v1.7291h2.2496v-2.5937c0-0.47735-0.50268-0.86456-1.1239-0.86456zm6.7489 5.1874c-0.62128 0-1.1239 0.38721-1.1239 0.86456v12.1c0 0.47737 0.50266 0.86456 1.1239 0.86456h15.749c0.62125 0 1.1239-0.3872 1.1239-0.86456v-12.1c0-0.47735-0.50268-0.86456-1.1239-0.86456zm1.1257 1.7291h13.498v10.371h-13.498z" clip-rule="evenodd" fill="#3f424d" fill-rule="evenodd" stroke-width=".98604"/></svg>

After

Width:  |  Height:  |  Size: 919 B

View 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.097 23.789-7.4272-10.314h5.8379l4.5082 7.0055 4.4758-7.0055h5.8379l-7.4272 10.314 7.7839 10.735h-5.8704l-4.8001-7.4596-4.8325 7.4596h-5.8379z" fill="#3f424d" stroke-width=".67675" aria-label="X"/></svg>

After

Width:  |  Height:  |  Size: 605 B

View File

@@ -280,16 +280,6 @@ MAIN_WINDOW_HEADER_STYLE = f"""
}} }}
""" """
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
TITLE_LABEL_STYLE = """
QLabel {
font-family: 'RASKHAL';
font-size: 38px;
margin: 0 0 0 0;
color: #007AFF;
}
"""
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК) # СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
NAV_WIDGET_STYLE = f""" NAV_WIDGET_STYLE = f"""
QWidget {{ QWidget {{

View File

@@ -9,7 +9,6 @@ from PySide6.QtGui import QIcon, QAction
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config
from portprotonqt.dialogs import GameLaunchDialog from portprotonqt.dialogs import GameLaunchDialog
@@ -31,15 +30,7 @@ class TrayManager:
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
selected_theme = read_theme_from_config() selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme self.current_theme_name = selected_theme
try: self.theme = self.theme_manager.apply_theme(selected_theme)
self.theme = self.theme_manager.apply_theme(selected_theme)
except FileNotFoundError:
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
self.theme = self.theme_manager.apply_theme("standart")
self.current_theme_name = "standart"
save_theme_to_config("standart")
if not self.theme:
self.theme = default_styles
self.main_window = main_window self.main_window = main_window
self.tray_icon = QSystemTrayIcon(self.main_window) self.tray_icon = QSystemTrayIcon(self.main_window)

View File

@@ -1,10 +1,10 @@
[build-system] [build-system]
requires = ["setuptools>=61.0"] requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.5" version = "0.1.6"
description = "A project to rewrite PortProton (PortWINE) using PySide" description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md" readme = "README.md"
license = { text = "GPL-3.0" } license = { text = "GPL-3.0" }
@@ -22,7 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Operating System :: Linux" "Operating System :: POSIX :: Linux"
] ]
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [

View File

@@ -5,21 +5,25 @@
"lockFileMaintenance": { "lockFileMaintenance": {
"enabled": true "enabled": true
}, },
"pre-commit": {
"enabled": true
},
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"automerge": true "automerge": true
}, },
{ {
"matchDatasources": ["python-version"], "matchFileNames": [".gitea/workflows/build.yml"],
"enabled": false "enabled": false,
"description": "Disabled because download-artifact@v4 is not working"
}, },
{ {
"matchFileNames": [".python-version"], "matchFileNames": [".python-version"],
"enabled": false "enabled": false
}, },
{ {
"matchManagers": ["github-actions", "pre-commit", "poetry"], "matchManagers": ["poetry", "pyenv"],
"enabled": false "enabled": false
}, },
{ {
@@ -29,9 +33,14 @@
"groupName": "Python dependencies" "groupName": "Python dependencies"
}, },
{ {
"matchPackageNames": ["numpy", "setuptools"], "matchPackageNames": ["numpy", "setuptools", "python"],
"enabled": false, "enabled": false,
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)" "description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
},
{
"matchDatasources": ["github-runners", "python-version"],
"enabled": false,
"description": "Prevent Renovate from updating runs-on to unsupported ubuntu-24.04"
} }
] ]
} }

2
uv.lock generated
View File

@@ -501,7 +501,7 @@ wheels = [
[[package]] [[package]]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.5" version = "0.1.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "babel" }, { name = "babel" },