82 Commits

Author SHA1 Message Date
3e74cbdcf5 chore(locales): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Successful in 16s
Code check / Check code (push) Successful in 1m7s
renovate / renovate (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-03 20:34:42 +05:00
a9b97e3a4b feat(get_wine): make unpack progress real
All checks were successful
Code check / Check code (push) Successful in 1m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-03 16:13:19 +05:00
b9fe0250ed chore: unify get and delete wine
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-03 16:13:14 +05:00
4dcfca919f Updating the Russian translation
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (pull_request) Successful in 1m20s
Code check / Check code (pull_request) Successful in 1m30s
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Successful in 16s
Code check / Check code (push) Successful in 1m2s
2026-01-03 01:31:28 +05:00
66c23db29c fix(animations): resolve memory leaks in GameCardAnimations and DetailPageAnimations
All checks were successful
Code check / Check code (push) Successful in 1m48s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-02 21:11:41 +05:00
e7a7300665 chore(get_wine): simplify archive extraction using libarchive native API
All checks were successful
Code check / Check code (push) Successful in 1m28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-02 14:21:22 +05:00
2521f7d2f4 fix(get_wine): handle symlinks too
All checks were successful
Code check / Check code (push) Successful in 1m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-02 12:14:08 +05:00
5df0b8783f Updating the translation for the WINE download window
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (pull_request) Successful in 17s
Code check / Check code (pull_request) Successful in 1m34s
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Successful in 16s
Code check / Check code (push) Successful in 1m27s
2026-01-02 00:10:09 +05:00
044ea7d151 feat(get_wine): added CPU filtering
All checks were successful
Code check / Check code (push) Successful in 1m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-01 19:03:46 +05:00
cd93f9ebfe chore(tabbles): disable edititng
All checks were successful
Code check / Check code (push) Successful in 1m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-01 16:19:38 +05:00
1b9595ca95 chore(build): added python-libarchive-c to dependency
All checks were successful
Code check / Check code (push) Successful in 1m24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-01 15:50:58 +05:00
Gitea Actions
4dff545c0f chore: update steam apps list 2026-01-01T00:00:54Z 2026-01-01 00:00:54 +00:00
69d8e53c7b feat: reworked wine download
All checks were successful
Code check / Check code (push) Successful in 1m21s
Fetch Data / build (push) Successful in 48s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-31 13:50:52 +05:00
Renovate Bot
40769bfdf6 fix(deps): lock file maintenance python dependencies
All checks were successful
Code check / Check code (push) Successful in 1m11s
2025-12-30 15:58:37 +00:00
Renovate Bot
b3adef68d3 chore(deps): update archlinux:base-devel docker digest to f6b259c
Some checks failed
Code check / Check code (push) Has been cancelled
2025-12-30 15:56:27 +00:00
Renovate Bot
df707a84bc chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to eec497d
Some checks failed
Code check / Check code (push) Has been cancelled
Code check / Check code (pull_request) Successful in 1m8s
2025-12-30 15:52:48 +00:00
Renovate Bot
4c340c13ab chore(deps): pin archlinux docker tag to f6b259c
All checks were successful
Code check / Check code (pull_request) Successful in 1m12s
Code check / Check code (push) Successful in 1m11s
2025-12-30 15:48:13 +00:00
a81cef4457 feat(appimage): use AnyLinux Appimage to support musl-libc systems
All checks were successful
Code check / Check code (push) Successful in 1m12s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-30 15:44:59 +00:00
4c537248f1 Revert "fix(animations): prevent memory leaks by properly clearing animation references"
All checks were successful
Code check / Check code (push) Successful in 1m6s
This reverts commit 55dcda738b.
2025-12-30 11:06:15 +05:00
55dcda738b fix(animations): prevent memory leaks by properly clearing animation references
All checks were successful
Code check / Check code (push) Successful in 1m4s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-29 11:17:09 +05:00
aa0c0a5675 fix: fix slider size on autoinstall
All checks were successful
Code check / Check code (push) Successful in 1m19s
renovate / renovate (push) Successful in 33s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-27 00:13:13 +05:00
613b28a751 chore(localization): added translate support to theme name, description and screenshots
All checks were successful
Code check / Check code (push) Successful in 1m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-26 13:02:45 +05:00
a9e9f4e4e3 get_other_wine: added initial
All checks were successful
Code check / Check code (push) Successful in 1m22s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-26 00:23:07 +05:00
61c59814a5 feat(security): strengthen theme security against multiple attack vectors
All checks were successful
Code check / Check code (push) Successful in 1m6s
- Detect dangerous modules, functions, attributes, and system/network operations
- Prevent code execution via dynamic imports, reflection, and importlib
- Block f-string injection and dangerous expressions
- Detect obfuscated code patterns, including string concatenation (im+port, ev+al),
  Base64-encoded payloads, and character code arrays
- Validate image files using extension checks, magic bytes, and size limits
- Implement AST-based analysis for deep code inspection

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-25 16:02:34 +05:00
80d3b69311 chore(themes): reorgonize it to submodules
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-25 12:30:27 +05:00
ac09ac1e36 fix: handle None steam data in egs_api callbacks
All checks were successful
Code check / Check code (push) Successful in 1m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-23 00:27:18 +05:00
7cdc7264cd chore(steam_api): returned partially search oops
Some checks failed
Code check / Check code (push) Failing after 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-23 00:22:27 +05:00
94f61b1124 perf: optimize Steam and anti-cheat metadata caching
Some checks failed
Code check / Check code (push) Failing after 1m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-23 00:15:45 +05:00
58bbff8e69 chore: clean all vulture 80% confidence dead code
All checks were successful
Code check / Check code (push) Successful in 1m45s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-21 19:34:32 +05:00
Renovate Bot
6457084d56 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to e09f710
All checks were successful
Code check / Check code (pull_request) Successful in 1m9s
Code check / Check code (push) Successful in 1m7s
2025-12-21 10:19:52 +00:00
Renovate Bot
3c83a90721 fix(deps): lock file maintenance python dependencies
All checks were successful
Code check / Check code (pull_request) Successful in 1m2s
Code check / Check code (push) Successful in 1m10s
2025-12-21 04:54:36 +00:00
Renovate Bot
c76b80586a chore(deps): update archlinux:base-devel docker digest to 9414f5b
All checks were successful
Code check / Check code (pull_request) Successful in 1m16s
Code check / Check code (push) Successful in 1m8s
2025-12-21 00:00:43 +00:00
b30ade6e1e fix(tests): fix ruff and pyright
All checks were successful
Code check / Check code (push) Successful in 1m35s
renovate / renovate (push) Successful in 39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-20 15:42:18 +05:00
7a5b467490 feat(autoinstalls): added detail page
Some checks failed
Code check / Check code (push) Failing after 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-19 16:28:50 +05:00
6f82068864 chore: bump to 0.1.9
All checks were successful
Code check / Check code (push) Successful in 1m14s
renovate / renovate (push) Successful in 37s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-08 11:47:25 +05:00
d4672ecb0e chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-08 11:47:19 +05:00
Renovate Bot
087ac8eda2 chore(deps): update https://gitea.com/actions/setup-node digest to 395ad32
All checks were successful
Code check / Check code (push) Successful in 1m22s
2025-12-07 10:48:27 +00:00
Renovate Bot
0a9acaf5da chore(deps): update https://gitea.com/actions/checkout digest to 8e8c483
Some checks failed
Code check / Check code (push) Has been cancelled
2025-12-07 10:48:16 +00:00
d0fad6a3c9 fix: added correct parent to GameCard
All checks were successful
Code check / Check code (push) Successful in 1m30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-07 15:46:27 +05:00
468887110c fix(qt): prevent RuntimeError from accessing deleted Qt C++ objects
All checks were successful
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-07 12:45:37 +05:00
32e4950a00 Revert "chore: bump ver to 0.1.9"
All checks were successful
Code check / Check code (push) Successful in 1m5s
renovate / renovate (push) Successful in 44s
This reverts commit 29d25cec01.
2025-12-06 14:26:04 +05:00
b16074fa5c fix: Add protection against accessing deleted Qt objects in async callbacks
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-06 14:22:41 +05:00
1bd7c23419 fix(settings): Remove surrounding quotes from the value if present
All checks were successful
Code check / Check code (push) Successful in 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-04 11:53:54 +05:00
f4275dd465 fix(get_portproton_start_command): Check if flatpak command exists before trying to run it
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-02 18:44:47 +05:00
c8b91c4687 fix(settings): update keyboard navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-02 18:40:27 +05:00
4aaeb2e809 fix: dont start game by Enter
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-02 18:23:49 +05:00
b6ea9350fa fix: fix gamecard refrefresh regression after 0889aa8
All checks were successful
Code check / Check code (push) Successful in 1m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-02 17:52:19 +05:00
29d25cec01 chore: bump ver to 0.1.9
All checks were successful
Code check / Check code (push) Successful in 1m13s
Build AppImage, Arch and Fedora Packages / Build AppImage (push) Successful in 2m50s
Build AppImage, Arch and Fedora Packages / Build Arch Package (push) Successful in 1m42s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (41) (push) Successful in 59s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (42) (push) Successful in 1m1s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (43) (push) Successful in 54s
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) Successful in 35s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 20:29:45 +05:00
a634de5462 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 20:27:12 +05:00
1ba1781994 feat(settings): added preloader because flatpak is too slow
All checks were successful
Code check / Check code (push) Successful in 1m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 17:06:20 +05:00
0aae292f61 fix(settings): fix work on Flatpak
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 16:59:43 +05:00
3ef433af0c fix: Only handle menu button if our main window is currently active
All checks were successful
Code check / Check code (push) Successful in 1m52s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 12:08:55 +05:00
Gitea Actions
9fe33e02d8 chore: update steam apps list 2025-12-01T00:01:44Z 2025-12-01 00:01:44 +00:00
2ac91a759d chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m5s
Fetch Data / build (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-30 13:20:33 +05:00
2c82bff204 fix(main_window): remove redundant loading status and improve loading flow
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-30 13:14:38 +05:00
0889aa883e fix: refresh button refresh custom data too now
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-30 12:59:32 +05:00
7780dcfc4d chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m10s
renovate / renovate (push) Successful in 38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-29 23:12:31 +05:00
9ef39ae2b6 fix: save cover images from URL to custom_data folder
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-29 23:08:54 +05:00
86fb2b2d7c chore: added refresh hint
All checks were successful
Code check / Check code (push) Successful in 1m24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-29 11:33:07 +05:00
9d469f0a12 add horizontal scroll styles for exe settings
All checks were successful
Code check / Check code (push) Successful in 1m11s
2025-11-28 13:54:25 +00:00
665a4df322 perf(search): implement full async + indexed search system with major performance gains
All checks were successful
Code check / Check code (push) Successful in 1m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-28 13:48:17 +05:00
3abaccb1e0 fix(startup): prevent main thread hangs and optimize resource loading
All checks were successful
Code check / Check code (push) Successful in 1m33s
- run start_sh initialization via QTimer.singleShot with timeout
- add timeout protection to load_theme_fonts()
- load Steam/EGS/PortProton games in parallel instead of sequential
- delay game loading until UI is fully initialized
- fix callback chaining to avoid blocking operations
- add proper timeout + error handling for all Steam/EGS network requests
- add timeouts for flatpak subprocess calls
- improve file I/O error handling to avoid UI freeze
- optimize theme font loading:
  - delay font loading via QTimer.singleShot
  - load fonts in batches of 10
  - reduce font load timeout to 3s
  - remove fonts only when switching themes

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-28 12:00:00 +05:00
77b025f580 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-27 16:03:43 +05:00
Renovate Bot
42e2025e54 fix(deps): lock file maintenance python dependencies
All checks were successful
Code check / Check code (pull_request) Successful in 1m13s
Code check / Check code (push) Successful in 1m24s
2025-11-27 10:58:16 +00:00
Renovate Bot
8f84bbce31 chore(deps): update https://gitea.com/actions/setup-python digest to 83679a8
Some checks failed
Code check / Check code (push) Has been cancelled
Code check / Check code (pull_request) Has been cancelled
2025-11-27 10:55:42 +00:00
3026e7da4e fix: fix code work with pyside 6.10
Some checks failed
Code check / Check code (push) Has been cancelled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-27 15:54:08 +05:00
3522764c3e fix(detail-page): prevent crash on exit by adding robust widget/animation safety checks
All checks were successful
Code check / Check code (push) Successful in 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 21:43:18 +05:00
fd456e5330 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 18:02:48 +05:00
99a963d60c chore: drop all pyright ignore
All checks were successful
Code check / Check code (push) Successful in 1m22s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 17:34:24 +05:00
0b36e73bce chore(build): fix build on arch
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 16:59:04 +05:00
4baa2e8684 chore(themes): delete unused fonts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 16:57:56 +05:00
4344bbca70 feat: added combination for Update Grid
All checks were successful
Code check / Check code (push) Successful in 1m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 14:54:15 +05:00
0a8a290d2d chore: ignore pyright
All checks were successful
Code check / Check code (push) Successful in 1m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-25 20:49:58 +05:00
92652e8faa fix(mouse_emulations): ignore triggers
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-25 20:32:44 +05:00
4f2afaed24 fix: use kernel for detect_gamepad_axes
Some checks failed
Code check / Check code (push) Failing after 1m24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-25 14:39:52 +05:00
1751e01e47 feat: added setfocus to gamedetail page
All checks were successful
Code check / Check code (push) Successful in 1m23s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-25 10:22:27 +05:00
0f74a47aed chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 23:49:29 +05:00
666ec654a0 fix(ui): prevent text truncation in show_gamepad_tooltip
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 23:34:57 +05:00
0c25cc9fd2 chore(settings): rework tabble
All checks were successful
Code check / Check code (push) Successful in 1m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 23:23:56 +05:00
5de83dbf49 fix(settings): drop .ppdb from show-ppdb
All checks were successful
Code check / Check code (push) Successful in 1m22s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 23:03:31 +05:00
1821faadf6 styles for virtual keyboard
All checks were successful
Code check / Check code (push) Successful in 2m56s
2025-11-24 16:47:41 +00:00
17f0a6b0ea fix(ui): prevent segfault by validating widget existence in async callbacks
All checks were successful
Code check / Check code (push) Successful in 1m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 16:27:02 +05:00
84 changed files with 31054 additions and 5518 deletions

View File

@@ -11,40 +11,34 @@ jobs:
build-appimage: build-appimage:
name: Build AppImage name: Build AppImage
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container:
image: archlinux:base-devel@sha256:f6b259c6c0cd1bc4c86510485acb6a5f053c15789c9c68c7434b6fe99564906c
options: --privileged --device /dev/fuse
steps: steps:
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - name: Prepare container
- name: Install required dependencies
run: | run: |
sudo apt update pacman-key --init
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
- name: Upgrade pip toolchain - uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
run: |
python3 -m pip install --upgrade \
pip setuptools setuptools-scm wheel packaging build
- name: Install appimage-builder - name: Install appimage dependencies
run: | run: |
git clone https://github.com/Boria138/appimage-builder cd build-aux/AppImage
cd appimage-builder chmod +x get-dependencies.sh portprotonqt-appimage.sh
pip install . ./get-dependencies.sh --git
- name: Install uv
run: |
pip install uv
- name: Build AppImage - name: Build AppImage
run: | run: |
cd build-aux cd build-aux/AppImage
sed -i '/app_info:/,/- exec:/ s/^\(\s*version:\s*\).*/\1"0"/' AppImageBuilder.yml ./portprotonqt-appimage.sh
appimage-builder
- name: Upload AppImage - name: Upload AppImage
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4
with: with:
name: PortProtonQt-AppImage name: PortProtonQt-AppImage
path: build-aux/PortProtonQt*.AppImage path: build-aux/AppImage/dist/*.AppImage
build-fedora: build-fedora:
name: Build Fedora RPM name: Build Fedora RPM
@@ -73,7 +67,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo - name: Checkout repo
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Copy fedora.spec - name: Copy fedora.spec
run: | run: |
@@ -94,16 +88,12 @@ jobs:
name: Build Arch Package name: Build Arch Package
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: container:
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e image: archlinux:base-devel@sha256:f6b259c6c0cd1bc4c86510485acb6a5f053c15789c9c68c7434b6fe99564906c
volumes:
- /usr:/usr-host
- /opt:/opt-host
options: --privileged
steps: steps:
- name: Prepare container - name: Prepare container
run: | run: |
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
yes | pacman -Scc yes | pacman -Scc
@@ -134,7 +124,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Upload Arch package - name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4

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.8 VERSION: 0.1.9
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -17,54 +17,45 @@ jobs:
build-appimage: build-appimage:
name: Build AppImage name: Build AppImage
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container:
image: archlinux:base-devel
options: --privileged --device /dev/fuse
steps: steps:
- name: Prepare container
run: |
pacman-key --init
pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
- name: Install required dependencies - name: Install appimage dependencies
run: | run: |
sudo apt update cd build-aux/AppImage
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme chmod +x get-dependencies.sh portprotonqt-appimage.sh
./get-dependencies.sh
- name: Upgrade pip toolchain
run: |
python3 -m pip install --upgrade \
pip setuptools setuptools-scm wheel packaging build
- name: Install appimage-builder
run: |
git clone https://github.com/Boria138/appimage-builder
cd appimage-builder
pip install .
- name: Install uv
run: |
pip install uv
- name: Build AppImage - name: Build AppImage
run: | run: |
cd build-aux cd build-aux/AppImage
appimage-builder ./portprotonqt-appimage.sh
- name: Upload AppImage - name: Upload AppImage
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4
with: with:
name: PortProtonQt-AppImage name: PortProtonQt-AppImage
path: build-aux/PortProtonQt*.AppImage* path: build-aux/AppImage/dist/*.AppImage
build-arch: build-arch:
name: Build Arch Package name: Build Arch Package
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: container:
image: archlinux:base-devel image: archlinux:base-devel
volumes:
- /usr:/usr-host
- /opt:/opt-host
options: --privileged
steps: steps:
- name: Prepare container - name: Prepare container
run: | run: |
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
yes | pacman -Scc yes | pacman -Scc

View File

@@ -12,14 +12,13 @@ on:
jobs: jobs:
check-translations: check-translations:
if: false
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Python - name: Set up Python
uses: https://gitea.com/actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6 uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -37,7 +37,7 @@ jobs:
cat changed_files.txt cat changed_files.txt
# Check AppImage files # Check AppImage files
if grep -q "build-aux/AppImageBuilder.yml" changed_files.txt; then if grep -q "build-aux/AppImage/" changed_files.txt; then
echo "appimage=true" >> $GITHUB_OUTPUT echo "appimage=true" >> $GITHUB_OUTPUT
else else
echo "appimage=false" >> $GITHUB_OUTPUT echo "appimage=false" >> $GITHUB_OUTPUT
@@ -62,29 +62,34 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: changes needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch' if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:f6b259c6c0cd1bc4c86510485acb6a5f053c15789c9c68c7434b6fe99564906c
options: --privileged --device /dev/fuse
steps: steps:
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - name: Prepare container
- name: Install required dependencies
run: | run: |
sudo apt update pacman-key --init
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync zstd git pacman -Sy --noconfirm --disable-download-timeout --needed archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
- name: Install tools - uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Install appimage dependencies
run: | run: |
pip3 install git+https://github.com/Boria138/appimage-builder.git cd build-aux/AppImage
pip3 install uv chmod +x get-dependencies.sh portprotonqt-appimage.sh
./get-dependencies.sh
- name: Build AppImage - name: Build AppImage
run: | run: |
cd build-aux cd build-aux/AppImage
appimage-builder ./portprotonqt-appimage.sh
- name: Upload AppImage - name: Upload AppImage
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4
with: with:
name: PortProtonQt-AppImage name: PortProtonQt-AppImage
path: build-aux/PortProtonQt*.AppImage path: build-aux/AppImage/dist/*.AppImage
build-fedora: build-fedora:
name: Build Fedora RPM name: Build Fedora RPM
@@ -115,7 +120,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo - name: Checkout repo
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Copy fedora-git.spec - name: Copy fedora-git.spec
run: | run: |
@@ -138,11 +143,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container: container:
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e image: archlinux:base-devel@sha256:f6b259c6c0cd1bc4c86510485acb6a5f053c15789c9c68c7434b6fe99564906c
volumes:
- /usr:/usr-host
- /opt:/opt-host
options: --privileged
steps: steps:
- name: Prepare container - name: Prepare container
@@ -178,7 +179,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Upload Arch package - name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4

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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Node.js - name: Set up Node.js
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 uses: https://gitea.com/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Python - name: Set up Python
uses: https://gitea.com/actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6 uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
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@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6 container: ghcr.io/renovatebot/renovate:latest@sha256:eec497df1ca6ebe8bccf577c5dab8825ab5f3673a42a58f066e31dbf070664e6
steps: steps:
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Node.js - name: Set up Node.js
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 uses: https://gitea.com/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with: with:
node-version: 20 node-version: 20

View File

@@ -3,6 +3,38 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [0.1.9] - 2025-12-08
### Added
- Добавлены основные и расширенные настройки для `.exe`-файлов
- Добавлена кнопка обновления сетки без необходимости перезапуска PortProtonQt (F5 на клавиатуре, GUIDE + Select на геймпаде)
- Добавлена эмуляция мыши по GUIDE (Xbox или PS) + Start для установки приложений или взаимодействия с инструментами Wine не адаптированные под геймпад (работает только если PortProtonQt вне фокуса)
- При сворачивании приложения в трей оно теперь корректно восстанавливается, вместо запуска нового экземпляра
- Добавлена поддержка SteamGridDB в качестве дополнительного источника обложек
- При добавлении карточки в избранное она автоматически становится первой без необходимости перезапуска
### Changed
- Изменено оформление виртуальной клавиатуры для лучшего соответствия общей теме
- Ускорено чтение конфигов за счёт уменьшения количества обращений к файловой системе.
- Из стандартной темы удалены неиспользуемые шрифты
- Улучшена совместимость с Qt 6.10
- Ускорен запуск программы
- В диалог редактирования ярылыка добавлен placeholder с уточнением того что в качевстве обложки можно использовать и ссылку, а не только файл
- Ссылку на обложку в диалоге редактирования ярлыка теперь можно указывать без протокола вроде http или https
### Fixed
- Добавлено больше проверок на None для избежания вылетов
- Улучшена работа с потоками для избежания вылетов
- Исправлен запуск PortProton из Flatpak: теперь используется `flatpak run`, а не `start.sh`
- Исправлено применение обложки по ссылке например со steamgriddb.com/
- Исправлено множественное открытие окон в X11
### Contributors
- @Vector_null
- @Dervart
---
## [0.1.8] - 2025-10-18 ## [0.1.8] - 2025-10-18
### Added ### Added

View File

@@ -0,0 +1,55 @@
#!/bin/sh
set -eu
# Determine if git mode is enabled based on the first argument
if [ "${1:-}" = "--git" ] || [ "${1:-}" = "-g" ]; then
GIT_MODE=true
else
GIT_MODE=false
fi
ARCH="$(uname -m)"
PACKAGE_BUILDER="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/make-aur-package.sh"
EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/get-debloated-pkgs.sh"
if [ "$GIT_MODE" = true ]; then
echo "Using git version of PortProtonQt..."
PPQT_PKGBUILD="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/build-aux/PKGBUILD-git"
else
echo "Using stable version of PortProtonQt..."
PPQT_PKGBUILD="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/build-aux/PKGBUILD"
fi
echo "Installing dependencies..."
echo "---------------------------------------------------------------"
pacman-key --init
pacman -Syy --needed --noconfirm archlinux-keyring qt6-wayland
echo "Installing debloated packages..."
echo "---------------------------------------------------------------"
wget --retry-connrefused --tries=30 "$EXTRA_PACKAGES" -O ./get-debloated-pkgs.sh
chmod +x ./get-debloated-pkgs.sh
./get-debloated-pkgs.sh --add-common --prefer-nano
echo "Installing AUR packages..."
echo "---------------------------------------------------------------"
wget --retry-connrefused --tries=30 "$PACKAGE_BUILDER" -O ./make-aur-package.sh
chmod +x ./make-aur-package.sh
./make-aur-package.sh --chaotic-aur icoextract
./make-aur-package.sh --chaotic-aur python-vdf
echo "Building PortProtonQt from PKGBUILD..."
echo "---------------------------------------------------------------"
wget --retry-connrefused --tries=30 "$PPQT_PKGBUILD" -O ./PKGBUILD
makepkg -si --noconfirm
if [ "$GIT_MODE" = true ]; then
# For git version, we use portprotonqt-git
pacman -Q portprotonqt-git | awk '{print $2}' | cut -d- -f1 > ~/version
else
# For stable version, we use portprotonqt
pacman -Q portprotonqt | awk '{print $2}' | cut -d- -f1 > ~/version
fi

View File

@@ -0,0 +1,42 @@
#!/bin/sh
set -eu
SHARUN="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/quick-sharun.sh"
ARCH="$(uname -m)"
VERSION="$(cat ~/version)"
export ARCH VERSION
export OUTPATH=./dist
export DESKTOP=/usr/share/applications/ru.linux_gaming.PortProtonQt.desktop
export ICON=/usr/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
export OUTNAME=PortProtonQt-"$VERSION"-anylinux-"$ARCH".AppImage
export DEPLOY_OPENGL=1
export DEPLOY_SYS_PYTHON=1
export OPTIMIZE_LAUNCH=1
# Adjust comp settings to bypass oom-killer
export DWARFS_COMP="zstd:level=15 -S22 -B5"
# DEPLOY ALL LIBS
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
chmod +x ./quick-sharun
# Add udev rules
mkdir -p ./AppDir/etc/udev/rules.d
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
# Deploy Qt translations
mkdir -p ./AppDir/usr/share/qt6/translations
cp -r /usr/share/qt6/translations/* ./AppDir/usr/share/qt6/translations/
# Deploy dependencies
# Qt libs have to be passed manually due to the app being a python script
./quick-sharun \
/usr/bin/portprotonqt* \
/usr/lib/libQt6Core.so* \
/usr/lib/libQt6Gui.so* \
/usr/lib/libQt6Network.so* \
/usr/lib/libudev.so*
# Turn AppDir into AppImage
./quick-sharun --make-appimage

View File

@@ -1,80 +0,0 @@
version: 1
script:
- rm -rf AppDir || true
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
- uv venv
- uv pip install --no-cache-dir ../
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr
- cp -r lib AppDir/usr
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
- shopt -s extglob
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Network*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
AppDir:
path: ./AppDir
after_bundle:
- rm -rf $TARGET_APPDIR/usr/share/man || true
- rm -rf $TARGET_APPDIR/usr/share/doc || true
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true
- rm -rf $TARGET_APPDIR/usr/share/info || true
- rm -rf $TARGET_APPDIR/usr/share/help || true
- rm -rf $TARGET_APPDIR/usr/share/gtk-doc || true
- rm -rf $TARGET_APPDIR/usr/share/devhelp || true
- rm -rf $TARGET_APPDIR/usr/share/examples || true
- rm -rf $TARGET_APPDIR/usr/share/pkgconfig || true
- rm -rf $TARGET_APPDIR/usr/share/bash-completion || true
- rm -rf $TARGET_APPDIR/usr/share/pixmaps || true
- rm -rf $TARGET_APPDIR/usr/share/mime || true
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true
- rm -rf $TARGET_APPDIR/usr/include || true
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
- find $TARGET_APPDIR -type d -empty -delete || true
app_info:
id: ru.linux_gaming.PortProtonQt
name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt
version: 0.1.8
exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@"
apt:
arch: amd64
sources:
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
include:
- python3-minimal
- python3-pkg-resources
- libopengl0
- libk5crypto3
- libkrb5-3
- libgssapi-krb5-2
- libxcb-cursor0
- libimage-exiftool-perl
- xdg-utils
- cabextract
- curl
- 7zip
- unzip
- unrar
exclude:
- "*-doc"
- "*-man"
- manpages
- mandb
- "*-dev"
- "*-static"
- "*-dbg"
- "*-dbgsym"
runtime:
env:
PYTHONHOME: '${APPDIR}/usr'
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
AppImage:
sign-key: None
arch: x86_64
comp: zstd

View File

@@ -1,12 +1,12 @@
pkgname=portprotonqt pkgname=portprotonqt
pkgver=0.1.8 pkgver=0.1.9
pkgrel=1 pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any') arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt" url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0') license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
makedepends=('python-'{'build','installer','setuptools','wheel'}) makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver") source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP') sha256sums=('SKIP')

View File

@@ -6,7 +6,7 @@ arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt" url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0') license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'icoextract' 'python-pillow' 'python-rapidfuzz' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
makedepends=('python-'{'build','installer','setuptools','wheel'}) makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git") source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
sha256sums=('SKIP') sha256sums=('SKIP')

View File

@@ -44,9 +44,11 @@ Requires: python3-tqdm
Requires: python3-vdf Requires: python3-vdf
Requires: python3-pefile Requires: python3-pefile
Requires: python3-pillow Requires: python3-pillow
Requires: python3-beautifulsoup4
Requires: python3-rapidfuzz
Requires: python3-libarchive-c
Requires: perl-Image-ExifTool Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
Requires: python3-beautifulsoup4
Requires: cabextract Requires: cabextract
Requires: gzip Requires: gzip
Requires: unzip Requires: unzip

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.8 %global pypi_version 0.1.9
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1
@@ -41,9 +41,11 @@ Requires: python3-tqdm
Requires: python3-vdf Requires: python3-vdf
Requires: python3-pefile Requires: python3-pefile
Requires: python3-pillow Requires: python3-pillow
Requires: python3-beautifulsoup4
Requires: python3-rapidfuzz
Requires: python3-libarchive-c
Requires: perl-Image-ExifTool Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
Requires: python3-beautifulsoup4
Requires: cabextract Requires: cabextract
Requires: gzip Requires: gzip
Requires: unzip Requires: unzip

View File

@@ -1373,7 +1373,7 @@
}, },
{ {
"normalized_name": "arena breakout infinite", "normalized_name": "arena breakout infinite",
"status": "Broken" "status": "Denied"
}, },
{ {
"normalized_name": "pixel gun 3d pc", "normalized_name": "pixel gun 3d pc",
@@ -2097,7 +2097,7 @@
}, },
{ {
"normalized_name": "breachers", "normalized_name": "breachers",
"status": "Running" "status": "Denied"
}, },
{ {
"normalized_name": "line of sight", "normalized_name": "line of sight",
@@ -2153,7 +2153,7 @@
}, },
{ {
"normalized_name": "ghosts of tabor", "normalized_name": "ghosts of tabor",
"status": "Broken" "status": "Denied"
}, },
{ {
"normalized_name": "undawn", "normalized_name": "undawn",
@@ -3801,7 +3801,7 @@
}, },
{ {
"normalized_name": "phantasy star online 2 new genesis", "normalized_name": "phantasy star online 2 new genesis",
"status": "Broken" "status": "Running"
}, },
{ {
"normalized_name": "fortress forever", "normalized_name": "fortress forever",
@@ -4316,7 +4316,7 @@
"status": "Broken" "status": "Broken"
}, },
{ {
"normalized_name": "solo leveling arise", "normalized_name": "solo leveling arise overdrive",
"status": "Running" "status": "Running"
}, },
{ {
@@ -4425,7 +4425,7 @@
}, },
{ {
"normalized_name": "carx street", "normalized_name": "carx street",
"status": "Broken" "status": "Running"
}, },
{ {
"normalized_name": "warcos 2", "normalized_name": "warcos 2",
@@ -4505,7 +4505,7 @@
}, },
{ {
"normalized_name": "redmatch 2", "normalized_name": "redmatch 2",
"status": "Broken" "status": "Running"
}, },
{ {
"normalized_name": "blade & soul heroes", "normalized_name": "blade & soul heroes",
@@ -4527,10 +4527,6 @@
"normalized_name": "project wraith", "normalized_name": "project wraith",
"status": "Broken" "status": "Broken"
}, },
{
"normalized_name": "solo leveling arise",
"status": "Broken"
},
{ {
"normalized_name": "freedom wars", "normalized_name": "freedom wars",
"status": "Running" "status": "Running"
@@ -4542,5 +4538,129 @@
{ {
"normalized_name": "no more room in hell 2", "normalized_name": "no more room in hell 2",
"status": "Running" "status": "Running"
},
{
"normalized_name": "call of duty black ops 7",
"status": "Denied"
},
{
"normalized_name": "skate.",
"status": "Denied"
},
{
"normalized_name": "wildgate",
"status": "Running"
},
{
"normalized_name": "fellowship",
"status": "Running"
},
{
"normalized_name": "dragon ball xenoverse",
"status": "Running"
},
{
"normalized_name": "king of meat",
"status": "Running"
},
{
"normalized_name": "last flag",
"status": "Broken"
},
{
"normalized_name": "skidrush",
"status": "Broken"
},
{
"normalized_name": "nosgoth",
"status": "Running"
},
{
"normalized_name": "counter strike online 2",
"status": "Broken"
},
{
"normalized_name": "game of thrones kingsroad",
"status": "Running"
},
{
"normalized_name": "vindictus defying fate",
"status": "Broken"
},
{
"normalized_name": "gears of war reloaded",
"status": "Running"
},
{
"normalized_name": "swords & soldiers",
"status": "Running"
},
{
"normalized_name": "super people (2025)",
"status": "Broken"
},
{
"normalized_name": "afk journey",
"status": "Running"
},
{
"normalized_name": "the midnight walkers",
"status": "Broken"
},
{
"normalized_name": "異世界∞異世界 ~次はどの作品を、集めよう~",
"status": "Broken"
},
{
"normalized_name": "chrono odyssey",
"status": "Running"
},
{
"normalized_name": "madoka magica magia exedra",
"status": "Broken"
},
{
"normalized_name": "pubg black budget",
"status": "Broken"
},
{
"normalized_name": "sniper elite resistance",
"status": "Running"
},
{
"normalized_name": "gigantic",
"status": "Broken"
},
{
"normalized_name": "team fortress 2 classified",
"status": "Running"
},
{
"normalized_name": "panzer arena coop",
"status": "Broken"
},
{
"normalized_name": "girls' frontline",
"status": "Running"
},
{
"normalized_name": "battlefield redsec",
"status": "Denied"
},
{
"normalized_name": "evolve stage 2",
"status": "Running"
},
{
"normalized_name": "aura kingdom impact",
"status": "Running"
},
{
"normalized_name": "risk your life",
"status": "Broken"
},
{
"normalized_name": "forefront",
"status": "Denied"
} }
] ]

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,256 @@
[ [
{
"normalized_title": "back to the future the game",
"slug": "back-to-the-future-the-game"
},
{
"normalized_title": "resident evil revelations 2",
"slug": "resident-evil-revelations-2"
},
{
"normalized_title": "hi fi rush",
"slug": "hi-fi-rush"
},
{
"normalized_title": "medal of honor warfighter",
"slug": "medal-of-honor-warfighter"
},
{
"normalized_title": "medal of honor",
"slug": "medal-of-honor"
},
{
"normalized_title": "will rock",
"slug": "will-rock"
},
{
"normalized_title": "beyond good & evil",
"slug": "beyond-good-evil"
},
{
"normalized_title": "industry giant 2",
"slug": "industry-giant-2"
},
{
"normalized_title": "rise of the tomb raider 20 year celebration",
"slug": "rise-of-the-tomb-raider-20-year-celebration"
},
{
"normalized_title": "need for speed underground",
"slug": "need-for-speed-underground"
},
{
"normalized_title": "deus ex 2 invisible war",
"slug": "deus-ex-2-invisible-war"
},
{
"normalized_title": "lords of the fallen game of the year 2014",
"slug": "lords-of-the-fallen-game-of-the-year-edition-2014"
},
{
"normalized_title": "crysis 3",
"slug": "crysis-3"
},
{
"normalized_title": "south park the fractured but whole",
"slug": "south-park-the-fractured-but-whole"
},
{
"normalized_title": "mount & blade ii bannerlord",
"slug": "mount-blade-ii-bannerlord"
},
{
"normalized_title": "need for speed rivals",
"slug": "need-for-speed-rivals"
},
{
"normalized_title": "just cause 3",
"slug": "just-cause-3"
},
{
"normalized_title": "warhammer 40 000 boltgun",
"slug": "warhammer-40-000-boltgun"
},
{
"normalized_title": "metal eden",
"slug": "metal-eden"
},
{
"normalized_title": "dead cells",
"slug": "dead-cells"
},
{
"normalized_title": "teardown",
"slug": "teardown"
},
{
"normalized_title": "hell is us",
"slug": "hell-is-us"
},
{
"normalized_title": "alien breed impact",
"slug": "alien-breed-impact"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "myst",
"slug": "myst"
},
{
"normalized_title": "warhammer 40 000 dawn of war",
"slug": "warhammer-40-000-dawn-of-war-definitive-edition"
},
{
"normalized_title": "lego star wars iii the clone wars",
"slug": "lego-star-wars-iii-the-clone-wars"
},
{
"normalized_title": "battlefield 4",
"slug": "battlefield-4"
},
{
"normalized_title": "bulletstorm full clip",
"slug": "bulletstorm-full-clip-edition"
},
{
"normalized_title": "call of duty black ops ii",
"slug": "call-of-duty-black-ops-ii"
},
{
"normalized_title": "battlefield 3",
"slug": "battlefield-3"
},
{
"normalized_title": "call of duty modern warfare 3 (2011)",
"slug": "call-of-duty-modern-warfare-3-2011"
},
{
"normalized_title": "metal gear solid v the phantom pain",
"slug": "metal-gear-solid-v-the-phantom-pain"
},
{
"normalized_title": "battlefield bad company 2",
"slug": "battlefield-bad-company-2"
},
{
"normalized_title": "call of duty black ops",
"slug": "call-of-duty-black-ops"
},
{
"normalized_title": "call of duty modern warfare 2 (2009)",
"slug": "call-of-duty-modern-warfare-2-2009"
},
{
"normalized_title": "call of duty black ops cold war",
"slug": "call-of-duty-black-ops-cold-war"
},
{
"normalized_title": "call of duty infinite warfare",
"slug": "call-of-duty-infinite-warfare"
},
{
"normalized_title": "lost planet 2",
"slug": "lost-planet-2"
},
{
"normalized_title": "lost planet extreme condition colonies",
"slug": "lost-planet-extreme-condition-colonies-edition"
},
{
"normalized_title": "starcraft",
"slug": "starcraft-remastered"
},
{
"normalized_title": "the entropy centre",
"slug": "the-entropy-centre"
},
{
"normalized_title": "metal gear solid v ground zeroes",
"slug": "metal-gear-solid-v-ground-zeroes"
},
{
"normalized_title": "escape from tarkov",
"slug": "escape-from-tarkov"
},
{
"normalized_title": "command & conquer generals",
"slug": "command-conquer-generals"
},
{
"normalized_title": "command & conquer generals zero hour",
"slug": "command-conquer-generals-zero-hour"
},
{
"normalized_title": "absolum",
"slug": "absolum"
},
{
"normalized_title": "tom clancy's splinter cell chaos theory",
"slug": "tom-clancys-splinter-cell-chaos-theory"
},
{
"normalized_title": "winter burrow",
"slug": "winter-burrow"
},
{
"normalized_title": "forager",
"slug": "forager"
},
{
"normalized_title": "wall world",
"slug": "wall-world"
},
{
"normalized_title": "grand theft auto iv the",
"slug": "grand-theft-auto-iv-the-complete-edition"
},
{
"normalized_title": "voidtrain",
"slug": "voidtrain"
},
{
"normalized_title": "jdm japanese drift master",
"slug": "jdm-japanese-drift-master"
},
{
"normalized_title": "lego harry potter collection",
"slug": "lego-harry-potter-collection"
},
{
"normalized_title": "life is strange season",
"slug": "life-is-strange-complete-season"
},
{
"normalized_title": "земский собор [демо]",
"slug": "zemskij-sobor-demo"
},
{
"normalized_title": "syberia",
"slug": "syberia-remastered"
},
{
"normalized_title": "europa universalis v",
"slug": "europa-universalis-v"
},
{
"normalized_title": "no i'm not a human",
"slug": "no-im-not-a-human"
},
{
"normalized_title": "dispatch digital deluxe",
"slug": "dispatch-digital-deluxe-edition"
},
{
"normalized_title": "cossacks 3 digital deluxe",
"slug": "cossacks-3-digital-deluxe"
},
{
"normalized_title": "battlefield 2",
"slug": "battlefield-2"
},
{ {
"normalized_title": "split/second", "normalized_title": "split/second",
"slug": "split-second" "slug": "split-second"
@@ -11,10 +263,6 @@
"normalized_title": "foundation", "normalized_title": "foundation",
"slug": "foundation" "slug": "foundation"
}, },
{
"normalized_title": "земский собор [демо]",
"slug": "zemskij-sobor-demo"
},
{ {
"normalized_title": "crusader kings 3", "normalized_title": "crusader kings 3",
"slug": "crusader-kings-3" "slug": "crusader-kings-3"
@@ -143,14 +391,6 @@
"normalized_title": "far cry 5", "normalized_title": "far cry 5",
"slug": "far-cry-5" "slug": "far-cry-5"
}, },
{
"normalized_title": "metal eden",
"slug": "metal-eden"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{ {
"normalized_title": "old world", "normalized_title": "old world",
"slug": "old-world" "slug": "old-world"
@@ -1159,10 +1399,6 @@
"normalized_title": "mafia", "normalized_title": "mafia",
"slug": "mafia-definitive-edition" "slug": "mafia-definitive-edition"
}, },
{
"normalized_title": "teardown",
"slug": "teardown"
},
{ {
"normalized_title": "spellforce conquest of eo", "normalized_title": "spellforce conquest of eo",
"slug": "spellforce-conquest-of-eo" "slug": "spellforce-conquest-of-eo"
@@ -1411,10 +1647,6 @@
"normalized_title": "world of sea battle", "normalized_title": "world of sea battle",
"slug": "world-of-sea-battle" "slug": "world-of-sea-battle"
}, },
{
"normalized_title": "escape from tarkov",
"slug": "escape-from-tarkov"
},
{ {
"normalized_title": "bayonetta", "normalized_title": "bayonetta",
"slug": "bayonetta" "slug": "bayonetta"
@@ -1539,10 +1771,6 @@
"normalized_title": "call of duty 2", "normalized_title": "call of duty 2",
"slug": "call-of-duty-2" "slug": "call-of-duty-2"
}, },
{
"normalized_title": "call of duty infinite warfare",
"slug": "call-of-duty-infinite-warfare"
},
{ {
"normalized_title": "call of duty world at war", "normalized_title": "call of duty world at war",
"slug": "call-of-duty-world-at-war" "slug": "call-of-duty-world-at-war"
@@ -1735,10 +1963,6 @@
"normalized_title": "elden ring", "normalized_title": "elden ring",
"slug": "elden-ring" "slug": "elden-ring"
}, },
{
"normalized_title": "starcraft",
"slug": "starcraft-remastered"
},
{ {
"normalized_title": "cataclismo", "normalized_title": "cataclismo",
"slug": "cataclismo" "slug": "cataclismo"

Binary file not shown.

View File

@@ -1,474 +0,0 @@
#!/usr/bin/env python3
"""
PySide6 Dependencies Analyzer with ldd support
Анализирует зависимости PySide6 модулей используя ldd для определения
реальных зависимостей скомпилированных библиотек.
"""
import ast
import os
import sys
import subprocess
import re
from pathlib import Path
from typing import Set, Dict, List
import argparse
import json
class PySide6DependencyAnalyzer:
def __init__(self, project_root: Path = None):
# Системные библиотеки, которые нужно всегда оставлять
self.system_libs = {
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus',
'libQt6Svg'
}
self.critical_modules = {
'QtSvg',
}
self.real_dependencies = {}
self.used_modules_code = set()
self.used_modules_ldd = set()
self.all_required_modules = set()
# Определяем корень проекта
if project_root is None:
# Корень проекта - две директории выше от скрипта
self.project_root = Path(__file__).parent.parent
else:
self.project_root = project_root
self.venv_path = self.project_root / ".venv"
self.build_path = self.project_root / "build-aux"
def find_python_files(self, directory: Path) -> List[Path]:
"""Находит все Python файлы в директории"""
python_files = []
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git'}]
for file in files:
if file.endswith('.py'):
python_files.append(Path(root) / file)
return python_files
def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]:
"""Находит все PySide6 библиотеки (.so файлы)"""
libs = {}
# Ищем venv в корне проекта
venv_candidates = [
self.venv_path, # .venv
self.project_root / "venv",
self.project_root / ".virtualenv",
]
pyside6_path = None
# Пробуем найти PySide6 в venv
for venv in venv_candidates:
if venv.exists():
# Ищем Python версию
lib_path = venv / "lib"
if lib_path.exists():
for python_dir in lib_path.iterdir():
if python_dir.name.startswith('python'):
candidate = python_dir / "site-packages" / "PySide6"
if candidate.exists():
pyside6_path = candidate
print(f"Найден PySide6 в: {candidate}")
break
if pyside6_path:
break
if not pyside6_path:
print(f"Предупреждение: PySide6 не найден в venv, проверяем AppDir...")
# Если не нашли в venv, пробуем в AppDir
if base_path:
appdir_candidate = base_path / "AppDir/usr/local/lib"
if appdir_candidate.exists():
for python_dir in appdir_candidate.iterdir():
if python_dir.name.startswith('python'):
candidate = python_dir / "dist-packages" / "PySide6"
if candidate.exists():
pyside6_path = candidate
print(f"Найден PySide6 в AppDir: {candidate}")
break
if not pyside6_path:
return libs
# Ищем .so файлы модулей
for so_file in pyside6_path.glob("Qt*.*.so"):
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
if module_name.startswith('Qt'):
libs[module_name] = so_file
# Также ищем в подпапках
for subdir in pyside6_path.iterdir():
if subdir.is_dir() and subdir.name.startswith('Qt'):
for so_file in subdir.glob("*.so*"):
if 'Qt' in so_file.name:
libs[subdir.name] = so_file
break
return libs
def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]:
"""Анализирует зависимости библиотеки с помощью ldd"""
qt_deps = set()
try:
result = subprocess.run(['ldd', str(lib_path)],
capture_output=True, text=True, check=True)
# Парсим вывод ldd и ищем Qt библиотеки
for line in result.stdout.split('\n'):
# Ищем строки вида: libQt6Core.so.6 => /path/to/lib
match = re.search(r'libQt6(\w+)\.so', line)
if match:
qt_module = f"Qt{match.group(1)}"
qt_deps.add(qt_module)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}")
return qt_deps
def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]:
"""Строит граф зависимостей на основе ldd анализа"""
dependencies = {}
print("Анализ реальных зависимостей с помощью ldd...")
for module, lib_path in pyside_libs.items():
print(f" Анализируется {module}...")
deps = self.analyze_ldd_dependencies(lib_path)
dependencies[module] = deps
if deps:
print(f" Зависимости: {', '.join(sorted(deps))}")
return dependencies
def analyze_file_imports(self, file_path: Path) -> Set[str]:
"""Анализирует один Python файл и возвращает используемые PySide6 модули"""
modules = set()
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith('PySide6.'):
module = alias.name.split('.', 2)[1]
if module.startswith('Qt'):
modules.add(module)
elif isinstance(node, ast.ImportFrom):
if node.module and node.module.startswith('PySide6.'):
module = node.module.split('.', 2)[1]
if module.startswith('Qt'):
modules.add(module)
except Exception as e:
print(f"Ошибка при анализе {file_path}: {e}")
return modules
def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]:
"""Получает все зависимости для набора модулей, используя граф зависимостей из ldd"""
all_deps = set(modules)
if not dependency_graph:
return all_deps
# Повторяем до тех пор, пока не найдем все транзитивные зависимости
changed = True
iteration = 0
while changed and iteration < 10: # Защита от бесконечного цикла
changed = False
current_deps = set(all_deps)
for module in current_deps:
if module in dependency_graph:
new_deps = dependency_graph[module] - all_deps
if new_deps:
all_deps.update(new_deps)
changed = True
iteration += 1
return all_deps
def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict:
"""Анализирует весь проект"""
python_files = self.find_python_files(project_path)
print(f"Найдено {len(python_files)} Python файлов")
# Анализ статических импортов
used_modules_code = set()
file_modules = {}
for file_path in python_files:
modules = self.analyze_file_imports(file_path)
if modules:
file_modules[str(file_path.relative_to(project_path))] = list(modules)
used_modules_code.update(modules)
print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}")
# Поиск PySide6 библиотек
search_base = appdir_path if appdir_path else project_path
pyside_libs = self.find_pyside6_libs(search_base)
if not pyside_libs:
print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.")
return {
'error': 'PySide6 библиотеки не найдены',
'analysis_method': 'failed',
'found_libraries': 0,
'directly_used_code': sorted(used_modules_code),
'all_required': [],
'removable': [],
'available_modules': [],
'file_usage': file_modules
}
print(f"Найдено {len(pyside_libs)} PySide6 библиотек")
# Анализ реальных зависимостей с ldd
real_dependencies = self.build_real_dependency_graph(pyside_libs)
# Определяем модули, которые реально используются через ldd
used_modules_ldd = set()
for module in used_modules_code:
if module in real_dependencies:
used_modules_ldd.update(real_dependencies[module])
used_modules_ldd.add(module)
print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}")
# Объединяем результаты анализа кода и ldd
all_used_modules = used_modules_code | used_modules_ldd
# Получаем все необходимые модули включая зависимости
all_required = self.get_all_dependencies(all_used_modules, real_dependencies)
# Все доступные PySide6 модули
available_modules = set(pyside_libs.keys())
# Модули, которые можно удалить
removable = available_modules - all_required
return {
'analysis_method': 'ldd + static analysis',
'found_libraries': len(pyside_libs),
'directly_used_code': sorted(used_modules_code),
'directly_used_ldd': sorted(used_modules_ldd),
'all_required': sorted(all_required),
'removable': sorted(removable),
'available_modules': sorted(available_modules),
'file_usage': file_modules,
'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()},
'library_paths': {k: str(v) for k, v in pyside_libs.items()},
'analysis_summary': {
'total_modules': len(available_modules),
'required_modules': len(all_required),
'removable_modules': len(removable),
'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%"
}
}
def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str:
"""Генерирует обновленный AppImage рецепт с командами очистки"""
# Читаем существующий рецепт
try:
with open(template_path, 'r', encoding='utf-8') as f:
recipe_content = f.read()
except FileNotFoundError:
print(f"Шаблон рецепта не найден: {template_path}")
return ""
# Генерируем новые команды очистки
cleanup_lines = []
# QML удаляем только если не используется
qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'}
if qml_modules.issubset(set(removable_modules)):
cleanup_lines.append(" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/")
# Инструменты разработки (всегда удаляем)
cleanup_lines.append(" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}")
# Модули для удаления
if removable_modules:
removable_filtered = [m for m in removable_modules if m not in self.critical_modules]
if removable_filtered:
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_filtered)])
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
# Генерируем команду для удаления нативных библиотек с сохранением нужных
required_libs = set()
for module in sorted(set(self.all_required_modules)):
required_libs.add(f"libQt6{module.replace('Qt', '')}*")
# Добавляем системные библиотеки
for lib in self.system_libs:
required_libs.add(f"{lib}*")
keep_pattern = '|'.join(sorted(required_libs))
cleanup_lines.extend([
" - shopt -s extglob",
f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
])
import re
# Ищем весь блок команд очистки PySide6 (от первой rm до AppDir:)
# Паттерн: после " - cp -r lib AppDir/usr\n" идут команды rm, а затем "AppDir:"
pattern = r'( - cp -r lib AppDir/usr\n)((?: - (?:rm|shopt).*\n)*?)(?=AppDir:)'
match = re.search(pattern, recipe_content)
if not match:
print("ПРЕДУПРЕЖДЕНИЕ: Не удалось найти блок очистки в рецепте")
print("Добавляем команды очистки перед блоком AppDir:")
# Просто вставим команды перед AppDir:
appdir_pos = recipe_content.find('AppDir:')
if appdir_pos != -1:
new_content = (
recipe_content[:appdir_pos] +
'\n'.join(cleanup_lines) + '\n' +
recipe_content[appdir_pos:]
)
return new_content
else:
print("ОШИБКА: Не найден блок AppDir: в рецепте")
return ""
# Создаем замену - группа 1 (cp -r lib) + новые команды очистки
replacement = r'\1' + '\n'.join(cleanup_lines) + '\n'
updated_recipe = re.sub(pattern, replacement, recipe_content, count=1)
return updated_recipe
def main():
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
parser.add_argument('project_path', nargs='?', default='.',
help='Путь к проекту для анализа (по умолчанию: текущая директория)')
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
parser.add_argument('--venv', help='Путь к виртуальному окружению (по умолчанию: .venv в корне проекта)')
args = parser.parse_args()
project_path = Path(args.project_path).resolve()
if not project_path.exists():
print(f"Ошибка: путь {project_path} не существует")
sys.exit(1)
appdir_path = Path(args.appdir).resolve() if args.appdir else None
if appdir_path and not appdir_path.exists():
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
appdir_path = None
# Определяем корень проекта
# Если запущен из подпапки проекта, ищем корень
project_root = project_path
if (project_path / ".git").exists() or (project_path / "pyproject.toml").exists():
project_root = project_path
else:
# Пытаемся найти корень проекта
current = project_path
while current != current.parent:
if (current / ".git").exists() or (current / "pyproject.toml").exists():
project_root = current
break
current = current.parent
print(f"Корень проекта: {project_root}")
analyzer = PySide6DependencyAnalyzer(project_root=project_root)
# Если указан custom venv путь
if args.venv:
analyzer.venv_path = Path(args.venv).resolve()
print(f"Использую указанный venv: {analyzer.venv_path}")
results = analyzer.analyze_project(project_path, appdir_path)
# Сохраняем в анализатор для генерации команд
analyzer.all_required_modules = set(results.get('all_required', []))
# Выводим результаты
print("\n" + "="*60)
print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)")
print("="*60)
if 'error' in results:
print(f"\nОШИБКА: {results['error']}")
sys.exit(1)
print(f"\nМетод анализа: {results['analysis_method']}")
print(f"Найдено библиотек: {results['found_libraries']}")
if results['directly_used_code']:
print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):")
for module in results['directly_used_code']:
print(f"{module}")
if results['directly_used_ldd']:
print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):")
for module in results['directly_used_ldd']:
print(f"{module}")
print(f"\nВсе необходимые модули ({len(results['all_required'])}):")
for module in results['all_required']:
print(f"{module}")
print(f"\nМодули, которые можно удалить ({len(results['removable'])}):")
for module in results['removable']:
print(f"{module}")
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
if args.verbose and results['real_dependencies']:
print(f"\nРеальные зависимости (ldd):")
for module, deps in results['real_dependencies'].items():
if deps:
print(f" {module}{', '.join(deps)}")
# Обновляем AppImage рецепт
recipe_path = analyzer.build_path / "AppImageBuilder.yml"
if recipe_path.exists():
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
if updated_recipe:
with open(recipe_path, 'w', encoding='utf-8') as f:
f.write(updated_recipe)
print(f"\nAppImage рецепт обновлен: {recipe_path}")
else:
print(f"\nОШИБКА: не удалось обновить рецепт")
else:
print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}")
# Сохраняем результаты в JSON
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"Результаты сохранены в: {args.output}")
print("\n" + "="*60)
if __name__ == "__main__":
main()

View File

@@ -3,7 +3,10 @@
import sys import sys
from pathlib import Path from pathlib import Path
import re import re
import ast
# Import the security checker from the main module
sys.path.insert(0, str(Path(__file__).parent.parent)) # Add project root to path
from portprotonqt.theme_security import ThemeSecurityChecker
# Запрещенные QSS-свойства # Запрещенные QSS-свойства
FORBIDDEN_PROPERTIES = { FORBIDDEN_PROPERTIES = {
@@ -13,57 +16,29 @@ FORBIDDEN_PROPERTIES = {
"text-shadow", "text-shadow",
} }
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_qss_files(): def check_qss_files():
has_errors = False has_errors = False
for qss_file in Path("portprotonqt/themes").glob("**/*.py"): for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
with open(qss_file, "r") as f: # Check for forbidden QSS properties first
with open(qss_file, "r", encoding='utf-8') as f:
content = f.read() content = f.read()
# Проверка на запрещённые QSS-свойства for prop in FORBIDDEN_PROPERTIES:
for prop in FORBIDDEN_PROPERTIES: if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
if re.search(rf"{prop}\s*:", content, re.IGNORECASE): print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
has_errors = True
# Проверка на опасные импорты и функции
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 has_errors = True
# Use the imported ThemeSecurityChecker to check for dangerous imports and functions
checker = ThemeSecurityChecker()
is_safe, errors = checker.check_theme_safety(str(qss_file))
if not is_safe:
for error in errors:
print(error)
has_errors = True
return has_errors return has_errors
if __name__ == "__main__": if __name__ == "__main__":
if check_qss_files(): if check_qss_files():
sys.exit(1) sys.exit(1)

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 339 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 375 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 339 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 375 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 339 of 339 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 375 of 375 |
--- ---

View File

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

View File

@@ -31,17 +31,49 @@ mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
## 🎨 Style File (`styles.py`) ## 🎨 Style File (`styles.py`)
Create a `styles.py` in the theme root. It should define variables or functions that return CSS. Create a `styles.py` in the theme root. It should define variables or functions that return QSS (Qt Style Sheets). For better organization, you can split your theme into multiple submodules by creating a subdirectory (e.g., `styles`, `components`, etc.) with separate Python files for different components, and import them in `styles.py`.
**Example:** **Example of modular structure:**
```
my_custom_theme/
├── styles.py
├── metainfo.ini
├── fonts/
├── images/
└── styles/ # This can be named anything (e.g., components, modules, etc.)
├── __init__.py # This empty file makes the directory a Python package
├── constants.py
├── base.py
├── game_card.py
├── detail_page.py
├── settings.py
├── winetricks.py
└── theme_utils.py
```
**Main styles.py file:**
```python ```python
def custom_button_style(color1, color2): # Import from the theme's submodules using absolute paths relative to the package
return f""" # Replace 'my_custom_theme' with your actual theme folder name and 'styles' with your subdirectory name
QPushButton {{ from portprotonqt.themes.my_custom_theme.styles.constants import *
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, from portprotonqt.themes.my_custom_theme.styles.base import *
stop:0 {color1}, stop:1 {color2}); from portprotonqt.themes.my_custom_theme.styles.game_card import *
}} from portprotonqt.themes.my_custom_theme.styles.detail_page import *
""" from portprotonqt.themes.my_custom_theme.styles.settings import *
from portprotonqt.themes.my_custom_theme.styles.winetricks import *
from portprotonqt.themes.my_custom_theme.styles.theme_utils import *
```
**Example submodule (styles/constants.py):**
```python
# Theme constants
font_family = "Play"
font_size_a = "16px"
font_size_b = "24px"
border_radius_a = "10px"
color_a = "#409EFF"
color_b = "#282a33"
# ... other constants
``` ```
--- ---
@@ -207,18 +239,52 @@ GAME_CARD_ANIMATION = {
```ini ```ini
[Metainfo] [Metainfo]
name = My Custom Theme name_en = My Custom Theme
name_ru = Моя пользовательская тема
author = Your Name author = Your Name
author_link = https://example.com author_link = https://example.com
description = Description of your theme. description_en = Description of your theme.
description_ru = Описание вашей темы.
``` ```
### Translation Support
You must provide translations for your theme's name and description by adding language-specific fields:
- `name_en`, `name_ru`, etc. for theme names
- `description_en`, `description_ru`, etc. for theme descriptions
The application will automatically select the appropriate translation based on the user's system language, falling back to English if translations are not available for the user's language.
--- ---
## 🖼 Screenshots ## 🖼 Screenshots
Folder: `images/screenshots/` — place UI screenshots there. Folder: `images/screenshots/` — place UI screenshots there.
### Screenshot Translation Support
You can provide translations for screenshot captions by adding entries to the `[Screenshots]` section in your `metainfo.ini` file:
```ini
[Screenshots]
auto_installs_en = Auto-installs
auto_installs_ru = Автоустановки
library_en = Library
library_ru = Библиотека
game_card_en = Game Card
game_card_ru = Карточка
context_menu_en = Context Menu
context_menu_ru = Контекстное меню
portproton_settings_en = PortProton Settings
portproton_settings_ru = Настройки PortProton
wine_settings_en = Wine Settings
wine_settings_ru = Настройки Wine
themes_en = Themes
themes_ru = Темы
```
Screenshot files should be named in English (without spaces), and the application will display the appropriate translated caption based on the user's system language, falling back to English if translations are not available.
--- ---
## 🔡 Fonts and Icons (optional) ## 🔡 Fonts and Icons (optional)

View File

@@ -31,17 +31,49 @@ mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
## 🎨 Файл стилей (`styles.py`) ## 🎨 Файл стилей (`styles.py`)
Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие CSS-оформление. Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие QSS-оформление (Qt Style Sheets). Для лучшей организации кода, вы можете разделить тему на несколько подмодулей, создав поддиректорию (например, `styles`, `components` и т.д.) с отдельными Python-файлами для разных компонентов, и импортировать их в `styles.py`.
**Пример функции:** **Пример модульной структуры:**
```
my_custom_theme/
├── styles.py
├── metainfo.ini
├── fonts/
├── images/
└── styles/ # Это может быть названо как угодно (например, components, modules и т.д.)
├── __init__.py # Этот пустой файл делает директорию Python-пакетом
├── constants.py
├── base.py
├── game_card.py
├── detail_page.py
├── settings.py
├── winetricks.py
└── theme_utils.py
```
**Основной файл styles.py:**
```python ```python
def custom_button_style(color1, color2): # Импорт из подмодулей темы с использованием абсолютных путей относительно пакета
return f""" # Замените 'my_custom_theme' на фактическое имя папки вашей темы и 'styles' на имя вашей поддиректории
QPushButton {{ from portprotonqt.themes.my_custom_theme.styles.constants import *
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, from portprotonqt.themes.my_custom_theme.styles.base import *
stop:0 {color1}, stop:1 {color2}); from portprotonqt.themes.my_custom_theme.styles.game_card import *
}} from portprotonqt.themes.my_custom_theme.styles.detail_page import *
""" from portprotonqt.themes.my_custom_theme.styles.settings import *
from portprotonqt.themes.my_custom_theme.styles.winetricks import *
from portprotonqt.themes.my_custom_theme.styles.theme_utils import *
```
**Пример подмодуля (styles/constants.py):**
```python
# Константы темы
font_family = "Play"
font_size_a = "16px"
font_size_b = "24px"
border_radius_a = "10px"
color_a = "#409EFF"
color_b = "#282a33"
# ... другие константы
``` ```
--- ---
@@ -207,18 +239,52 @@ GAME_CARD_ANIMATION = {
```ini ```ini
[Metainfo] [Metainfo]
name = My Custom Theme name_en = My Custom Theme
name_ru = Моя пользовательская тема
author = Ваше имя author = Ваше имя
author_link = https://example.com author_link = https://example.com
description = Описание вашей темы. description_en = Description of your theme.
description_ru = Описание вашей темы.
``` ```
### Поддержка переводов
Вы должны предоставить переводы для названия и описания вашей темы, добавив поля с указанием языка:
- `name_en`, `name_ru` и т.д. для названий тем
- `description_en`, `description_ru` и т.д. для описаний тем
Приложение автоматически выберет соответствующий перевод на основе языка системы пользователя, с откатом к английскому языку, если переводы недоступны для языка пользователя.
--- ---
## 🖼 Скриншоты ## 🖼 Скриншоты
Папка: `images/screenshots/` — любые изображения оформления темы. Папка: `images/screenshots/` — любые изображения оформления темы.
### Поддержка перевода скриншотов
Вы можете предоставить переводы для подписей к скриншотам, добавив записи в секцию `[Screenshots]` в файле `metainfo.ini`:
```ini
[Screenshots]
auto_installs_en = Auto-installs
auto_installs_ru = Автоустановки
library_en = Library
library_ru = Библиотека
game_card_en = Game Card
game_card_ru = Карточка
context_menu_en = Context Menu
context_menu_ru = Контекстное меню
portproton_settings_en = PortProton Settings
portproton_settings_ru = Настройки PortProton
wine_settings_en = Wine Settings
wine_settings_ru = Настройки Wine
themes_en = Themes
themes_ru = Темы
```
Файлы скриншотов должны быть названы на английском языке (без пробелов), и приложение будет отображать соответствующую переведенную подпись в зависимости от языка системы пользователя, с откатом к английскому языку, если переводы недоступны.
--- ---
## 🔡 Шрифты и иконки (опционально) ## 🔡 Шрифты и иконки (опционально)

View File

@@ -1,3 +1,4 @@
from typing import Any, cast
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
@@ -32,6 +33,47 @@ class GameCardAnimations:
self.pulse_anim: QPropertyAnimation | None = None self.pulse_anim: QPropertyAnimation | None = None
self._isPulseAnimationConnected = False self._isPulseAnimationConnected = False
def cleanup(self):
"""Clean up all animation objects to prevent memory leaks."""
if self.thickness_anim:
try:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
try:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
except RuntimeError:
pass # Signal was already disconnected
self.thickness_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.thickness_anim = None
if self.gradient_anim:
try:
self.gradient_anim.stop()
self.gradient_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.gradient_anim = None
if self.scale_anim:
try:
self.scale_anim.stop()
self.scale_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.scale_anim = None
if self.pulse_anim:
try:
self.pulse_anim.stop()
self.pulse_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.pulse_anim = None
self._isPulseAnimationConnected = False
def setup_animations(self): def setup_animations(self):
"""Initialize animation properties based on theme.""" """Initialize animation properties based on theme."""
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth")) self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
@@ -49,8 +91,16 @@ class GameCardAnimations:
"""Start pulse animation for border width when hovered or focused.""" """Start pulse animation for border width when hovered or focused."""
if not (self.game_card._hovered or self.game_card._focused): if not (self.game_card._hovered or self.game_card._focused):
return return
# Clean up existing pulse animation to prevent memory leaks
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() try:
self.pulse_anim.stop()
self.pulse_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.pulse_anim = None
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth")) self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"]) self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
self.pulse_anim.setLoopCount(0) self.pulse_anim.setLoopCount(0)
@@ -73,7 +123,10 @@ class GameCardAnimations:
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.start_pulse_animation) try:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
except RuntimeError:
pass # Signal was already disconnected
self._isPulseAnimationConnected = False self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]])) self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setStartValue(self.game_card._borderWidth) self.thickness_anim.setStartValue(self.game_card._borderWidth)
@@ -83,8 +136,15 @@ class GameCardAnimations:
self.thickness_anim.start() self.thickness_anim.start()
if animation_type == "gradient": if animation_type == "gradient":
# Clean up existing gradient animation to prevent memory leaks
if self.gradient_anim: if self.gradient_anim:
self.gradient_anim.stop() try:
self.gradient_anim.stop()
self.gradient_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.gradient_anim = None
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
@@ -92,8 +152,15 @@ class GameCardAnimations:
self.gradient_anim.setLoopCount(-1) self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start() self.gradient_anim.start()
elif animation_type == "scale": elif animation_type == "scale":
# Clean up existing scale animation to prevent memory leaks
if self.scale_anim: if self.scale_anim:
self.scale_anim.stop() try:
self.scale_anim.stop()
self.scale_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.scale_anim = None
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]])) self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
@@ -109,11 +176,21 @@ class GameCardAnimations:
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type == "gradient": if animation_type == "gradient":
if self.gradient_anim: if self.gradient_anim:
self.gradient_anim.stop() try:
self.gradient_anim.stop()
self.gradient_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.gradient_anim = None self.gradient_anim = None
elif animation_type == "scale": elif animation_type == "scale":
if self.scale_anim: if self.scale_anim:
self.scale_anim.stop() try:
self.scale_anim.stop()
self.scale_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.scale_anim = None
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]])) self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
@@ -121,12 +198,19 @@ class GameCardAnimations:
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"]) self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start() self.scale_anim.start()
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() try:
self.pulse_anim.stop()
self.pulse_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.pulse_anim = None self.pulse_anim = None
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.start_pulse_animation) try:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
except RuntimeError:
pass # Signal was already disconnected
self._isPulseAnimationConnected = False self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]])) self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self.game_card._borderWidth) self.thickness_anim.setStartValue(self.game_card._borderWidth)
@@ -147,7 +231,10 @@ class GameCardAnimations:
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.start_pulse_animation) try:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
except RuntimeError:
pass # Signal was already disconnected
self._isPulseAnimationConnected = False self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]])) self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setStartValue(self.game_card._borderWidth) self.thickness_anim.setStartValue(self.game_card._borderWidth)
@@ -157,8 +244,15 @@ class GameCardAnimations:
self.thickness_anim.start() self.thickness_anim.start()
if animation_type == "gradient": if animation_type == "gradient":
# Clean up existing gradient animation to prevent memory leaks
if self.gradient_anim: if self.gradient_anim:
self.gradient_anim.stop() try:
self.gradient_anim.stop()
self.gradient_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.gradient_anim = None
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
@@ -166,8 +260,15 @@ class GameCardAnimations:
self.gradient_anim.setLoopCount(-1) self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start() self.gradient_anim.start()
elif animation_type == "scale": elif animation_type == "scale":
# Clean up existing scale animation to prevent memory leaks
if self.scale_anim: if self.scale_anim:
self.scale_anim.stop() try:
self.scale_anim.stop()
self.scale_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.scale_anim = None
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]])) self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
@@ -183,11 +284,21 @@ class GameCardAnimations:
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type == "gradient": if animation_type == "gradient":
if self.gradient_anim: if self.gradient_anim:
self.gradient_anim.stop() try:
self.gradient_anim.stop()
self.gradient_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.gradient_anim = None self.gradient_anim = None
elif animation_type == "scale": elif animation_type == "scale":
if self.scale_anim: if self.scale_anim:
self.scale_anim.stop() try:
self.scale_anim.stop()
self.scale_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.scale_anim = None
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]])) self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
@@ -195,12 +306,19 @@ class GameCardAnimations:
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"]) self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start() self.scale_anim.start()
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() try:
self.pulse_anim.stop()
self.pulse_anim.deleteLater()
except RuntimeError:
pass # Object already deleted
self.pulse_anim = None self.pulse_anim = None
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.start_pulse_animation) try:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
except RuntimeError:
pass # Signal was already disconnected
self._isPulseAnimationConnected = False self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]])) self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self.game_card._borderWidth) self.thickness_anim.setStartValue(self.game_card._borderWidth)
@@ -236,14 +354,57 @@ class DetailPageAnimations:
self.main_window = main_window self.main_window = main_window
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.animations = main_window._animations if hasattr(main_window, '_animations') else {} # Ensure the main window has an animations dict
if not hasattr(main_window, '_animations'):
main_window._animations = {}
self.animations = main_window._animations
def cleanup(self):
"""Clean up all animations to prevent memory leaks."""
# Stop and clean up all animations in the dict
for _detail_page, animation in list(self.animations.items()):
try:
if isinstance(animation, QAbstractAnimation):
animation.stop()
animation.deleteLater()
except RuntimeError:
pass # Object already deleted
self.animations.clear()
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable): def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
"""Animate the detail page based on theme settings.""" """Animate the detail page based on theme settings."""
# Check if the detail page is still valid before proceeding
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
logger.warning("Detail page is not valid, skipping enter animation")
load_image_and_restore_effect()
cleanup_animation()
return
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade") animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
# Safely stop and remove any existing animation for this detail page
if detail_page in self.animations:
try:
existing_animation = self.animations[detail_page]
if isinstance(existing_animation, QAbstractAnimation) and existing_animation.state() == QAbstractAnimation.State.Running:
existing_animation.stop()
existing_animation.deleteLater()
except RuntimeError:
logger.debug("Existing animation already deleted")
except Exception as e:
logger.error(f"Error stopping existing animation: {e}", exc_info=True)
finally:
self.animations.pop(detail_page, None)
if animation_type == "fade": if animation_type == "fade":
# Check again if page is still valid before starting animation
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during fade setup, skipping animation")
load_image_and_restore_effect()
cleanup_animation()
return
original_effect = detail_page.graphicsEffect() original_effect = detail_page.graphicsEffect()
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True) opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
opacity_effect.setOpacity(0.0) opacity_effect.setOpacity(0.0)
@@ -252,17 +413,36 @@ class DetailPageAnimations:
animation.setDuration(duration) animation.setDuration(duration)
animation.setStartValue(0.0) animation.setStartValue(0.0)
animation.setEndValue(0.999) animation.setEndValue(0.999)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
def restore_effect(): def restore_effect():
try: try:
detail_page.setGraphicsEffect(original_effect) # type: ignore # Check if page is still valid before restoring effect
if detail_page and not detail_page.isHidden():
detail_page.setGraphicsEffect(cast(Any, original_effect))
except RuntimeError: except RuntimeError:
logger.warning("Original effect already deleted") logger.warning("Original effect already deleted")
animation.finished.connect(restore_effect)
animation.finished.connect(load_image_and_restore_effect) # Only start animation if page is still valid
animation.finished.connect(opacity_effect.deleteLater) if detail_page and not detail_page.isHidden():
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(restore_effect)
animation.finished.connect(load_image_and_restore_effect)
animation.finished.connect(opacity_effect.deleteLater)
else:
logger.warning("Detail page invalid when starting fade, cleaning up")
restore_effect()
load_image_and_restore_effect()
opacity_effect.deleteLater()
cleanup_animation()
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]: elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
# Check again if page is still valid before starting animation
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during slide setup, skipping animation")
load_image_and_restore_effect()
cleanup_animation()
return
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
start_pos = { start_pos = {
@@ -277,11 +457,25 @@ class DetailPageAnimations:
animation.setStartValue(start_pos) animation.setStartValue(start_pos)
animation.setEndValue(self.main_window.stackedWidget.rect().topLeft()) animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
animation.setEasingCurve(easing_curve) animation.setEasingCurve(easing_curve)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation # Only start animation if page is still valid
animation.finished.connect(cleanup_animation) if detail_page and not detail_page.isHidden():
animation.finished.connect(load_image_and_restore_effect) animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(cleanup_animation)
animation.finished.connect(load_image_and_restore_effect)
else:
logger.warning("Detail page invalid when starting slide, cleaning up")
load_image_and_restore_effect()
cleanup_animation()
elif animation_type == "bounce": elif animation_type == "bounce":
# Check again if page is still valid before starting animation
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during bounce setup, skipping animation")
load_image_and_restore_effect()
cleanup_animation()
return
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
detail_page.setWindowOpacity(0.0) detail_page.setWindowOpacity(0.0)
@@ -300,14 +494,27 @@ class DetailPageAnimations:
group_anim = QParallelAnimationGroup() group_anim = QParallelAnimationGroup()
group_anim.addAnimation(opacity_anim) group_anim.addAnimation(opacity_anim)
group_anim.addAnimation(geometry_anim) group_anim.addAnimation(geometry_anim)
group_anim.finished.connect(load_image_and_restore_effect)
group_anim.finished.connect(cleanup_animation) # Only start animation if page is still valid
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped) if detail_page and not detail_page.isHidden():
self.animations[detail_page] = group_anim group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = group_anim
group_anim.finished.connect(load_image_and_restore_effect)
group_anim.finished.connect(cleanup_animation)
else:
logger.warning("Detail page invalid when starting bounce, cleaning up")
load_image_and_restore_effect()
cleanup_animation()
def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable): def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
"""Animate the detail page exit based on theme settings.""" """Animate the detail page exit based on theme settings."""
try: try:
# Check if the detail page is still valid before proceeding
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
logger.warning("Detail page is not valid, skipping exit animation")
cleanup_callback()
return
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade") animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
# Safely stop and remove any existing animation # Safely stop and remove any existing animation
@@ -316,6 +523,7 @@ class DetailPageAnimations:
animation = self.animations[detail_page] animation = self.animations[detail_page]
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running: if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
animation.stop() animation.stop()
animation.deleteLater()
except RuntimeError: except RuntimeError:
logger.warning("Animation already deleted for page") logger.warning("Animation already deleted for page")
except Exception as e: except Exception as e:
@@ -326,6 +534,13 @@ class DetailPageAnimations:
# Define animation based on type # Define animation based on type
if animation_type == "fade": if animation_type == "fade":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
# Check if page is still valid before accessing properties
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during fade exit setup, skipping animation")
cleanup_callback()
return
original_effect = detail_page.graphicsEffect() original_effect = detail_page.graphicsEffect()
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False) opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
opacity_effect.setOpacity(0.999) opacity_effect.setOpacity(0.999)
@@ -334,18 +549,36 @@ class DetailPageAnimations:
animation.setDuration(duration) animation.setDuration(duration)
animation.setStartValue(0.999) animation.setStartValue(0.999)
animation.setEndValue(0.0) animation.setEndValue(0.0)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
def restore_and_cleanup(): def restore_and_cleanup():
try: try:
detail_page.setGraphicsEffect(original_effect) # type: ignore # Check if page is still valid before restoring effect
if detail_page and not detail_page.isHidden():
detail_page.setGraphicsEffect(cast(Any, original_effect))
except RuntimeError: except RuntimeError:
logger.debug("Original effect already deleted") logger.debug("Original effect already deleted")
cleanup_callback() cleanup_callback()
animation.finished.connect(restore_and_cleanup)
animation.finished.connect(opacity_effect.deleteLater) # Check if animation is still valid before starting
if animation and not detail_page.isHidden():
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(restore_and_cleanup)
animation.finished.connect(opacity_effect.deleteLater)
else:
logger.warning("Animation or detail page invalid when starting fade exit, cleaning up")
restore_and_cleanup()
opacity_effect.deleteLater()
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]: elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
# Check if page is still valid before accessing properties
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during slide exit setup, skipping animation")
cleanup_callback()
return
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
end_pos = { end_pos = {
"slide_left": QPoint(-self.main_window.width(), 0), "slide_left": QPoint(-self.main_window.width(), 0),
@@ -353,16 +586,37 @@ class DetailPageAnimations:
"slide_up": QPoint(0, self.main_window.height()), "slide_up": QPoint(0, self.main_window.height()),
"slide_down": QPoint(0, -self.main_window.height()) "slide_down": QPoint(0, -self.main_window.height())
}[animation_type] }[animation_type]
animation = QPropertyAnimation(detail_page, QByteArray(b"pos")) animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
animation.setDuration(duration) animation.setDuration(duration)
animation.setStartValue(detail_page.pos()) animation.setStartValue(detail_page.pos())
animation.setEndValue(end_pos) animation.setEndValue(end_pos)
animation.setEasingCurve(easing_curve) animation.setEasingCurve(easing_curve)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation def slide_cleanup():
animation.finished.connect(cleanup_callback) # Check if page is still valid before cleanup
if not detail_page or detail_page.isHidden():
logger.debug("Detail page already cleaned up")
cleanup_callback()
# Check if animation is still valid before starting
if animation and not detail_page.isHidden():
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(slide_cleanup)
else:
logger.warning("Animation or detail page invalid when starting slide exit, cleaning up")
slide_cleanup()
elif animation_type == "bounce": elif animation_type == "bounce":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
# Check if page is still valid before accessing properties
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during bounce exit setup, skipping animation")
cleanup_callback()
return
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity")) opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
opacity_anim.setDuration(duration) opacity_anim.setDuration(duration)
@@ -375,13 +629,38 @@ class DetailPageAnimations:
geometry_anim.setStartValue(detail_page.geometry()) geometry_anim.setStartValue(detail_page.geometry())
geometry_anim.setEndValue(final_rect) geometry_anim.setEndValue(final_rect)
geometry_anim.setEasingCurve(easing_curve) geometry_anim.setEasingCurve(easing_curve)
# Check if animations are still valid before creating group
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during bounce exit setup, cleaning up")
cleanup_callback()
return
group_anim = QParallelAnimationGroup() group_anim = QParallelAnimationGroup()
group_anim.addAnimation(opacity_anim) group_anim.addAnimation(opacity_anim)
group_anim.addAnimation(geometry_anim) group_anim.addAnimation(geometry_anim)
group_anim.finished.connect(cleanup_callback)
# Check if group animation is still valid before connecting
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during group animation setup, cleaning up")
cleanup_callback()
return
def bounce_cleanup():
# Check if page is still valid before cleanup
if not detail_page or detail_page.isHidden():
logger.debug("Detail page already cleaned up")
cleanup_callback()
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped) group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = group_anim self.animations[detail_page] = group_anim
group_anim.finished.connect(bounce_cleanup)
except RuntimeError:
# Widget was already deleted, which is expected after deleteLater()
logger.debug("Detail page already deleted during animation setup")
cleanup_callback()
except Exception as e: except Exception as e:
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True) logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
self.animations.pop(detail_page, None) if detail_page in self.animations:
self.animations.pop(detail_page, None)
cleanup_callback() cleanup_callback()

View File

@@ -17,7 +17,7 @@ from portprotonqt.cli import parse_args
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.8" __app_version__ = "0.1.9"
def get_version(): def get_version():
try: try:
@@ -34,13 +34,12 @@ def main():
os.environ["PROCESS_LOG"] = "1" os.environ["PROCESS_LOG"] = "1"
os.environ["START_FROM_STEAM"] = "1" os.environ["START_FROM_STEAM"] = "1"
# Get the PortProton start command
start_sh = get_portproton_start_command() start_sh = get_portproton_start_command()
if start_sh is None: if start_sh is None:
return return
subprocess.run(start_sh + ["cli", "--initial"])
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__)) app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__) app.setDesktopFileName(__app_id__)
@@ -104,15 +103,14 @@ def main():
def restore_window(): def restore_window():
try: try:
if msg.startswith("show"): if msg.startswith("show"):
if hasattr(window, "restore_from_tray"): # Ensure the window is visible and not minimized
window.restore_from_tray() # type: ignore[attr-defined] window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
else: window.show()
window.showNormal() window.raise_()
window.raise_() window.activateWindow()
window.activateWindow()
window.setWindowState( # Ensure window is in active state for systems with strict focus policies
window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
)
if ":fullscreen" in msg: if ":fullscreen" in msg:
logger.info("Switching to fullscreen via IPC") logger.info("Switching to fullscreen via IPC")
@@ -145,6 +143,22 @@ def main():
save_fullscreen_config(False) save_fullscreen_config(False)
window.showNormal() window.showNormal()
# Execute the initial PortProton command after the UI is set up
def run_initial_command():
nonlocal start_sh
if start_sh:
try:
subprocess.run(start_sh + ["cli", "--initial"], timeout=10)
except subprocess.TimeoutExpired:
logger.warning("Initial PortProton command timed out")
except Exception as e:
logger.error(f"Error running initial PortProton command: {e}")
else:
logger.warning("PortProton start command not available, skipping initial command")
# Run the initial command after the UI is displayed
QTimer.singleShot(100, run_initial_command)
# --- Cleanup --- # --- Cleanup ---
def cleanup_on_exit(): def cleanup_on_exit():
try: try:

View File

@@ -3,6 +3,7 @@ import configparser
import shutil import shutil
import subprocess import subprocess
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.localization import get_theme_translations
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -77,22 +78,6 @@ def invalidate_config_cache(config_file: str = CONFIG_FILE):
del _config_last_modified[config_file] del _config_last_modified[config_file]
logger.debug(f"Config cache invalidated for {config_file}") logger.debug(f"Config cache invalidated for {config_file}")
def read_config():
"""Reads the configuration file and returns a dictionary of parameters.
Example line in config (no sections):
detail_level = detailed
"""
config_dict = {}
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
key, sep, value = line.partition("=")
if sep:
config_dict[key.strip()] = value.strip()
return config_dict
def read_theme_from_config(): def read_theme_from_config():
"""Reads the theme from the [Appearance] section of the configuration file. """Reads the theme from the [Appearance] section of the configuration file.
@@ -137,8 +122,14 @@ def save_time_config(detail_level):
def read_file_content(file_path): def read_file_content(file_path):
"""Reads the content of a file and returns it as a string.""" """Reads the content of a file and returns it as a string."""
with open(file_path, encoding="utf-8") as f: try:
return f.read().strip() # Add timeout protection for file operations using a simple approach
with open(file_path, encoding="utf-8") as f:
content = f.read().strip()
return content
except Exception as e:
logger.warning(f"Error reading file {file_path}: {e}")
raise # Re-raise the exception to be handled by the caller
def get_portproton_location(): def get_portproton_location():
"""Возвращает путь к PortProton каталогу (строку) или None.""" """Возвращает путь к PortProton каталогу (строку) или None."""
@@ -159,6 +150,8 @@ def get_portproton_location():
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults") logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
except (OSError, PermissionError) as e: except (OSError, PermissionError) as e:
logger.warning(f"Failed to read PortProton configuration file: {e}") logger.warning(f"Failed to read PortProton configuration file: {e}")
except Exception as e:
logger.warning(f"Unexpected error reading PortProton configuration file: {e}")
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_flatpak_dir): if os.path.isdir(default_flatpak_dir):
@@ -175,18 +168,39 @@ def get_portproton_start_command():
if not portproton_path: if not portproton_path:
return None return None
# Check if flatpak command exists before trying to run it
try: try:
result = subprocess.run( subprocess.run(
["flatpak", "list"], ["flatpak", "--version"],
capture_output=True, capture_output=True,
text=True, text=True,
check=False check=False,
timeout=5
) )
if "ru.linux_gaming.PortProton" in result.stdout: flatpak_available = True
logger.info("Detected Flatpak installation") except FileNotFoundError:
return ["flatpak", "run", "ru.linux_gaming.PortProton"] flatpak_available = False
except Exception: except Exception:
pass flatpak_available = False
if flatpak_available:
try:
result = subprocess.run(
["flatpak", "list"],
capture_output=True,
text=True,
check=False,
timeout=10
)
if "ru.linux_gaming.PortProton" in result.stdout:
logger.info("Detected Flatpak installation")
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
except subprocess.TimeoutExpired:
logger.warning("Flatpak list command timed out")
return None
except Exception as e:
logger.warning(f"Error checking flatpak list: {e}")
pass
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh") start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
if os.path.exists(start_sh_path): if os.path.exists(start_sh_path):
@@ -215,13 +229,17 @@ def load_theme_metainfo(theme_name):
theme_folder = os.path.join(themes_dir, theme_name) theme_folder = os.path.join(themes_dir, theme_name)
metainfo_file = os.path.join(theme_folder, "metainfo.ini") metainfo_file = os.path.join(theme_folder, "metainfo.ini")
if os.path.exists(metainfo_file): if os.path.exists(metainfo_file):
# Load translated theme name and description
theme_translations = get_theme_translations(metainfo_file)
cp = configparser.ConfigParser() cp = configparser.ConfigParser()
cp.read(metainfo_file, encoding="utf-8") cp.read(metainfo_file, encoding="utf-8")
if "Metainfo" in cp: if "Metainfo" in cp:
meta["author"] = cp.get("Metainfo", "author", fallback="Unknown") meta["author"] = cp.get("Metainfo", "author", fallback="Unknown")
meta["author_link"] = cp.get("Metainfo", "author_link", fallback="") meta["author_link"] = cp.get("Metainfo", "author_link", fallback="")
meta["description"] = cp.get("Metainfo", "description", fallback="") # Use translated name and description
meta["name"] = cp.get("Metainfo", "name", fallback=theme_name) meta["name"] = theme_translations.get("name", theme_name)
meta["description"] = theme_translations.get("description", "")
break break
return meta return meta

View File

@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
class ContextMenuManager: class ContextMenuManager:
"""Manages context menu actions for game management in PortProtonQt.""" """Manages context menu actions for game management in PortProtonQt."""
def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager): def __init__(self, parent, portproton_location, theme, game_library_manager):
""" """
Initialize the ContextMenuManager. Initialize the ContextMenuManager.
@@ -44,7 +44,6 @@ class ContextMenuManager:
self.portproton_location = portproton_location self.portproton_location = portproton_location
self.theme = theme self.theme = theme
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.load_games = load_games_callback
self.game_library_manager = game_library_manager self.game_library_manager = game_library_manager
self.update_game_grid = game_library_manager.update_game_grid self.update_game_grid = game_library_manager.update_game_grid
self.legendary_path = os.path.join( self.legendary_path = os.path.join(
@@ -1035,7 +1034,15 @@ Icon={icon_path}
) )
return return
if os.path.isfile(new_cover_path): # Check if new_cover_path is a URL by checking for common image extensions
image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
has_image_extension = any(new_cover_path.lower().endswith(ext) for ext in image_extensions)
# Consider it a URL if it has image extension and is not a local file
is_url = has_image_extension and not os.path.isfile(new_cover_path)
# Use the downloaded file path if we have a URL and the file was downloaded, otherwise use the local file
if os.path.isfile(new_cover_path) or (is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path)):
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0] exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
xdg_data_home = os.getenv( xdg_data_home = os.getenv(
"XDG_DATA_HOME", "XDG_DATA_HOME",
@@ -1043,16 +1050,25 @@ Icon={icon_path}
) )
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name) custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
os.makedirs(custom_folder, exist_ok=True) os.makedirs(custom_folder, exist_ok=True)
ext = os.path.splitext(new_cover_path)[1].lower()
# Use the actual cover file path (either from URL download or local file)
cover_to_copy = dialog.last_cover_path if is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path) else new_cover_path
ext = os.path.splitext(cover_to_copy)[1].lower()
if ext in [".png", ".jpg", ".jpeg", ".bmp"]: if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
try: try:
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}")) shutil.copyfile(cover_to_copy, os.path.join(custom_folder, f"cover{ext}"))
except OSError as e: except OSError as e:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("Failed to copy cover image: {error}").format(error=str(e)) _("Failed to copy cover image: {error}").format(error=str(e))
) )
return return
else:
self.signals.show_warning_dialog.emit(
_("Error"),
_("Unsupported image format: {extension}").format(extension=ext)
)
return
def add_to_steam(self, game_name, exec_line, cover_path): def add_to_steam(self, game_name, exec_line, cover_path):
""" """

View File

@@ -0,0 +1,222 @@
import os
import shutil
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QWidget, QCheckBox,
QPushButton, QMessageBox,
QLabel, QTextEdit, QHBoxLayout,
QListWidget, QListWidgetItem)
from PySide6.QtCore import Qt
from portprotonqt.localization import _
class WineDeleteManager(QDialog):
def __init__(self, parent=None, portproton_location=None, selected_wine=None):
super().__init__(parent)
self.selected_wines = set() # Use set to store selected wine names
self.portproton_location = portproton_location
self.selected_wine = selected_wine # The wine that should be pre-selected
self.initUI()
self.load_wine_data()
def initUI(self):
self.setWindowTitle(_('Delete Wine'))
self.resize(800, 600)
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
# Wine list widget - основной растягивающийся элемент
self.list_widget = QListWidget()
self.list_widget.setSelectionMode(QListWidget.SelectionMode.NoSelection) # Disable default selection to use checkboxes
layout.addWidget(self.list_widget, 1)
# Инфо-блок для показа выбранного (компактный для информации по выбранным закачкам)
selection_widget = QWidget()
selection_layout = QVBoxLayout(selection_widget)
selection_layout.setContentsMargins(0, 2, 0, 2)
selection_layout.setSpacing(2)
selection_label = QLabel(_("Selected WINE:"))
selection_layout.addWidget(selection_label)
self.selection_text = QTextEdit()
self.selection_text.setMaximumHeight(80)
self.selection_text.setReadOnly(True)
self.selection_text.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
self.selection_text.setPlainText(_("No WINE selected"))
selection_layout.addWidget(self.selection_text)
layout.addWidget(selection_widget)
# Кнопки управления
button_layout = QHBoxLayout()
self.delete_btn = QPushButton(_('Delete Selected'))
self.delete_btn.clicked.connect(self.delete_selected)
self.delete_btn.setEnabled(False)
self.delete_btn.setMinimumHeight(40)
self.clear_btn = QPushButton(_('Clear All'))
self.clear_btn.clicked.connect(self.clear_selection)
self.clear_btn.setMinimumHeight(40)
button_layout.addWidget(self.delete_btn)
button_layout.addWidget(self.clear_btn)
layout.addLayout(button_layout)
def load_wine_data(self):
"""Load wine data from dist directory"""
if not self.portproton_location:
return
dist_path = os.path.join(self.portproton_location, "data", "dist")
if not os.path.exists(dist_path):
return
# Get all wine directories
wine_dirs = [d for d in os.listdir(dist_path) if os.path.isdir(os.path.join(dist_path, d))]
# Add each wine to the list
for wine_name in wine_dirs:
self.add_wine_to_list(wine_name)
def add_wine_to_list(self, wine_name):
"""Add a wine to the list with checkbox"""
# Create a widget with checkbox and wine name
item_widget = QWidget()
item_layout = QHBoxLayout(item_widget)
item_layout.setContentsMargins(5, 2, 5, 2)
item_layout.setSpacing(5)
checkbox = QCheckBox(wine_name)
checkbox.stateChanged.connect(lambda state, name=wine_name: self.on_wine_toggled(state, name))
item_layout.addWidget(checkbox)
item_layout.addStretch() # Add stretch to align checkbox to the left
# Create list item and set the widget
list_item = QListWidgetItem(self.list_widget)
list_item.setSizeHint(item_widget.sizeHint())
self.list_widget.addItem(list_item)
self.list_widget.setItemWidget(list_item, item_widget)
def on_wine_toggled(self, state, wine_name):
"""Handle wine selection/deselection"""
if state == Qt.CheckState.Checked.value:
self.selected_wines.add(wine_name)
else:
self.selected_wines.discard(wine_name)
self.update_selection_display()
def update_selection_display(self):
"""Update the selection display"""
# Get currently selected wines from the list
currently_selected = set()
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
item_widget = self.list_widget.itemWidget(item)
if item_widget:
checkbox = item_widget.findChild(QCheckBox)
if checkbox and checkbox.isChecked():
currently_selected.add(checkbox.text())
# Update the internal set to match the current state
self.selected_wines = currently_selected
if self.selected_wines:
selection_text = _('Selected {} WINE:\n').format(len(self.selected_wines))
for i, wine_name in enumerate(sorted(self.selected_wines), 1):
selection_text += f"{i}. {wine_name}\n"
self.selection_text.setPlainText(selection_text)
self.delete_btn.setEnabled(True)
else:
self.selection_text.setPlainText(_("No WINE selected"))
self.delete_btn.setEnabled(False)
def clear_selection(self):
"""Clear all selections"""
self.selected_wines.clear()
# Uncheck all checkboxes in the list
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
item_widget = self.list_widget.itemWidget(item)
if item_widget:
checkbox = item_widget.findChild(QCheckBox)
if checkbox:
checkbox.setChecked(False)
self.update_selection_display()
def delete_selected(self):
"""Delete all selected wines"""
# Get currently selected wines from the list
currently_selected = set()
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
item_widget = self.list_widget.itemWidget(item)
if item_widget:
checkbox = item_widget.findChild(QCheckBox)
if checkbox and checkbox.isChecked():
currently_selected.add(checkbox.text())
if not currently_selected:
QMessageBox.warning(self, _("No Selection"), _("Please select at least one WINE to delete."))
return
# Confirm deletion
wine_list = "\n".join(sorted(currently_selected))
reply = QMessageBox.question(
self,
_("Confirm Deletion"),
_("Are you sure you want to delete the following WINE versions?\n\n{}").format(wine_list),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
if not self.portproton_location:
return
dist_path = os.path.join(self.portproton_location, "data", "dist")
errors = []
for wine_name in currently_selected:
wine_path = os.path.join(dist_path, wine_name)
try:
if os.path.exists(wine_path):
shutil.rmtree(wine_path)
except Exception as e:
error_msg = _("Failed to delete WINE '{}': {}").format(wine_name, str(e))
errors.append(error_msg)
QMessageBox.warning(self, _("Error"), error_msg)
if errors:
QMessageBox.warning(self, _("Some Deletions Failed"),
_("Some WINE versions could not be deleted:\n\n{}").format("\n".join(errors)))
else:
QMessageBox.information(self, _("Success"), _("Selected WINE versions deleted successfully."))
# Close the dialog after deletion
self.accept()
def show_wine_delete_manager(parent=None, portproton_location=None, selected_wine=None):
"""
Shows the WINE deletion dialog.
Args:
parent: Parent widget for the dialog
portproton_location: Location of PortProton installation
selected_wine: Wine that should be pre-selected
Returns:
WineDeleteManager dialog instance
"""
dialog = WineDeleteManager(parent, portproton_location, selected_wine)
dialog.exec() # Use exec() for modal dialog
return dialog

View File

@@ -0,0 +1,878 @@
import os
import shlex
from PySide6.QtWidgets import (QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QLabel, QHBoxLayout, QWidget, QApplication)
from PySide6.QtCore import Qt, QUrl, QTimer, QAbstractAnimation
from PySide6.QtGui import QColor, QDesktopServices
from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton
from portprotonqt.game_card import GameCard
from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader
from portprotonqt.animations import DetailPageAnimations
logger = get_logger(__name__)
class DetailPageManager:
"""Manages detail pages for games."""
def __init__(self, main_window):
self.main_window = main_window
self._detail_page_active = False
self._current_detail_page = None
self._exit_animation_in_progress = False
self._animations = {}
self.portproton_api = PortProtonAPI(Downloader(max_workers=4))
def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="",
last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
"""Open detailed game information page showing all game stats, playtime and settings."""
detailPage = QWidget()
imageLabel = QLabel()
imageLabel.setFixedSize(300, 450)
self._detail_page_active = True
self._current_detail_page = detailPage
# Store the source tab index (Library is typically index 0)
self._return_to_tab_index = 0 # Library tab
# Function to load image and restore effect
def load_image_and_restore_effect():
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping image load")
return
try:
detailPage.setWindowOpacity(1.0)
except RuntimeError:
logger.warning("Detail page is None, hidden, or no longer valid, skipping opacity set")
return
if cover_path:
def on_pixmap_ready(pixmap):
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update")
return
try:
rounded = round_corners(pixmap, 10)
imageLabel.setPixmap(rounded)
logger.debug("Pixmap set for imageLabel")
def on_palette_ready(palette):
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update")
return
try:
dark_palette = [self.main_window.darkenColor(color, factor=200) for color in palette]
stops = ",\n".join(
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
)
detailPage.setStyleSheet(self.main_window.theme.detail_page_style(stops))
detailPage.update()
logger.debug("Stylesheet updated with palette")
except RuntimeError:
logger.warning("Detail page already deleted, skipping palette stylesheet update")
self.main_window.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
except RuntimeError:
logger.warning("Detail page already deleted, skipping pixmap update")
load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
else:
try:
detailPage.setStyleSheet(self.main_window.theme.DETAIL_PAGE_NO_COVER_STYLE)
detailPage.update()
except RuntimeError:
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
def cleanup_animation():
if detailPage in self._animations:
del self._animations[detailPage]
mainLayout = QVBoxLayout(detailPage)
mainLayout.setContentsMargins(30, 30, 30, 30)
mainLayout.setSpacing(20)
backButton = AutoSizeButton(_("Back"), icon=self.main_window.theme_manager.get_icon("back"))
backButton.setFixedWidth(100)
backButton.setStyleSheet(self.main_window.theme.ADDGAME_BACK_BUTTON_STYLE)
backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage))
mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft)
contentFrame = QFrame()
contentFrame.setStyleSheet(self.main_window.theme.DETAIL_CONTENT_FRAME_STYLE)
contentFrameLayout = QHBoxLayout(contentFrame)
contentFrameLayout.setContentsMargins(20, 20, 20, 20)
contentFrameLayout.setSpacing(40)
mainLayout.addWidget(contentFrame)
# Cover (at left)
coverFrame = QFrame()
coverFrame.setFixedSize(300, 450)
coverFrame.setStyleSheet(self.main_window.theme.COVER_FRAME_STYLE)
shadow = QGraphicsDropShadowEffect(coverFrame)
shadow.setBlurRadius(20)
shadow.setColor(QColor(0, 0, 0, 200))
shadow.setOffset(0, 0)
coverFrame.setGraphicsEffect(shadow)
coverLayout = QVBoxLayout(coverFrame)
coverLayout.setContentsMargins(0, 0, 0, 0)
coverLayout.addWidget(imageLabel)
# Favorite icon
favoriteLabelCover = ClickableLabel(coverFrame)
favoriteLabelCover.setFixedSize(*self.main_window.theme.favoriteLabelSize)
favoriteLabelCover.setStyleSheet(self.main_window.theme.FAVORITE_LABEL_STYLE)
favorites = read_favorites()
if name in favorites:
favoriteLabelCover.setText("")
else:
favoriteLabelCover.setText("")
favoriteLabelCover.clicked.connect(lambda: self.toggleFavoriteInDetailPage(name, favoriteLabelCover))
favoriteLabelCover.move(8, 8)
favoriteLabelCover.raise_()
# Add badges (ProtonDB, Steam, PortProton, WeAntiCheatYet)
display_filter = read_display_filter()
steam_visible = (str(game_source).lower() == "steam" and display_filter in ("all", "favorites"))
egs_visible = (str(game_source).lower() == "epic" and display_filter in ("all", "favorites"))
portproton_visible = (str(game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
right_margin = 8
badge_spacing = 5
top_y = 10
badge_y_positions = []
badge_width = int(300 * 2/3)
# ProtonDB badge
protondb_text = GameCard.getProtonDBText(protondb_tier)
if protondb_text:
icon_filename = GameCard.getProtonDBIconFilename(protondb_tier)
icon = self.main_window.theme_manager.get_icon(icon_filename, self.main_window.current_theme_name)
protondbLabel = ClickableLabel(
protondb_text,
icon=icon,
parent=coverFrame,
icon_size=16,
icon_space=3,
)
protondbLabel.setStyleSheet(self.main_window.theme.get_protondb_badge_style(protondb_tier))
protondbLabel.setFixedWidth(badge_width)
protondbLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://www.protondb.com/app/{appid}")))
protondb_visible = True
else:
protondbLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3)
protondbLabel.setFixedWidth(badge_width)
protondbLabel.setVisible(False)
protondb_visible = False
# Steam badge
steam_icon = self.main_window.theme_manager.get_icon("steam")
steamLabel = ClickableLabel(
"Steam",
icon=steam_icon,
parent=coverFrame,
icon_size=16,
icon_space=5,
)
steamLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
steamLabel.setFixedWidth(badge_width)
steamLabel.setVisible(steam_visible)
steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}")))
# Epic Games Store badge
egs_icon = self.main_window.theme_manager.get_icon("epic_games")
egsLabel = ClickableLabel(
"Epic Games",
icon=egs_icon,
parent=coverFrame,
icon_size=16,
icon_space=5,
change_cursor=False
)
egsLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
egsLabel.setFixedWidth(badge_width)
egsLabel.setVisible(egs_visible)
# PortProton badge
portproton_icon = self.main_window.theme_manager.get_icon("portproton")
portprotonLabel = ClickableLabel(
"PortProton",
icon=portproton_icon,
parent=coverFrame,
icon_size=16,
icon_space=5,
)
portprotonLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
portprotonLabel.setFixedWidth(badge_width)
portprotonLabel.setVisible(portproton_visible)
portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name))
# WeAntiCheatYet badge
anticheat_text = GameCard.getAntiCheatText(anticheat_status)
if anticheat_text:
icon_filename = GameCard.getAntiCheatIconFilename(anticheat_status)
icon = self.main_window.theme_manager.get_icon(icon_filename, self.main_window.current_theme_name)
anticheatLabel = ClickableLabel(
anticheat_text,
icon=icon,
parent=coverFrame,
icon_size=16,
icon_space=3,
)
anticheatLabel.setStyleSheet(self.main_window.theme.get_anticheat_badge_style(anticheat_status))
anticheatLabel.setFixedWidth(badge_width)
anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}")))
anticheat_visible = True
else:
anticheatLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3)
anticheatLabel.setFixedWidth(badge_width)
anticheatLabel.setVisible(False)
anticheat_visible = False
# Position badges
if steam_visible:
steam_x = 300 - badge_width - right_margin
steamLabel.move(steam_x, top_y)
badge_y_positions.append(top_y + steamLabel.height())
if egs_visible:
egs_x = 300 - badge_width - right_margin
egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
egsLabel.move(egs_x, egs_y)
badge_y_positions.append(egs_y + egsLabel.height())
if portproton_visible:
portproton_x = 300 - badge_width - right_margin
portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
portprotonLabel.move(portproton_x, portproton_y)
badge_y_positions.append(portproton_y + portprotonLabel.height())
if protondb_visible:
protondb_x = 300 - badge_width - right_margin
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
protondbLabel.move(protondb_x, protondb_y)
badge_y_positions.append(protondb_y + protondbLabel.height())
if anticheat_visible:
anticheat_x = 300 - badge_width - right_margin
anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
anticheatLabel.move(anticheat_x, anticheat_y)
anticheatLabel.raise_()
protondbLabel.raise_()
portprotonLabel.raise_()
egsLabel.raise_()
steamLabel.raise_()
contentFrameLayout.addWidget(coverFrame)
# Game details (at right)
detailsWidget = QWidget()
detailsWidget.setStyleSheet(self.main_window.theme.DETAILS_WIDGET_STYLE)
detailsLayout = QVBoxLayout(detailsWidget)
detailsLayout.setContentsMargins(20, 20, 20, 20)
detailsLayout.setSpacing(15)
titleLabel = QLabel(name)
titleLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_TITLE_STYLE)
detailsLayout.addWidget(titleLabel)
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setStyleSheet(self.main_window.theme.DETAIL_PAGE_LINE_STYLE)
detailsLayout.addWidget(line)
descLabel = QLabel(description)
descLabel.setWordWrap(True)
descLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_DESC_STYLE)
detailsLayout.addWidget(descLabel)
# Initialize HowLongToBeat
hltb = HowLongToBeat(parent=self.main_window)
# Create layout for all game info
gameInfoLayout = QVBoxLayout()
gameInfoLayout.setSpacing(10)
# First row: Last Launch and Play Time
firstRowLayout = QHBoxLayout()
firstRowLayout.setSpacing(10)
# Last Launch
lastLaunchTitle = QLabel(_("LAST LAUNCH"))
lastLaunchTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
lastLaunchValue = QLabel(last_launch)
lastLaunchValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
firstRowLayout.addWidget(lastLaunchTitle)
firstRowLayout.addWidget(lastLaunchValue)
firstRowLayout.addSpacing(30)
# Play Time
playTimeTitle = QLabel(_("PLAY TIME"))
playTimeTitle.setStyleSheet(self.main_window.theme.PLAY_TIME_TITLE_STYLE)
playTimeValue = QLabel(formatted_playtime)
playTimeValue.setStyleSheet(self.main_window.theme.PLAY_TIME_VALUE_STYLE)
firstRowLayout.addWidget(playTimeTitle)
firstRowLayout.addWidget(playTimeValue)
gameInfoLayout.addLayout(firstRowLayout)
# Create placeholder for second row (HLTB data)
hltbLayout = QHBoxLayout()
hltbLayout.setSpacing(10)
# Completion time (Main Story, Main + Sides, Completionist)
def on_hltb_results(results):
if not hasattr(self, '_detail_page_active') or not self._detail_page_active:
return
if not self._current_detail_page or self._current_detail_page.isHidden() or not self._current_detail_page.parent():
return
# Additional check: make sure the detail page in the stacked widget is still our current detail page
if self.main_window.stackedWidget.currentWidget() != self._current_detail_page and self._current_detail_page not in [self.main_window.stackedWidget.widget(i) for i in range(self.main_window.stackedWidget.count())]:
return
if results:
game = results[0] # Take first result
main_story_time = hltb.format_game_time(game, "main_story")
main_extra_time = hltb.format_game_time(game, "main_extra")
completionist_time = hltb.format_game_time(game, "completionist")
# Clear layout before adding new elements
def clear_layout(layout):
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
sublayout = item.layout()
if widget:
widget.deleteLater()
elif sublayout:
clear_layout(sublayout)
clear_layout(hltbLayout)
has_data = False
if main_story_time is not None:
try:
mainStoryTitle = QLabel(_("MAIN STORY"))
mainStoryTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
mainStoryValue = QLabel(main_story_time)
mainStoryValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
hltbLayout.addWidget(mainStoryTitle)
hltbLayout.addWidget(mainStoryValue)
hltbLayout.addSpacing(30)
has_data = True
except RuntimeError:
logger.warning("Detail page already deleted, skipping main story time update")
if main_extra_time is not None:
try:
mainExtraTitle = QLabel(_("MAIN + SIDES"))
mainExtraTitle.setStyleSheet(self.main_window.theme.PLAY_TIME_TITLE_STYLE)
mainExtraValue = QLabel(main_extra_time)
mainExtraValue.setStyleSheet(self.main_window.theme.PLAY_TIME_VALUE_STYLE)
hltbLayout.addWidget(mainExtraTitle)
hltbLayout.addWidget(mainExtraValue)
hltbLayout.addSpacing(30)
has_data = True
except RuntimeError:
logger.warning("Detail page already deleted, skipping main extra time update")
if completionist_time is not None:
try:
completionistTitle = QLabel(_("COMPLETIONIST"))
completionistTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
completionistValue = QLabel(completionist_time)
completionistValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
hltbLayout.addWidget(completionistTitle)
hltbLayout.addWidget(completionistValue)
has_data = True
except RuntimeError:
logger.warning("Detail page already deleted, skipping completionist time update")
# If there's data, add the layout to the second row
if has_data:
gameInfoLayout.addLayout(hltbLayout)
# Connect searchCompleted signal to on_hltb_results
hltb.searchCompleted.connect(on_hltb_results)
# Start search in background thread
hltb.search_with_callback(name, case_sensitive=False)
# Add the game info layout
detailsLayout.addLayout(gameInfoLayout)
if controller_support:
cs = controller_support.lower()
translated_cs = ""
if cs == "full":
translated_cs = _("full")
elif cs == "partial":
translated_cs = _("partial")
elif cs == "none":
translated_cs = _("none")
gamepadSupportLabel = QLabel(_("Gamepad Support: {0}").format(translated_cs))
gamepadSupportLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
gamepadSupportLabel.setStyleSheet(self.main_window.theme.GAMEPAD_SUPPORT_VALUE_STYLE)
detailsLayout.addWidget(gamepadSupportLabel, alignment=Qt.AlignmentFlag.AlignCenter)
detailsLayout.addStretch(1)
# Determine current game ID from exec_line
entry_exec_split = shlex.split(exec_line)
if not entry_exec_split:
return
if entry_exec_split[0] == "env":
file_to_check = entry_exec_split[2] if len(entry_exec_split) >= 3 else None
elif entry_exec_split[0] == "flatpak":
file_to_check = entry_exec_split[3] if len(entry_exec_split) >= 4 else None
else:
file_to_check = entry_exec_split[0]
current_exe = os.path.basename(file_to_check) if file_to_check else None
buttons_layout = QHBoxLayout()
if self.main_window.target_exe is not None and current_exe == self.main_window.target_exe:
playButton = AutoSizeButton(_("Stop"), icon=self.main_window.theme_manager.get_icon("stop"))
else:
playButton = AutoSizeButton(_("Play"), icon=self.main_window.theme_manager.get_icon("play"))
playButton.setFixedSize(120, 40)
playButton.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
playButton.clicked.connect(lambda: self.main_window.toggleGame(exec_line, playButton))
buttons_layout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
# Settings button
settings_icon = self.main_window.theme_manager.get_icon("settings")
settings_button = AutoSizeButton(_("Settings"), icon=settings_icon)
settings_button.setFixedSize(120, 40)
settings_button.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
settings_button.clicked.connect(lambda: self.main_window.open_exe_settings(file_to_check))
buttons_layout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
buttons_layout.addStretch()
detailsLayout.addLayout(buttons_layout)
contentFrameLayout.addWidget(detailsWidget)
mainLayout.addStretch()
self.main_window.stackedWidget.addWidget(detailPage)
self.main_window.stackedWidget.setCurrentWidget(detailPage)
self.main_window.currentDetailPage = detailPage
self.main_window.current_exec_line = exec_line
self.main_window.current_play_button = playButton
# Animation
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
# Update page reference
self.main_window.currentDetailPage = detailPage
original_load = load_image_and_restore_effect
def enhanced_load():
original_load()
QTimer.singleShot(50, try_set_focus)
def try_set_focus():
if not (playButton and not playButton.isHidden()):
return
# Ensure page is active
self.main_window.stackedWidget.setCurrentWidget(detailPage)
detailPage.setFocus(Qt.FocusReason.OtherFocusReason)
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
playButton.update()
detailPage.raise_()
self.main_window.activateWindow()
if playButton.hasFocus():
logger.debug("Play button successfully received focus")
else:
logger.debug("Retrying focus...")
QTimer.singleShot(20, retry_focus)
def retry_focus():
if not (playButton and not playButton.isHidden() and not playButton.hasFocus()):
return
# Process events to ensure UI state is updated
QApplication.processEvents()
self.main_window.activateWindow()
self.main_window.stackedWidget.setCurrentWidget(detailPage)
detailPage.raise_()
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
playButton.update()
if not playButton.hasFocus():
logger.debug("Final retry...")
playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
if playButton.hasFocus():
logger.debug("Play button received focus after final retry")
else:
logger.debug("Play button still doesn't have focus")
detail_animations.animate_detail_page(
detailPage,
enhanced_load,
cleanup_animation
)
def openAutoInstallDetailPage(self, name, description, cover_path=None, exec_line="", game_source=""):
"""Open minimal detail page for auto-install games with name, description, cover, and install button."""
detailPage = QWidget()
imageLabel = QLabel()
imageLabel.setFixedSize(300, 450)
self._detail_page_active = True
self._current_detail_page = detailPage
# Store the source tab index (Auto Install is typically index 1)
self._return_to_tab_index = 1 # Auto Install tab
# Try to get the description from downloaded metadata for richer content
script_name = ""
if exec_line and exec_line.startswith("autoinstall:"):
script_name = exec_line[11:].lstrip(':').strip()
if script_name:
# Get localized description based on current UI language
# Import locale module to detect current locale
import locale
try:
current_locale = locale.getlocale()[0] or 'en'
except Exception:
current_locale = 'en'
lang_code = 'ru' if current_locale and 'ru' in current_locale.lower() else 'en'
metadata_description = self.portproton_api.get_autoinstall_description(script_name, lang_code)
if metadata_description:
description = metadata_description
# Function to load image and restore effect
def load_image_and_restore_effect():
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping image load")
return
try:
detailPage.setWindowOpacity(1.0)
except RuntimeError:
logger.warning("Detail page is None, hidden, or no longer valid, skipping opacity set")
return
if cover_path:
def on_pixmap_ready(pixmap):
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update")
return
try:
rounded = round_corners(pixmap, 10)
imageLabel.setPixmap(rounded)
logger.debug("Pixmap set for imageLabel")
def on_palette_ready(palette):
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update")
return
try:
dark_palette = [self.main_window.darkenColor(color, factor=200) for color in palette]
stops = ",\n".join(
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
)
detailPage.setStyleSheet(self.main_window.theme.detail_page_style(stops))
detailPage.update()
logger.debug("Stylesheet updated with palette")
except RuntimeError:
logger.warning("Detail page already deleted, skipping palette stylesheet update")
self.main_window.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
except RuntimeError:
logger.warning("Detail page already deleted, skipping pixmap update")
load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
else:
try:
detailPage.setStyleSheet(self.main_window.theme.DETAIL_PAGE_NO_COVER_STYLE)
detailPage.update()
except RuntimeError:
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
def cleanup_animation():
if detailPage in self._animations:
del self._animations[detailPage]
mainLayout = QVBoxLayout(detailPage)
mainLayout.setContentsMargins(30, 30, 30, 30)
mainLayout.setSpacing(20)
backButton = AutoSizeButton(_("Back"), icon=self.main_window.theme_manager.get_icon("back"))
backButton.setFixedWidth(100)
backButton.setStyleSheet(self.main_window.theme.ADDGAME_BACK_BUTTON_STYLE)
backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage))
mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft)
contentFrame = QFrame()
contentFrame.setStyleSheet(self.main_window.theme.DETAIL_CONTENT_FRAME_STYLE)
contentFrameLayout = QHBoxLayout(contentFrame)
contentFrameLayout.setContentsMargins(20, 20, 20, 20)
contentFrameLayout.setSpacing(40)
mainLayout.addWidget(contentFrame)
# Cover (at left)
coverFrame = QFrame()
coverFrame.setFixedSize(300, 450)
coverFrame.setStyleSheet(self.main_window.theme.COVER_FRAME_STYLE)
shadow = QGraphicsDropShadowEffect(coverFrame)
shadow.setBlurRadius(20)
shadow.setColor(QColor(0, 0, 0, 200))
shadow.setOffset(0, 0)
coverFrame.setGraphicsEffect(shadow)
coverLayout = QVBoxLayout(coverFrame)
coverLayout.setContentsMargins(0, 0, 0, 0)
coverLayout.addWidget(imageLabel)
# No favorite icon for auto-install games
# No badges for auto-install detail page
contentFrameLayout.addWidget(coverFrame)
# Game details (at right) - minimal version without time info
detailsWidget = QWidget()
detailsWidget.setStyleSheet(self.main_window.theme.DETAILS_WIDGET_STYLE)
detailsLayout = QVBoxLayout(detailsWidget)
detailsLayout.setContentsMargins(20, 20, 20, 20)
detailsLayout.setSpacing(15)
titleLabel = QLabel(name)
titleLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_TITLE_STYLE)
detailsLayout.addWidget(titleLabel)
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setStyleSheet(self.main_window.theme.DETAIL_PAGE_LINE_STYLE)
detailsLayout.addWidget(line)
descLabel = QLabel(description)
descLabel.setWordWrap(True)
descLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_DESC_STYLE)
detailsLayout.addWidget(descLabel)
# No HLTB data, playtime, or launch info for auto install
detailsLayout.addStretch(1)
buttons_layout = QHBoxLayout()
# The script_name was already extracted at the beginning of the function
# Determine if game is already installed based on whether .desktop files exist for the script
game_installed = self.is_autoinstall_game_installed(script_name, name) if script_name else False
install_button_text = _("Reinstall") if game_installed else _("Install")
# Use update icon for reinstall, save icon for initial install
install_button_icon = self.main_window.theme_manager.get_icon("update" if game_installed else "save")
installButton = AutoSizeButton(install_button_text, icon=install_button_icon)
installButton.setFixedSize(120, 40)
installButton.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
installButton.clicked.connect(lambda: self.main_window.launch_autoinstall(script_name))
buttons_layout.addWidget(installButton, alignment=Qt.AlignmentFlag.AlignLeft)
buttons_layout.addStretch()
detailsLayout.addLayout(buttons_layout)
contentFrameLayout.addWidget(detailsWidget)
mainLayout.addStretch()
self.main_window.stackedWidget.addWidget(detailPage)
self.main_window.stackedWidget.setCurrentWidget(detailPage)
self.main_window.currentDetailPage = detailPage
# Animation
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
# Update page reference
self.main_window.currentDetailPage = detailPage
original_load = load_image_and_restore_effect
def enhanced_load():
original_load()
QTimer.singleShot(50, try_set_focus)
def try_set_focus():
if not (installButton and not installButton.isHidden()):
return
# Ensure page is active
self.main_window.stackedWidget.setCurrentWidget(detailPage)
detailPage.setFocus(Qt.FocusReason.OtherFocusReason)
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
installButton.update()
detailPage.raise_()
self.main_window.activateWindow()
if installButton.hasFocus():
logger.debug("Install button successfully received focus")
else:
logger.debug("Retrying focus...")
QTimer.singleShot(20, retry_focus)
def retry_focus():
if not (installButton and not installButton.isHidden() and not installButton.hasFocus()):
return
# Process events to ensure UI state is updated
QApplication.processEvents()
self.main_window.activateWindow()
self.main_window.stackedWidget.setCurrentWidget(detailPage)
detailPage.raise_()
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
installButton.update()
if not installButton.hasFocus():
logger.debug("Final retry...")
installButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
if installButton.hasFocus():
logger.debug("Install button received focus after final retry")
else:
logger.debug("Install button still doesn't have focus")
detail_animations.animate_detail_page(
detailPage,
enhanced_load,
cleanup_animation
)
def is_autoinstall_game_installed(self, script_name, game_name):
"""Check if an auto-install game is already installed by looking for .desktop files."""
if not self.main_window.portproton_location:
return False
# Look for .desktop files that might match this game/script
try:
desktop_files = os.listdir(self.main_window.portproton_location)
for file in desktop_files:
if file.endswith('.desktop'):
# Check if the desktop file contains references to the script or game name
try:
with open(os.path.join(self.main_window.portproton_location, file), encoding='utf-8') as f:
content = f.read()
if script_name.lower() in content.lower() or game_name.lower() in content.lower():
return True
except (OSError, UnicodeDecodeError):
continue
except (OSError, AttributeError):
pass
return False
def toggleFavoriteInDetailPage(self, game_name, label):
favorites = read_favorites()
if game_name in favorites:
favorites.remove(game_name)
label.setText("")
else:
favorites.append(game_name)
label.setText("")
save_favorites(favorites)
self.main_window.game_library_manager.update_game_grid()
def goBackDetailPage(self, page: QWidget | None) -> None:
if page is None or page != self.main_window.stackedWidget.currentWidget() or getattr(self, '_exit_animation_in_progress', False):
return
self._exit_animation_in_progress = True
self._detail_page_active = False
self._current_detail_page = None
def cleanup():
"""Helper function to clean up after animation."""
try:
# Stop and clean up any existing animations for this page
if hasattr(self, '_animations') and page in self._animations:
try:
animation = self._animations[page]
if isinstance(animation, QAbstractAnimation):
if animation.state() == QAbstractAnimation.State.Running:
animation.stop()
# Since animation is set to delete when stopped, we don't manually delete it
del self._animations[page]
except (KeyError, RuntimeError):
pass # Animation already deleted or not found
# Ensure page is still valid before trying to remove it
# Check if page is still in the stacked widget by iterating through all widgets
page_found = False
for i in range(self.main_window.stackedWidget.count()):
if self.main_window.stackedWidget.widget(i) is page:
page_found = True
break
if page_found:
# Remove the detail page widget
self.main_window.stackedWidget.removeWidget(page)
# Go back to the tab where the detail page was opened from
return_tab_index = getattr(self, '_return_to_tab_index', 0) # Default to library tab
self.main_window.stackedWidget.setCurrentIndex(return_tab_index)
# Ensure proper layout update after returning to the tab
# This is important when a refresh happened while detail page was open
if return_tab_index == 0: # Library tab
if hasattr(self.main_window, 'game_library_manager'):
QTimer.singleShot(10, lambda: self.main_window.game_library_manager.update_game_grid())
elif return_tab_index == 1: # Auto Install tab
# Force update of the auto install container layout
if hasattr(self.main_window, 'autoInstallContainer'):
QTimer.singleShot(10, lambda: self.main_window.autoInstallContainer.updateGeometry())
if hasattr(self.main_window, 'autoInstallContainerLayout'):
QTimer.singleShot(15, lambda: self.main_window.autoInstallContainerLayout.update())
else:
logger.debug("Page not found in stacked widget, may have been removed already")
# Clear references to avoid dangling references
if hasattr(self.main_window, 'currentDetailPage'):
self.main_window.currentDetailPage = None
if hasattr(self.main_window, 'current_exec_line'):
self.main_window.current_exec_line = None
if hasattr(self.main_window, 'current_play_button'):
self.main_window.current_play_button = None
self._exit_animation_in_progress = False
except RuntimeError:
# Widget was already deleted, which is expected after deleteLater()
logger.debug("Detail page already deleted during cleanup")
self._exit_animation_in_progress = False
except Exception as e:
logger.error(f"Unexpected error in cleanup: {e}", exc_info=True)
self._exit_animation_in_progress = False
# Start exit animation
try:
# Check if the page is still valid before starting animation
if page and not page.isHidden() and page.parent() is not None:
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
detail_animations.animate_detail_page_exit(page, cleanup)
else:
logger.warning("Detail page not valid, bypassing animation and cleaning up directly")
self._exit_animation_in_progress = False
cleanup()
except Exception as e:
logger.error(f"Error starting exit animation: {e}", exc_info=True)
self._exit_animation_in_progress = False
cleanup() # Fallback to cleanup if animation fails
def open_portproton_forum_topic(self, name):
result = self.portproton_api.get_forum_topic_slug(name)
base_url = "https://linux-gaming.ru/"
if result.startswith("search?q="):
url = QUrl(f"{base_url}{result}")
else:
url = QUrl(f"{base_url}t/{result}")
QDesktopServices.openUrl(url)

View File

@@ -10,7 +10,7 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
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, read_theme_from_config from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command, 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
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
@@ -853,7 +853,6 @@ class AddGameDialog(QDialog):
from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.edit_mode = edit_mode self.edit_mode = edit_mode
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
self.last_cover_path = cover_path # Store last selected cover path self.last_cover_path = cover_path # Store last selected cover path
self.downloader = Downloader(max_workers=4) # Initialize Downloader self.downloader = Downloader(max_workers=4) # Initialize Downloader
@@ -906,6 +905,7 @@ class AddGameDialog(QDialog):
self.coverEdit = CustomLineEdit(self, theme=self.theme) self.coverEdit = CustomLineEdit(self, theme=self.theme)
self.coverEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE) self.coverEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.coverEdit.setPlaceholderText(_("Enter local path or URL for cover image"))
if cover_path: if cover_path:
self.coverEdit.setText(cover_path) self.coverEdit.setText(cover_path)
@@ -949,7 +949,12 @@ class AddGameDialog(QDialog):
# Подключение сигналов # Подключение сигналов
self.select_button.clicked.connect(self.accept) self.select_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
self.coverEdit.textChanged.connect(self.updatePreview) # Set up a timer for debounced cover preview updates
self.cover_preview_timer = QTimer(self)
self.cover_preview_timer.setSingleShot(True)
self.cover_preview_timer.timeout.connect(self.updatePreview)
self.coverEdit.textChanged.connect(self.onCoverTextChanged)
self.exeEdit.textChanged.connect(self.updatePreview) self.exeEdit.textChanged.connect(self.updatePreview)
# Установка одинаковой ширины для кнопок и полей ввода # Установка одинаковой ширины для кнопок и полей ввода
@@ -1083,33 +1088,51 @@ class AddGameDialog(QDialog):
def handleDownloadedCover(self, file_path): def handleDownloadedCover(self, file_path):
"""Handle the downloaded cover image and update the preview.""" """Handle the downloaded cover image and update the preview."""
if file_path and os.path.isfile(file_path): # Check if the dialog or widget has been destroyed before updating
self.last_cover_path = file_path if not hasattr(self, 'coverPreview') or self.coverPreview is None:
pixmap = QPixmap(file_path) return
if not pixmap.isNull():
self.coverPreview.setPixmap(pixmap.scaled(250, 250, Qt.AspectRatioMode.KeepAspectRatio)) try:
if file_path and os.path.isfile(file_path):
self.last_cover_path = file_path
pixmap = QPixmap(file_path)
if not pixmap.isNull():
self.coverPreview.setPixmap(pixmap.scaled(250, 250, Qt.AspectRatioMode.KeepAspectRatio))
else:
self.coverPreview.setText(_("Invalid image"))
else: else:
self.coverPreview.setText(_("Invalid image")) self.coverPreview.setText(_("Failed to download cover"))
else: logger.warning(f"Failed to download cover to {file_path}")
self.coverPreview.setText(_("Failed to download cover")) except RuntimeError:
logger.warning(f"Failed to download cover to {file_path}") # Handle the case where the Qt object was deleted
pass
def onCoverTextChanged(self):
"""Handle cover text changes with debounce."""
# Restart the timer to delay the preview update
self.cover_preview_timer.start(500) # 500ms delay
def updatePreview(self): def updatePreview(self):
"""Update the cover preview image.""" """Update the cover preview image."""
cover_path = self.coverEdit.text().strip() cover_path = self.coverEdit.text().strip()
exe_path = self.exeEdit.text().strip() exe_path = self.exeEdit.text().strip()
# Check if cover_path is a URL # Check if cover_path is a URL by checking for common image extensions
url_pattern = r'^https?://[^\s/$.?#].[^\s]*$' image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
if re.match(url_pattern, cover_path): has_image_extension = any(cover_path.lower().endswith(ext) for ext in image_extensions)
# Consider it a URL if it has image extension and is not a local file
if has_image_extension and not os.path.isfile(cover_path):
# Create a temporary file for the downloaded image # Create a temporary file for the downloaded image
fd, local_path = tempfile.mkstemp(suffix=".png") fd, local_path = tempfile.mkstemp(suffix=".png")
os.close(fd) os.close(fd)
os.unlink(local_path) os.unlink(local_path)
# Start asynchronous download # Start asynchronous download
# Add protocol if not present
download_url = cover_path if cover_path.startswith(('http://', 'https://')) else f'https://{cover_path}'
self.downloader.download_async( self.downloader.download_async(
url=cover_path, url=download_url,
local_path=local_path, local_path=local_path,
timeout=10, timeout=10,
callback=self.handleDownloadedCover callback=self.handleDownloadedCover
@@ -1324,6 +1347,7 @@ class WinetricksDialog(QDialog):
# Log output # Log output
self.log_output = QTextEdit() self.log_output = QTextEdit()
self.log_output.setReadOnly(True) self.log_output.setReadOnly(True)
self.log_output.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE) self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
self.main_layout.addWidget(self.log_output) self.main_layout.addWidget(self.log_output)
@@ -1337,6 +1361,7 @@ class WinetricksDialog(QDialog):
self.dll_table = QTableWidget() self.dll_table = QTableWidget()
self.dll_table.setAlternatingRowColors(True) self.dll_table.setAlternatingRowColors(True)
self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.dll_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
# self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) # self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -1370,6 +1395,7 @@ class WinetricksDialog(QDialog):
self.fonts_table = QTableWidget() self.fonts_table = QTableWidget()
self.fonts_table.setAlternatingRowColors(True) self.fonts_table.setAlternatingRowColors(True)
self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.fonts_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
# self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) # self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -1403,6 +1429,7 @@ class WinetricksDialog(QDialog):
self.settings_table = QTableWidget() self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True) self.settings_table.setAlternatingRowColors(True)
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.settings_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
# self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) # self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -1701,8 +1728,10 @@ class ExeSettingsDialog(QDialog):
if self.portproton_path is None: if self.portproton_path is None:
logger.error("PortProton location not found") logger.error("PortProton location not found")
return return
base_path = os.path.join(self.portproton_path, "data") self.start_sh = get_portproton_start_command()
self.start_sh = [os.path.join(base_path, "scripts", "start.sh")] if self.start_sh is None:
logger.error("PortProton start command not found")
return
self.dist_options = [] self.dist_options = []
self.prefix_options = [] self.prefix_options = []
@@ -1776,9 +1805,9 @@ class ExeSettingsDialog(QDialog):
self.load_current_settings() self.load_current_settings()
def _get_process_args(self, subcommand_args): def _get_process_args(self, subcommand_args):
"""Get the full arguments for QProcess.start, handling flatpak separator.""" """Get the full arguments for QProcess.start, handling flatpak format."""
if self.start_sh[0] == "flatpak": if self.start_sh and self.start_sh[0] == "flatpak":
return self.start_sh[1:] + ["--"] + subcommand_args return self.start_sh + subcommand_args
else: else:
return self.start_sh + subcommand_args return self.start_sh + subcommand_args
@@ -1814,13 +1843,12 @@ class ExeSettingsDialog(QDialog):
# Connect tab change to update description hint # Connect tab change to update description hint
self.tab_widget.currentChanged.connect(self.on_table_selection_changed) self.tab_widget.currentChanged.connect(self.on_table_selection_changed)
# Main settings table # Main settings table with preloader
self.settings_table = QTableWidget() self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True) self.settings_table.setAlternatingRowColors(True)
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.settings_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.settings_table.setColumnCount(3) self.settings_table.setColumnCount(3)
self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")]) self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
@@ -1831,16 +1859,36 @@ class ExeSettingsDialog(QDialog):
self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone) self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone)
self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE) self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
self.main_tab_layout.addWidget(self.settings_table)
# Create preloader for main settings table
self.settings_preloader = Preloader()
settings_preloader_container = QWidget()
settings_preloader_layout = QVBoxLayout(settings_preloader_container)
settings_preloader_layout.addStretch()
settings_preloader_hlayout = QHBoxLayout()
settings_preloader_hlayout.addStretch()
settings_preloader_hlayout.addWidget(self.settings_preloader)
settings_preloader_hlayout.addStretch()
settings_preloader_layout.addLayout(settings_preloader_hlayout)
settings_preloader_layout.addStretch()
settings_preloader_layout.setContentsMargins(0, 0, 0, 0)
settings_preloader_layout.setSpacing(0)
# Create stacked widget for main settings
self.settings_container = QStackedWidget()
self.settings_container.addWidget(settings_preloader_container) # Index 0: preloader
self.settings_container.addWidget(self.settings_table) # Index 1: table
self.main_tab_layout.addWidget(self.settings_container)
# Connect selection changed signal for the main table # Connect selection changed signal for the main table
self.settings_table.currentCellChanged.connect(self.on_table_selection_changed) self.settings_table.currentCellChanged.connect(self.on_table_selection_changed)
# Advanced settings table # Advanced settings table with preloader
self.advanced_table = QTableWidget() self.advanced_table = QTableWidget()
self.advanced_table.setAlternatingRowColors(True) self.advanced_table.setAlternatingRowColors(True)
self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.advanced_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
# self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) # self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.advanced_table.setColumnCount(3) self.advanced_table.setColumnCount(3)
self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")]) self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
@@ -1852,7 +1900,26 @@ class ExeSettingsDialog(QDialog):
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone) self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE) self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
self.advanced_tab_layout.addWidget(self.advanced_table)
# Create preloader for advanced settings table
self.advanced_preloader = Preloader()
advanced_preloader_container = QWidget()
advanced_preloader_layout = QVBoxLayout(advanced_preloader_container)
advanced_preloader_layout.addStretch()
advanced_preloader_hlayout = QHBoxLayout()
advanced_preloader_hlayout.addStretch()
advanced_preloader_hlayout.addWidget(self.advanced_preloader)
advanced_preloader_hlayout.addStretch()
advanced_preloader_layout.addLayout(advanced_preloader_hlayout)
advanced_preloader_layout.addStretch()
advanced_preloader_layout.setContentsMargins(0, 0, 0, 0)
advanced_preloader_layout.setSpacing(0)
# Create stacked widget for advanced settings
self.advanced_container = QStackedWidget()
self.advanced_container.addWidget(advanced_preloader_container) # Index 0: preloader
self.advanced_container.addWidget(self.advanced_table) # Index 1: table
self.advanced_tab_layout.addWidget(self.advanced_container)
# Connect selection changed signal for the advanced table # Connect selection changed signal for the advanced table
self.advanced_table.currentCellChanged.connect(self.on_table_selection_changed) self.advanced_table.currentCellChanged.connect(self.on_table_selection_changed)
@@ -1890,9 +1957,14 @@ class ExeSettingsDialog(QDialog):
def load_current_settings(self): def load_current_settings(self):
"""Load available toggles first, then current settings.""" """Load available toggles first, then current settings."""
# Show preloaders initially
self.settings_container.setCurrentIndex(0) # Show preloader for main settings
self.advanced_container.setCurrentIndex(0) # Show preloader for advanced settings
process = QProcess(self) process = QProcess(self)
process.finished.connect(self.on_list_db_finished) process.finished.connect(self.on_list_db_finished)
process.start(self.start_sh[0], ["cli", "--list-db"]) args = self._get_process_args(["cli", "--list-db"])
process.start(args[0], args[1:])
def on_list_db_finished(self, exit_code, exit_status): def on_list_db_finished(self, exit_code, exit_status):
"""Handle --list-db output and extract available keys and system info.""" """Handle --list-db output and extract available keys and system info."""
@@ -1914,6 +1986,9 @@ class ExeSettingsDialog(QDialog):
if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'): if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'):
# System info # System info
k, v = line_stripped.split('=', 1) k, v = line_stripped.split('=', 1)
# Remove surrounding quotes from the value if present
if v.startswith('"') and v.endswith('"') and len(v) >= 2:
v = v[1:-1]
if k.startswith('NUMA_NODE_'): if k.startswith('NUMA_NODE_'):
node_id = k[10:] node_id = k[10:]
self.numa_nodes[node_id] = v self.numa_nodes[node_id] = v
@@ -1939,7 +2014,8 @@ class ExeSettingsDialog(QDialog):
# Load current settings # Load current settings
process = QProcess(self) process = QProcess(self)
process.finished.connect(self.on_show_ppdb_finished) process.finished.connect(self.on_show_ppdb_finished)
process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"]) args = self._get_process_args(["cli", "--show-ppdb", f"{self.exe_path}"])
process.start(args[0], args[1:])
def on_show_ppdb_finished(self, exit_code, exit_status): def on_show_ppdb_finished(self, exit_code, exit_status):
"""Handle --show-ppdb output.""" """Handle --show-ppdb output."""
@@ -1959,6 +2035,9 @@ class ExeSettingsDialog(QDialog):
try: try:
key, val = line_stripped.split('=', 1) key, val = line_stripped.split('=', 1)
if key in self.toggle_settings or key in ADVANCED_SETTING_KEYS: if key in self.toggle_settings or key in ADVANCED_SETTING_KEYS:
# Remove surrounding quotes from the value if present
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
val = val[1:-1]
self.current_settings[key] = val self.current_settings[key] = val
except ValueError: except ValueError:
continue continue
@@ -1979,6 +2058,10 @@ class ExeSettingsDialog(QDialog):
self.populate_table() self.populate_table()
self.populate_advanced() self.populate_advanced()
# Show the loaded content and hide preloaders
self.settings_container.setCurrentIndex(1) # Show main settings table
self.advanced_container.setCurrentIndex(1) # Show advanced settings table
def populate_table(self): def populate_table(self):
"""Populate the table with settings that are available in both lists.""" """Populate the table with settings that are available in both lists."""
self.settings_table.setRowCount(0) self.settings_table.setRowCount(0)
@@ -2001,10 +2084,8 @@ class ExeSettingsDialog(QDialog):
current_val = self.current_settings.get(toggle, '0') current_val = self.current_settings.get(toggle, '0')
is_blocked = toggle in self.blocked_keys is_blocked = toggle in self.blocked_keys
checkbox = QTableWidgetItem() checkbox = QTableWidgetItem()
checkbox.setFlags(checkbox.flags() | Qt.ItemFlag.ItemIsUserCheckable)
check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked
checkbox.setCheckState(check_state) checkbox.setCheckState(check_state)
checkbox.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if is_blocked: if is_blocked:
checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
checkbox.setBackground(QColor(240, 240, 240)) checkbox.setBackground(QColor(240, 240, 240))
@@ -2289,8 +2370,9 @@ class ExeSettingsDialog(QDialog):
process = QProcess(self) process = QProcess(self)
process.finished.connect(self.on_edit_db_finished) process.finished.connect(self.on_edit_db_finished)
args = ["cli", "--edit-db", self.exe_path] + changes process_args = ["cli", "--edit-db", self.exe_path] + changes
process.start(self.start_sh[0], args) args = self._get_process_args(process_args)
process.start(args[0], args[1:])
self.apply_button.setEnabled(False) self.apply_button.setEnabled(False)
def on_edit_db_finished(self, exit_code, exit_status): def on_edit_db_finished(self, exit_code, exit_status):
@@ -2330,43 +2412,28 @@ class ExeSettingsDialog(QDialog):
def show_gamepad_tooltip(self, show=True, text=""): def show_gamepad_tooltip(self, show=True, text=""):
"""Show or hide the gamepad tooltip with the provided text.""" """Show or hide the gamepad tooltip with the provided text."""
if show and text: if show and text:
# First set the text to measure the required size # Set the text to the tooltip
self.gamepad_tooltip.setText(text) self.gamepad_tooltip.setText(text)
# Calculate appropriate size based on text content # Temporarily set a large size to allow proper text wrapping measurement
self.gamepad_tooltip.setFixedSize(500, 300)
# Use font metrics to calculate the proper size with Qt's wrapping
font_metrics = self.gamepad_tooltip.fontMetrics() font_metrics = self.gamepad_tooltip.fontMetrics()
# Calculate text dimensions - wrap at max width
max_width = 500 # Maximum width in pixels max_width = 500 # Maximum width in pixels
text_lines = text.split('\n') # Handle multiline text
# If text is longer than can fit in a single line at max width, wrap it # Calculate the required size using Qt's text wrapping functionality
wrapped_lines = [] # We'll allow Qt to do the wrapping and measure accordingly
for line in text_lines: # Using the boundingRect with TextWordWrap flag for accurate measurement
if font_metrics.horizontalAdvance(line) <= max_width: text_rect = font_metrics.boundingRect(
wrapped_lines.append(line) 0, 0, max_width - 20, 1000, # Leave space for padding
else: Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextExpandTabs,
# Word wrap the line to fit within max width text
words = line.split(' ') )
current_line = ''
for word in words:
test_line = current_line + ' ' + word if current_line else word
if font_metrics.horizontalAdvance(test_line) <= max_width:
current_line = test_line
else:
if current_line:
wrapped_lines.append(current_line)
current_line = word
if current_line:
wrapped_lines.append(current_line)
# Set the final wrapped text # Calculate final dimensions with sufficient padding
wrapped_text = '\n'.join(wrapped_lines) required_width = min(max_width, text_rect.width() + 25) # Add padding
self.gamepad_tooltip.setText(wrapped_text) required_height = min(300, text_rect.height() + 25) # Add padding
# Calculate the required size
rect = font_metrics.boundingRect(0, 0, max_width, 1000, Qt.TextFlag.TextWordWrap, wrapped_text)
required_width = min(max_width, rect.width() + 20) # Add padding
required_height = min(300, rect.height() + 16) # Add padding, max height 300
# Position the tooltip near the currently focused cell # Position the tooltip near the currently focused cell
current_table = self.advanced_table if self.tab_widget.currentIndex() == 1 else self.settings_table current_table = self.advanced_table if self.tab_widget.currentIndex() == 1 else self.settings_table

View File

@@ -126,8 +126,6 @@ class Downloader(QObject):
self._has_internet = True self._has_internet = True
return self._has_internet return self._has_internet
def reset_internet_check(self):
self._has_internet = None
def _get_url_lock(self, url): def _get_url_lock(self, url):
with self._global_lock: with self._global_lock:
@@ -247,9 +245,6 @@ class Downloader(QObject):
with self._global_lock: with self._global_lock:
self._cache.clear() self._cache.clear()
def is_cached(self, url):
with self._global_lock:
return url in self._cache
def get_latest_legendary_release(self): def get_latest_legendary_release(self):
"""Get the latest legendary release info from GitHub API.""" """Get the latest legendary release info from GitHub API."""

View File

@@ -458,9 +458,13 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
if downloaded_count == total_covers: if downloaded_count == total_covers:
callback((True, f"Game '{game_title}' added to Steam with covers")) callback((True, f"Game '{game_title}' added to Steam with covers"))
def on_steam_apps(steam_data: tuple[list, dict]): def on_steam_apps(steam_data: tuple[list | None, dict | None]):
nonlocal steam_appid nonlocal steam_appid
steam_apps, steam_apps_index = steam_data steam_apps, steam_apps_index = steam_data
if not steam_apps or not steam_apps_index:
logger.info(f"No Steam data available for EGS game {game_title}, skipping cover download")
callback((True, f"Game '{game_title}' added to Steam"))
return
matching_app = search_app(game_title, steam_apps_index) matching_app = search_app(game_title, steam_apps_index)
steam_appid = matching_app.get("appid") if matching_app else None steam_appid = matching_app.get("appid") if matching_app else None
@@ -555,49 +559,11 @@ def get_egs_game_description_async(
cleaned = re.sub(r'[^a-z0-9 ]', '', title.lower()).strip() cleaned = re.sub(r'[^a-z0-9 ]', '', title.lower()).strip()
return re.sub(r'\s+', '-', cleaned) return re.sub(r'\s+', '-', cleaned)
def get_product_slug(namespace: str) -> str:
"""Fetches the product slug using the namespace via GraphQL."""
search_query = {
"query": """
query {
Catalog {
catalogNs(namespace: $namespace) {
mappings(pageType: "productHome") {
pageSlug
pageType
}
}
}
}
""",
"variables": {"namespace": namespace}
}
try:
response = requests.post(
"https://launcher.store.epicgames.com/graphql",
json=search_query,
headers=headers,
timeout=5
)
response.raise_for_status()
data = orjson.loads(response.content)
mappings = data.get("data", {}).get("Catalog", {}).get("catalogNs", {}).get("mappings", [])
for mapping in mappings:
if mapping.get("pageType") == "productHome":
return mapping.get("pageSlug", "")
logger.warning("No productHome slug found for namespace %s", namespace)
return ""
except requests.RequestException as e:
logger.warning("Failed to fetch product slug for namespace %s: %s", namespace, str(e))
return ""
except orjson.JSONDecodeError:
logger.warning("Invalid JSON response for namespace %s", namespace)
return ""
def fetch_legacy_description(url: str) -> str: def fetch_legacy_description(url: str) -> str:
"""Fetches description from the legacy API, handling DNS failures.""" """Fetches description from the legacy API, handling DNS failures."""
try: try:
response = requests.get(url, headers=headers, timeout=5) response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status() response.raise_for_status()
data = orjson.loads(response.content) data = orjson.loads(response.content)
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -619,6 +585,9 @@ def get_egs_game_description_async(
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e)) logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
return "" return ""
except requests.exceptions.Timeout:
logger.warning("Request timeout for legacy API %s", url)
return ""
except requests.RequestException as e: except requests.RequestException as e:
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e)) logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
return "" return ""
@@ -670,7 +639,7 @@ def get_egs_game_description_async(
url = "https://graphql.epicgames.com/graphql" url = "https://graphql.epicgames.com/graphql"
try: try:
response = requests.post(url, json=search_query, headers=headers, timeout=5) response = requests.post(url, json=search_query, headers=headers, timeout=10)
response.raise_for_status() response.raise_for_status()
data = orjson.loads(response.content) data = orjson.loads(response.content)
if namespace: if namespace:
@@ -689,6 +658,9 @@ def get_egs_game_description_async(
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])): for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
return element.get("description", ""), element.get("productSlug", "") return element.get("description", ""), element.get("productSlug", "")
return "", "" return "", ""
except requests.exceptions.Timeout:
logger.warning("GraphQL request timeout for %s with locale %s", app_name, locale)
return "", ""
except requests.RequestException as e: except requests.RequestException as e:
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e)) logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
return "", "" return "", ""
@@ -717,6 +689,10 @@ def get_egs_game_description_async(
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description) logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name) logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
except requests.exceptions.Timeout:
logger.warning("Legacy API request timed out for %s", app_name)
except Exception as e:
logger.error("Unexpected error fetching legacy API for %s: %s", app_name, str(e))
# Step 3: If still no description and no namespace, try GraphQL with title # Step 3: If still no description and no namespace, try GraphQL with title
if not description and not namespace: if not description and not namespace:
@@ -931,10 +907,14 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images") image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else "" local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
def on_steam_apps(steam_data: tuple[list, dict]): def on_steam_apps(steam_data: tuple[list | None, dict | None]):
steam_apps, steam_apps_index = steam_data steam_apps, steam_apps_index = steam_data
matching_app = search_app(title, steam_apps_index) if not steam_apps or not steam_apps_index:
steam_appid = matching_app.get("appid") if matching_app else None logger.info(f"No Steam data available for EGS game {title}, skipping appid lookup")
steam_appid = None
else:
matching_app = search_app(title, steam_apps_index)
steam_appid = matching_app.get("appid") if matching_app else None
def on_protondb_tier(protondb_tier: str): def on_protondb_tier(protondb_tier: str):
def on_description_fetched(api_description: str): def on_description_fetched(api_description: str):

View File

@@ -1,7 +1,6 @@
from PySide6.QtGui import QPainter, QColor, QDesktopServices from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable
from portprotonqt.image_utils import load_pixmap_async, round_corners from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
@@ -10,8 +9,6 @@ from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.animations import GameCardAnimations from portprotonqt.animations import GameCardAnimations
from typing import cast
class GameCard(QFrame): class GameCard(QFrame):
borderWidthChanged = Signal() borderWidthChanged = Signal()
@@ -203,13 +200,27 @@ class GameCard(QFrame):
self.update_cover_pixmap() self.update_cover_pixmap()
def update_cover_pixmap(self): def update_cover_pixmap(self):
# Check if the coverLabel still exists before trying to update it
# This prevents the "Internal C++ object already deleted" error when
# the widget has been destroyed but the async callback still executes
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
if self.base_pixmap and not self.base_pixmap.isNull(): if self.base_pixmap and not self.base_pixmap.isNull():
scaled_width = int(self.base_card_width * self._scale) scaled_width = int(self.base_card_width * self._scale)
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale)) rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
self.coverLabel.setPixmap(rounded_pixmap) try:
self.coverLabel.setPixmap(rounded_pixmap)
except RuntimeError:
# Handle the case where the Qt object was deleted between the check and the call
pass
def _position_badges(self, current_width): def _position_badges(self, current_width):
# Check if the card has been destroyed before updating
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
right_margin = int(8 * self._scale) right_margin = int(8 * self._scale)
badge_spacing = int(current_width * 0.02) badge_spacing = int(current_width * 0.02)
top_y = int(10 * self._scale) top_y = int(10 * self._scale)
@@ -228,16 +239,28 @@ class GameCard(QFrame):
if is_visible: if is_visible:
badge_x = current_width - badge_width - right_margin badge_x = current_width - badge_width - right_margin
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
badge.move(int(badge_x), int(badge_y)) try:
badge_y_positions.append(badge_y + badge.height()) badge.move(int(badge_x), int(badge_y))
badge_y_positions.append(badge_y + badge.height())
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
self.anticheatLabel.raise_() try:
self.protondbLabel.raise_() self.anticheatLabel.raise_()
self.portprotonLabel.raise_() self.protondbLabel.raise_()
self.egsLabel.raise_() self.portprotonLabel.raise_()
self.steamLabel.raise_() self.egsLabel.raise_()
self.steamLabel.raise_()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def update_scale(self): def update_scale(self):
# Check if the card has been destroyed before updating
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
scaled_width = int(self.base_card_width * self._scale) scaled_width = int(self.base_card_width * self._scale)
scaled_height = int(self.base_card_width * 1.8 * self._scale) scaled_height = int(self.base_card_width * 1.8 * self._scale)
scaled_extra = int(self.base_extra_margin * self._scale) scaled_extra = int(self.base_extra_margin * self._scale)
@@ -258,33 +281,53 @@ class GameCard(QFrame):
icon_space = int(scaled_width * 0.012) icon_space = int(scaled_width * 0.012)
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]: for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
if label is not None: if label is not None:
label.setFixedWidth(badge_width) try:
label.setIconSize(icon_size, icon_space) label.setFixedWidth(badge_width)
label.setCardWidth(scaled_width) label.setIconSize(icon_size, icon_space)
label.setCardWidth(scaled_width)
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
self._position_badges(scaled_width) self._position_badges(scaled_width)
if self.base_font_size is not None: if self.base_font_size is not None:
font = self.nameLabel.font() try:
new_font_size = self.base_font_size * self._scale font = self.nameLabel.font()
if new_font_size > 0: new_font_size = self.base_font_size * self._scale
font.setPointSizeF(new_font_size) if new_font_size > 0:
self.nameLabel.setFont(font) font.setPointSizeF(new_font_size)
self.nameLabel.setFont(font)
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
self.shadow.setBlurRadius(int(20 * self._scale)) try:
self.shadow.setBlurRadius(int(20 * self._scale))
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
self.updateGeometry() try:
self.update() self.updateGeometry()
self.update()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
# Ensure parent layout is updated safely # Ensure parent layout is updated safely
parent = self.parentWidget() try:
if parent: parent = self.parentWidget()
layout = parent.layout() if parent:
if layout: layout = parent.layout()
layout.invalidate() if layout:
layout.activate() layout.invalidate()
layout.update() layout.activate()
parent.updateGeometry() layout.update()
parent.updateGeometry()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def update_card_size(self, new_width: int): def update_card_size(self, new_width: int):
self.base_card_width = new_width self.base_card_width = new_width
@@ -292,6 +335,10 @@ class GameCard(QFrame):
self.update_scale() self.update_scale()
def update_badge_visibility(self, display_filter: str): def update_badge_visibility(self, display_filter: str):
# Check if the card has been destroyed before updating
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
self.display_filter = display_filter self.display_filter = display_filter
self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites")) self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
@@ -299,11 +346,15 @@ class GameCard(QFrame):
protondb_visible = bool(self.getProtonDBText(self.protondb_tier)) protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
self.steamLabel.setVisible(self.steam_visible) try:
self.egsLabel.setVisible(self.egs_visible) self.steamLabel.setVisible(self.steam_visible)
self.portprotonLabel.setVisible(self.portproton_visible) self.egsLabel.setVisible(self.egs_visible)
self.protondbLabel.setVisible(protondb_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.anticheatLabel.setVisible(anticheat_visible) self.protondbLabel.setVisible(protondb_visible)
self.anticheatLabel.setVisible(anticheat_visible)
except RuntimeError:
# Handle the case where the Qt object was deleted
return
scaled_width = int(self.base_card_width * self._scale) scaled_width = int(self.base_card_width * self._scale)
self._position_badges(scaled_width) self._position_badges(scaled_width)
@@ -398,18 +449,33 @@ class GameCard(QFrame):
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)
def update_favorite_icon(self): def update_favorite_icon(self):
if self.is_favorite: # Check if the card has been destroyed before updating
self.favoriteLabel.setText("") if not hasattr(self, 'coverLabel') or self.coverLabel is None:
else: return
self.favoriteLabel.setText("")
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
parent = self.parent() try:
while parent: if self.is_favorite:
if hasattr(parent, 'game_library_manager'): self.favoriteLabel.setText("")
QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined] else:
break self.favoriteLabel.setText("")
parent = parent.parent() self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
except RuntimeError:
# Handle the case where the Qt object was deleted
return
try:
parent = self.parent()
while parent:
if hasattr(parent, 'game_library_manager'):
# Access using getattr with default to avoid Ruff B009 warning
manager = getattr(parent, 'game_library_manager', None)
if manager is not None:
QTimer.singleShot(0, manager.update_game_grid)
break
parent = parent.parent()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def toggle_favorite(self): def toggle_favorite(self):
favorites = read_favorites() favorites = read_favorites()
@@ -452,9 +518,9 @@ class GameCard(QFrame):
self.update_scale() self.update_scale()
self.scaleChanged.emit() self.scaleChanged.emit()
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged)) borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=borderWidthChanged)
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged)) gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=gradientAngleChanged)
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged)) scale = Property(float, getScale, setScale, None, "", notify=scaleChanged)
def paintEvent(self, event): def paintEvent(self, event):
@@ -494,6 +560,23 @@ class GameCard(QFrame):
) )
super().mousePressEvent(event) super().mousePressEvent(event)
def cleanup(self):
"""Clean up animations to prevent memory leaks when the card is destroyed."""
if hasattr(self, 'animations') and self.animations:
try:
self.animations.cleanup()
except RuntimeError:
# Object already deleted
pass
def __del__(self):
"""Destructor to ensure cleanup happens."""
try:
self.cleanup()
except RuntimeError:
# Object already deleted
pass
def keyPressEvent(self, event): def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):

View File

@@ -1,5 +1,6 @@
from typing import Protocol from typing import Protocol
from portprotonqt.game_card import GameCard from portprotonqt.game_card import GameCard
from portprotonqt.search_utils import SearchOptimizer, ThreadedSearch
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import Qt, QTimer
from portprotonqt.custom_widgets import FlowLayout from portprotonqt.custom_widgets import FlowLayout
@@ -32,7 +33,6 @@ class MainWindowProtocol(Protocol):
# Required attributes # Required attributes
searchEdit: CustomLineEdit searchEdit: CustomLineEdit
_last_card_width: int
card_width: int card_width: int
current_hovered_card: GameCard | None current_hovered_card: GameCard | None
current_focused_card: GameCard | None current_focused_card: GameCard | None
@@ -56,6 +56,9 @@ class GameLibraryManager:
self.pending_deletions = deque() self.pending_deletions = deque()
self.is_filtering = False self.is_filtering = False
self.dirty = False self.dirty = False
# Initialize search optimizer
self.search_optimizer = SearchOptimizer()
self.search_thread: ThreadedSearch | None = None
def create_games_library_widget(self): def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider.""" """Creates the games library widget with search, grid, and slider."""
@@ -130,7 +133,6 @@ class GameLibraryManager:
self.sizeSlider.setToolTip(f"{self.card_width} px") self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width) save_card_size(self.card_width)
self.main_window.card_width = self.card_width self.main_window.card_width = self.card_width
self.main_window._last_card_width = self.card_width
for card in self.game_card_cache.values(): for card in self.game_card_cache.values():
card.update_card_size(self.card_width) card.update_card_size(self.card_width)
self.update_game_grid() self.update_game_grid()
@@ -163,12 +165,18 @@ class GameLibraryManager:
if is_focused: if is_focused:
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False try:
self.main_window.current_hovered_card.leaveEvent(None) self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
except RuntimeError:
pass # Card already deleted
self.main_window.current_hovered_card = None self.main_window.current_hovered_card = None
if self.main_window.current_focused_card and self.main_window.current_focused_card != card: if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False try:
self.main_window.current_focused_card.clearFocus() self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
except RuntimeError:
pass # Card already deleted
self.main_window.current_focused_card = card self.main_window.current_focused_card = card
else: else:
if self.main_window.current_focused_card == card: if self.main_window.current_focused_card == card:
@@ -189,11 +197,19 @@ class GameLibraryManager:
if is_hovered: if is_hovered:
if self.main_window.current_focused_card and self.main_window.current_focused_card != card: if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False try:
self.main_window.current_focused_card.clearFocus() if self.main_window.current_focused_card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
except RuntimeError:
pass # Card already deleted
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False try:
self.main_window.current_hovered_card.leaveEvent(None) if self.main_window.current_hovered_card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
except RuntimeError:
pass # Card already deleted
self.main_window.current_hovered_card = card self.main_window.current_hovered_card = card
else: else:
if self.main_window.current_hovered_card == card: if self.main_window.current_hovered_card == card:
@@ -212,6 +228,10 @@ class GameLibraryManager:
if games_list is not None: if games_list is not None:
self.filtered_games = games_list self.filtered_games = games_list
self.dirty = True # Full rebuild only for non-filter self.dirty = True # Full rebuild only for non-filter
else:
# When filtering, we want to update with the current filtered_games
# which has already been set by _perform_search
pass
self.is_filtering = is_filter self.is_filtering = is_filter
self._pending_update = True self._pending_update = True
@@ -222,13 +242,17 @@ class GameLibraryManager:
def force_update_cards_library(self): def force_update_cards_library(self):
if self.gamesListWidget and self.gamesListLayout: if self.gamesListWidget and self.gamesListLayout:
# Use singleShot to ensure UI updates happen after all other operations complete
# This prevents potential freezing in PySide 6.10.1
QTimer.singleShot(0, self._perform_force_update)
def _perform_force_update(self):
"""Perform the actual force update on the layout."""
if self.gamesListLayout:
self.gamesListLayout.invalidate() self.gamesListLayout.invalidate()
if self.gamesListWidget:
self.gamesListWidget.adjustSize()
self.gamesListWidget.updateGeometry() self.gamesListWidget.updateGeometry()
widget = self.gamesListWidget
QTimer.singleShot(0, lambda: (
widget.adjustSize(),
widget.updateGeometry()
))
def _update_game_grid_immediate(self): def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list.""" """Updates the game grid with the provided or current game list."""
@@ -238,8 +262,9 @@ class GameLibraryManager:
search_text = self.main_window.searchEdit.text().strip().lower() search_text = self.main_window.searchEdit.text().strip().lower()
if self.is_filtering: if self.is_filtering:
# Filter mode: do not change layout, only hide/show cards # Filter mode: use the pre-computed filtered_games from optimized search
self._apply_filter_visibility(search_text) # This means we already have the exact games to show
self._update_search_results(search_text)
else: else:
# Full update: sorting, removal/addition, reorganization # Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games if self.filtered_games else self.games games_list = self.filtered_games if self.filtered_games else self.games
@@ -358,34 +383,74 @@ class GameLibraryManager:
if self.gamesListLayout is not None: if self.gamesListLayout is not None:
self.gamesListLayout.update() self.gamesListLayout.update()
self.gamesListWidget.updateGeometry() self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
self.force_update_cards_library() self.force_update_cards_library()
self.is_filtering = False # Reset flag in any case self.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str): def _update_search_results(self, search_text: str = ""):
"""Applies visibility to cards based on search, without changing the layout.""" """Update the grid with pre-computed search results."""
visible_count = 0 if self.gamesListLayout is None or self.gamesListWidget is None:
for game_key, card in self.game_card_cache.items(): return
game_name = card.name # Assume GameCard has 'name' attribute
should_be_visible = not search_text or search_text in game_name.lower()
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
if should_be_visible:
visible_count += 1
# Load image only for newly visible cards
if game_key in self.pending_images:
cover_path, width, height, callback = self.pending_images.pop(game_key)
load_pixmap_async(cover_path, width, height, callback)
# Force full relayout after visibility changes # Batch layout updates
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None: if self.gamesListLayout is not None:
self.gamesListLayout.invalidate() # Принудительно инвалидируем для пересчёта self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Create set of keys for current filtered games for fast lookup
filtered_keys = {(game[0], game[4]) for game in self.filtered_games} # (name, exec_line)
# Process existing cards: show cards that are in filtered results, hide others
cards_to_hide = []
for card_key, card in self.game_card_cache.items():
if card_key in filtered_keys:
# Card should be visible
if not card.isVisible():
card.setVisible(True)
else:
# Card should be hidden
if card.isVisible():
card.setVisible(False)
cards_to_hide.append(card_key)
# Now add any missing cards that are in filtered results but not in cache
cards_to_add = []
for game_data in self.filtered_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
if game_key not in self.game_card_cache:
if self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(True) # New cards should be visible
cards_to_add.append((game_key, card))
# Add new cards to layout
for _game_key, card in cards_to_add:
self.gamesListLayout.addWidget(card)
# Remove cards that are no longer needed (if any)
# Note: we're not removing them completely as they might be needed later
# Instead, we just hide them and they'll be reused if needed
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.force_update_cards_library()
self.gamesListLayout.update() self.gamesListLayout.update()
if self.gamesListWidget is not None: if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry() self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
# If search is empty, load images for visible ones # If search is empty, load images for visible ones
if not search_text: if not search_text:
@@ -398,6 +463,7 @@ class GameLibraryManager:
select_callback=self.main_window.openGameDetailPage, select_callback=self.main_window.openGameDetailPage,
theme=self.theme, theme=self.theme,
card_width=self.card_width, card_width=self.card_width,
parent=self.gamesListWidget,
context_menu_manager=self.context_menu_manager context_menu_manager=self.context_menu_manager
) )
@@ -420,6 +486,11 @@ class GameLibraryManager:
def _flush_deletions(self): def _flush_deletions(self):
"""Delete pending widgets off the main update cycle.""" """Delete pending widgets off the main update cycle."""
for card in list(self.pending_deletions): for card in list(self.pending_deletions):
# Clear any references to this card if it's currently focused/hovered
if self.main_window.current_focused_card == card:
self.main_window.current_focused_card = None
if self.main_window.current_hovered_card == card:
self.main_window.current_hovered_card = None
card.deleteLater() card.deleteLater()
self.pending_deletions.remove(card) self.pending_deletions.remove(card)
@@ -427,24 +498,61 @@ class GameLibraryManager:
"""Clears all widgets from the layout.""" """Clears all widgets from the layout."""
if layout is None: if layout is None:
return return
# Remove all widgets from the layout and clean up caches
while layout.count(): while layout.count():
child = layout.takeAt(0) child = layout.takeAt(0)
if child.widget(): if child.widget():
widget = child.widget() widget = child.widget()
# Clean up cache if widget exists in it
for key, card in list(self.game_card_cache.items()): for key, card in list(self.game_card_cache.items()):
if card == widget: if card == widget:
del self.game_card_cache[key] del self.game_card_cache[key]
if key in self.pending_images: if key in self.pending_images:
del self.pending_images[key] del self.pending_images[key]
break
# Always schedule widget for deletion regardless of cache state
widget.deleteLater() widget.deleteLater()
# Also clear the cache completely if needed (in case layout wasn't in sync)
self.game_card_cache.clear()
self.pending_images.clear()
def set_games(self, games: list[tuple]): def set_games(self, games: list[tuple]):
"""Sets the games list and updates the filtered games.""" """Sets the games list and updates the filtered games."""
self.games = games self.games = games
self.filtered_games = self.games self.filtered_games = self.games
# Build search indices for fast searching
self._build_search_indices(games)
self.dirty = True # Full resort needed self.dirty = True # Full resort needed
self.update_game_grid() self.update_game_grid()
def _build_search_indices(self, games: list[tuple]):
"""Build search indices for fast searching."""
# Prepare items for indexing: (search_key, game_data)
# We'll index by game name (index 0) and potentially other fields
items = []
for game in games:
# game is a tuple: (name, description, cover, appid, exec_line, controller_support,
# last_launch, formatted_playtime, protondb_tier, anticheat_status,
# last_played_timestamp, playtime_seconds, game_source)
name = str(game[0]).lower() if game[0] else ""
description = str(game[1]).lower() if game[1] else ""
# Create multiple search entries for better matching
items.append((name, game)) # Exact name
# Add other searchable fields if needed
if description:
items.append((description, game))
# Also add individual words from the name for partial matching
for word in name.split():
if len(word) > 2: # Only index words longer than 2 characters
items.append((word, game))
self.search_optimizer.build_indices(items)
def add_game_incremental(self, game_data: tuple): def add_game_incremental(self, game_data: tuple):
"""Add a single game without full reload.""" """Add a single game without full reload."""
self.games.append(game_data) self.games.append(game_data)
@@ -468,4 +576,54 @@ class GameLibraryManager:
def filter_games_delayed(self): def filter_games_delayed(self):
"""Filters games based on search text and updates the grid.""" """Filters games based on search text and updates the grid."""
self.update_game_grid(is_filter=True) search_text = self.main_window.searchEdit.text().strip().lower()
if not search_text:
# If search is empty, show all games
self.filtered_games = self.games
self.update_game_grid(is_filter=True)
else:
# Use the optimized search
self._perform_search(search_text)
def _perform_search(self, search_text: str):
"""Perform the actual search using optimized search algorithms."""
if not search_text:
self.filtered_games = self.games
self.update_game_grid(is_filter=True)
return
# Use exact search first
exact_result = self.search_optimizer.exact_search(search_text)
if exact_result:
# If exact match found, show only that game
self.filtered_games = [exact_result]
self.update_game_grid(is_filter=True)
return
# Try prefix search
prefix_results = self.search_optimizer.prefix_search(search_text)
if prefix_results:
# Get the actual game data from the prefix matches
filtered_games = []
for _match_text, game_data in prefix_results:
if game_data not in filtered_games: # Avoid duplicates
filtered_games.append(game_data)
self.filtered_games = filtered_games
self.update_game_grid(is_filter=True)
return
# Finally, try fuzzy search
fuzzy_results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=60.0)
if fuzzy_results:
# Get the actual game data from the fuzzy matches
filtered_games = []
for _match_text, game_data, _score in fuzzy_results:
if game_data not in filtered_games: # Avoid duplicates
filtered_games.append(game_data)
self.filtered_games = filtered_games
self.update_game_grid(is_filter=True)
else:
# If no results found, show empty list
self.filtered_games = []
self.update_game_grid(is_filter=True)

File diff suppressed because it is too large Load Diff

View File

@@ -199,9 +199,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
painter.end() painter.end()
finish_with(pixmap) finish_with(pixmap)
with queue_lock: # Submit the process_image function directly to the executor
image_load_queue.put(process_image) # This avoids the potential blocking issue with queue.get() in PySide 6.10.1
image_executor.submit(lambda: image_load_queue.get()()) image_executor.submit(process_image)
def round_corners(pixmap, radius): def round_corners(pixmap, radius):
""" """

View File

@@ -2,11 +2,11 @@ import time
import threading import threading
import os import os
import math import math
from typing import Protocol, cast from typing import Protocol, cast, Any
from evdev import InputDevice, InputEvent, UInput, ecodes, list_devices, ff from evdev import InputDevice, InputEvent, UInput, ecodes, list_devices, ff
from enum import Enum from enum import Enum
from pyudev import Context, Monitor, Device, Devices from pyudev import Context, Monitor, Device, Devices
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem, QSlider
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, QMouseEvent from PySide6.QtGui import QKeyEvent, QMouseEvent
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
@@ -35,8 +35,12 @@ class MainWindowProtocol(Protocol):
... ...
def on_slider_released(self) -> None: def on_slider_released(self) -> None:
... ...
def on_auto_slider_released(self) -> None:
...
def isActiveWindow(self) -> bool: def isActiveWindow(self) -> bool:
... ...
def refreshGames(self) -> None:
...
stackedWidget: QStackedWidget stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget] tabButtons: dict[int, QWidget]
gamesListWidget: QWidget gamesListWidget: QWidget
@@ -44,6 +48,8 @@ class MainWindowProtocol(Protocol):
currentDetailPage: QWidget | None currentDetailPage: QWidget | None
current_exec_line: str | None current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None current_add_game_dialog: AddGameDialog | None
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
auto_size_slider: QSlider | None
# Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo Switch 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
@@ -118,22 +124,36 @@ class InputManager(QObject):
self.trigger_cooldown = 0.2 self.trigger_cooldown = 0.2
# Mouse emulation attributes # Mouse emulation attributes
self.mouse_emulation_enabled = True # Enable by default as crutch for external apps self.mouse_emulation_enabled = True
self.ui = None # UInput for virtual mouse self.ui = None
self.stick_x_raw = 0 self.stick_x_raw = 0
self.stick_y_raw = 0 self.stick_y_raw = 0
self.deadzone = 8000 # Deadzone for sticks
self.max_value = 32767 # Max stick value # Параметры осей (будут заполнены из ядра)
self.sensitivity = 8.0 # Cursor sensitivity self.center_x = 127 # центр X оси
self.center_y = 127 # центр Y оси
self.min_value = 0 # минимум осей
self.max_value = 255 # максимум осей
self.deadzone_value = 15 # мёртвая зона из ядра (flat параметр)
self.sensitivity = 8.0
self.scroll_accumulator = 0.0 self.scroll_accumulator = 0.0
self.scroll_sensitivity = 0.15 # Scroll sensitivity self.scroll_sensitivity = 0.15
self.scroll_threshold = 0.2 # Scroll threshold self.scroll_threshold = 0.2
self.last_update = time.time() self.last_update = time.time()
self.update_interval = 0.016 # ~60 FPS self.update_interval = 0.016 # ~60 FPS
self.emulation_active = False # Flag for external focus (updated in main thread) self.emulation_active = False
self.emulation_triggered = False self.emulation_triggered = False
self.start_held = False self.start_held = False
self.guide_held = False self.guide_held = False
# Variables for key combination handling
self.guide_pressed_time = 0
self.select_pressed_time = 0
self.guide_timer = QTimer(self)
self.guide_timer.setSingleShot(True)
self.guide_timer.timeout.connect(self._handle_guide_timeout)
self.guide_combination_timeout = 0.3 # 300ms timeout for combination
self.in_guide_combination_attempt = False # Flag to track if we're in a guide+select combination attempt
# Focus check timer for emulation flag (runs in main thread) # Focus check timer for emulation flag (runs in main thread)
self.focus_check_timer = QTimer(self) self.focus_check_timer = QTimer(self)
@@ -143,7 +163,8 @@ class InputManager(QObject):
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled) logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled: if self.mouse_emulation_enabled:
self.enable_mouse_emulation() # Initialize mouse emulation asynchronously to avoid blocking startup
QTimer.singleShot(0, self._async_enable_mouse_emulation)
# FileExplorer specific attributes # FileExplorer specific attributes
self.file_explorer = None self.file_explorer = None
@@ -181,6 +202,10 @@ class InputManager(QObject):
# Initialize evdev + hotplug # Initialize evdev + hotplug
self.init_gamepad() self.init_gamepad()
def _async_enable_mouse_emulation(self):
"""Asynchronously enable mouse emulation to avoid blocking startup."""
self.enable_mouse_emulation()
def _update_emulation_flag(self): def _update_emulation_flag(self):
"""Update emulation_active flag based on Qt app focus (main thread only).""" """Update emulation_active flag based on Qt app focus (main thread only)."""
active = QApplication.activeWindow() active = QApplication.activeWindow()
@@ -248,7 +273,7 @@ class InputManager(QObject):
return return
current_row = sorted_rows[current_row_idx][1] current_row = sorted_rows[current_row_idx][1]
focused_x = focused.pos().x() + focused.width() / 2 focused_x = focused.pos().x() + focused.width() / 2
current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0)
# Add null checks before using current_row_idx and current_col_idx # Add null checks before using current_row_idx and current_col_idx
if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
@@ -1030,15 +1055,27 @@ class InputManager(QObject):
self.stick_y_raw = 0 self.stick_y_raw = 0
self.scroll_accumulator = 0.0 self.scroll_accumulator = 0.0
def handle_scroll(self, raw_value): def handle_scroll(self, raw_value):
"""Обработка прокрутки с правого стика Y""" """Обработка прокрутки с правого стика Y"""
if not self.mouse_emulation_enabled or not self.emulation_active or not self.ui: if not self.mouse_emulation_enabled or not self.emulation_active or not self.ui:
return return
if abs(raw_value) < self.deadzone:
# Нормализуем от центра
centered_value = raw_value - self.center_y
if abs(centered_value) < self.deadzone_value:
self.scroll_accumulator = 0.0 self.scroll_accumulator = 0.0
return return
normalized = raw_value / self.max_value
# Нормализуем значение (-1.0 до 1.0)
range_val = (self.max_value - self.min_value) / 2
normalized = centered_value / range_val
# Накапливаем прокрутку
self.scroll_accumulator += normalized * self.scroll_sensitivity self.scroll_accumulator += normalized * self.scroll_sensitivity
# Отправляем события прокрутки
while abs(self.scroll_accumulator) >= self.scroll_threshold: while abs(self.scroll_accumulator) >= self.scroll_threshold:
scroll_step = 1 if self.scroll_accumulator > 0 else -1 scroll_step = 1 if self.scroll_accumulator > 0 else -1
self.scroll_wheel(-scroll_step) self.scroll_wheel(-scroll_step)
@@ -1048,18 +1085,35 @@ class InputManager(QObject):
"""Постоянное обновление позиции мыши на основе состояния стика""" """Постоянное обновление позиции мыши на основе состояния стика"""
if not self.ui or not self.emulation_active: if not self.ui or not self.emulation_active:
return return
x = self.stick_x_raw
y = self.stick_y_raw # Центрируем значения
x = self.stick_x_raw - self.center_x
y = self.stick_y_raw - self.center_y
# Применяем мёртвую зону из ядра
magnitude = math.sqrt(x * x + y * y) magnitude = math.sqrt(x * x + y * y)
if magnitude < self.deadzone:
if magnitude < self.deadzone_value:
return return
norm_x = x / magnitude
norm_y = y / magnitude if magnitude > 0:
adjusted_magnitude = max(0.0, min(1.0, (magnitude - self.deadzone) / (self.max_value - self.deadzone))) norm_x = x / magnitude
norm_y = y / magnitude
else:
return
# Нормализуем по диапазону оси
max_range = (self.max_value - self.min_value) / 2
adjusted_magnitude = (magnitude - self.deadzone_value) / (max_range - self.deadzone_value)
adjusted_magnitude = max(0.0, min(1.0, adjusted_magnitude))
# Нелинейная кривая
adjusted_magnitude = math.pow(adjusted_magnitude, 1.5) adjusted_magnitude = math.pow(adjusted_magnitude, 1.5)
speed = adjusted_magnitude * self.sensitivity speed = adjusted_magnitude * self.sensitivity
dx = int(norm_x * speed) dx = int(norm_x * speed)
dy = int(norm_y * speed) dy = int(norm_y * speed)
if dx != 0 or dy != 0: if dx != 0 or dy != 0:
self.move_mouse(dx, dy) self.move_mouse(dx, dy)
@@ -1166,6 +1220,28 @@ class InputManager(QObject):
except Exception as e: except Exception as e:
logger.error(f"Error stopping rumble: {e}", exc_info=True) logger.error(f"Error stopping rumble: {e}", exc_info=True)
def _handle_guide_timeout(self) -> None:
if self.guide_held:
time_since_guide = time.time() - self.guide_pressed_time
time_since_select = time.time() - self.select_pressed_time
if (self.select_pressed_time > self.guide_pressed_time and
time_since_select <= self.guide_combination_timeout and
time_since_guide <= self.guide_combination_timeout):
logger.debug("Guide + Select combination detected, refreshing game grid")
self._parent.refreshGames()
else:
logger.debug("Guide button pressed alone, opening system overlay")
active = QApplication.activeWindow()
if not isinstance(active, QDialog):
self._parent.openSystemOverlay()
self.guide_held = False
self.in_guide_combination_attempt = False
self.guide_pressed_time = 0
self.select_pressed_time = 0
@Slot(int, int) @Slot(int, int)
def handle_button_slot(self, button_code: int, value: int) -> None: def handle_button_slot(self, button_code: int, value: int) -> None:
active_window = QApplication.activeWindow() active_window = QApplication.activeWindow()
@@ -1200,8 +1276,6 @@ class InputManager(QObject):
app = QApplication.instance() app = QApplication.instance()
active = QApplication.activeWindow() active = QApplication.activeWindow()
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
modal_dialog = QApplication.activeModalWidget()
if not app or not active: if not app or not active:
return return
@@ -1230,11 +1304,35 @@ class InputManager(QObject):
search_edit.setFocus() search_edit.setFocus()
return return
# Handle Guide button to open system overlay # Guide + Select combination for refreshing game grid
if button_code in BUTTONS['guide']: if value == 1:
if not popup and not isinstance(active, QDialog): current_time = time.time()
self._parent.openSystemOverlay()
if button_code in BUTTONS['guide']:
self.guide_held = True
self.guide_pressed_time = current_time
self.in_guide_combination_attempt = True
if hasattr(self, 'guide_timer'):
self.guide_timer.start(int(self.guide_combination_timeout * 1000))
return return
elif button_code in BUTTONS['menu'] and hasattr(self, 'guide_held') and self.guide_held:
self.select_pressed_time = current_time
time_since_guide = current_time - self.guide_pressed_time
if time_since_guide <= self.guide_combination_timeout:
if hasattr(self, 'guide_timer'):
self.guide_timer.stop()
logger.debug("Guide + Select combination detected, refreshing game grid")
self._parent.refreshGames()
self.guide_held = False
self.in_guide_combination_attempt = False
self.guide_pressed_time = 0
self.select_pressed_time = 0
return
else:
self.in_guide_combination_attempt = False
self.guide_held = False
self.guide_pressed_time = 0
self.select_pressed_time = 0
# Handle common UI elements like QMenu and QMessageBox # Handle common UI elements like QMenu and QMessageBox
if self._handle_common_ui_elements(button_code): if self._handle_common_ui_elements(button_code):
@@ -1301,13 +1399,6 @@ class InputManager(QObject):
menu.setFocus(Qt.FocusReason.OtherFocusReason) menu.setFocus(Qt.FocusReason.OtherFocusReason)
return return
# Game launch on detail page
if (button_code in BUTTONS['confirm']) and self._parent.currentDetailPage is not None and modal_dialog is None:
if self._parent.current_exec_line:
self.trigger_rumble()
self._parent.toggleGame(self._parent.current_exec_line, None)
return
# Standard navigation # Standard navigation
if button_code in BUTTONS['confirm']: if button_code in BUTTONS['confirm']:
self._parent.activateFocusedWidget() self._parent.activateFocusedWidget()
@@ -1324,20 +1415,38 @@ class InputManager(QObject):
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons) idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
self._parent.switchTab(idx) self._parent.switchTab(idx)
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason) self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
elif button_code in BUTTONS['increase_size'] and self._parent.stackedWidget.currentIndex() == 0: elif button_code in BUTTONS['increase_size']:
# Increase card size with RT (Xbox) / R2 (PS) current_tab = self._parent.stackedWidget.currentIndex()
size_slider = getattr(self._parent, 'sizeSlider', None) if current_tab == 0: # Main games library
if size_slider: if hasattr(self._parent, 'game_library_manager') and self._parent.game_library_manager:
new_value = min(size_slider.value() + 10, size_slider.maximum()) size_slider = getattr(self._parent.game_library_manager, 'sizeSlider', None)
size_slider.setValue(new_value) if size_slider:
self._parent.on_slider_released() new_value = min(size_slider.value() + 10, size_slider.maximum())
elif button_code in BUTTONS['decrease_size'] and self._parent.stackedWidget.currentIndex() == 0: size_slider.setValue(new_value)
# Decrease card size with LT (Xbox) / L2 (PS) self._parent.on_slider_released()
size_slider = getattr(self._parent, 'sizeSlider', None) elif current_tab == 1: # Auto-install tab
if size_slider: auto_size_slider = getattr(self._parent, 'auto_size_slider', None)
new_value = max(size_slider.value() - 10, size_slider.minimum()) if auto_size_slider:
size_slider.setValue(new_value) new_value = min(auto_size_slider.value() + 10, auto_size_slider.maximum())
self._parent.on_slider_released() auto_size_slider.setValue(new_value)
if hasattr(self._parent, 'on_auto_slider_released'):
self._parent.on_auto_slider_released()
elif button_code in BUTTONS['decrease_size']:
current_tab = self._parent.stackedWidget.currentIndex()
if current_tab == 0: # Main games library
if hasattr(self._parent, 'game_library_manager') and self._parent.game_library_manager:
size_slider = getattr(self._parent.game_library_manager, 'sizeSlider', None)
if size_slider:
new_value = max(size_slider.value() - 10, size_slider.minimum())
size_slider.setValue(new_value)
self._parent.on_slider_released()
elif current_tab == 1: # Auto-install tab
auto_size_slider = getattr(self._parent, 'auto_size_slider', None)
if auto_size_slider:
new_value = max(auto_size_slider.value() - 10, auto_size_slider.minimum())
auto_size_slider.setValue(new_value)
if hasattr(self._parent, 'on_auto_slider_released'):
self._parent.on_auto_slider_released()
except Exception as e: except Exception as e:
logger.error(f"Error in handle_button_slot: {e}", exc_info=True) logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
@@ -1607,6 +1716,50 @@ class InputManager(QObject):
self._navigate_game_cards(container, current_index, code, value) self._navigate_game_cards(container, current_index, code, value)
return return
# Button navigation on detail pages (horizontal layout)
if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
focused = QApplication.focusWidget()
page = self._parent.stackedWidget.currentWidget()
# Check if we're on a detail page and focused widget is a button
if isinstance(focused, AutoSizeButton):
# Find all buttons in the same horizontal layout (same parent, same Y position)
parent_widget = focused.parentWidget()
if parent_widget:
# Find all AutoSizeButtons in the parent that are horizontally aligned
buttons = parent_widget.findChildren(AutoSizeButton)
# Filter buttons that are approximately on the same horizontal level (similar Y positions)
y_tolerance = 20 # pixels tolerance for vertical alignment
current_y = focused.geometry().y() + focused.geometry().height() // 2
aligned_buttons = []
for btn in buttons:
btn_center_y = btn.geometry().y() + btn.geometry().height() // 2
if abs(btn_center_y - current_y) <= y_tolerance:
aligned_buttons.append(btn)
# Sort buttons by x position for left-to-right navigation
if len(aligned_buttons) > 1:
aligned_buttons.sort(key=lambda b: b.geometry().x() + b.geometry().width() // 2)
# Find current button index
try:
current_index = aligned_buttons.index(focused)
except ValueError:
current_index = -1
if current_index >= 0:
if code == ecodes.ABS_HAT0X: # Horizontal navigation (left/right)
if value < 0 and current_index > 0: # Left
aligned_buttons[current_index - 1].setFocus(Qt.FocusReason.OtherFocusReason)
return
elif value > 0 and current_index < len(aligned_buttons) - 1: # Right
aligned_buttons[current_index + 1].setFocus(Qt.FocusReason.OtherFocusReason)
return
elif code == ecodes.ABS_HAT0Y: # Vertical navigation (up/down)
# For buttons on the same row, up/down should go to other controls
# So we'll continue to the next section of code for general navigation
pass
# Vertical navigation in other tabs # Vertical navigation in other tabs
if code == ecodes.ABS_HAT0Y and value != 0: if code == ecodes.ABS_HAT0Y and value != 0:
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
@@ -1772,6 +1925,11 @@ class InputManager(QObject):
self._parent.openSystemOverlay() self._parent.openSystemOverlay()
return True return True
# Refresh game grid with F5
if key == Qt.Key.Key_F5:
self._parent.refreshGames()
return True
# Close application with Ctrl+Q # Close application with Ctrl+Q
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier: if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
app.quit() app.quit()
@@ -1803,8 +1961,10 @@ class InputManager(QObject):
active_win.show_next() active_win.show_next()
return True # Consume event to prevent tab switching return True # Consume event to prevent tab switching
# Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit # Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit or QTableWidget or AutoSizeButton
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and not isinstance(focused, GameCard | QLineEdit) and not self.file_explorer: if (key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and
not isinstance(focused, GameCard | QLineEdit | QTableWidget | AutoSizeButton) and
not self.file_explorer):
idx = self._parent.stackedWidget.currentIndex() idx = self._parent.stackedWidget.currentIndex()
total = len(self._parent.tabButtons) total = len(self._parent.tabButtons)
if key == Qt.Key.Key_Left: if key == Qt.Key.Key_Left:
@@ -1840,12 +2000,6 @@ class InputManager(QObject):
self.dpad_moved.emit(dpad_code, dpad_value, now) self.dpad_moved.emit(dpad_code, dpad_value, now)
return True return True
# Launch/stop game on detail page
if self._parent.currentDetailPage and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
if self._parent.current_exec_line:
self._parent.toggleGame(self._parent.current_exec_line, None)
return True
# Context menu for GameCard # Context menu for GameCard
if isinstance(focused, GameCard): if isinstance(focused, GameCard):
if key == Qt.Key.Key_F10 and modifiers & Qt.KeyboardModifier.ShiftModifier: if key == Qt.Key.Key_F10 and modifiers & Qt.KeyboardModifier.ShiftModifier:
@@ -1855,6 +2009,18 @@ class InputManager(QObject):
# General actions: Activate, Back, Add # General actions: Activate, Back, Add
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
# Special handling for table widgets with checkboxes
if isinstance(focused, QTableWidget):
current_row = focused.currentRow()
current_col = focused.currentColumn()
if current_row >= 0 and current_col >= 0:
# Check if the cell contains a checkbox
item = focused.item(current_row, current_col)
if item and (item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
# Toggle the checkbox state
new_state = Qt.CheckState.Checked if item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
item.setCheckState(new_state)
return True
self._parent.activateFocusedWidget() self._parent.activateFocusedWidget()
return True return True
elif key in (Qt.Key.Key_Escape, Qt.Key.Key_Backspace): elif key in (Qt.Key.Key_Escape, Qt.Key.Key_Backspace):
@@ -2134,6 +2300,7 @@ class InputManager(QObject):
if is_joystick == '1': if is_joystick == '1':
logger.info(f"Found gamepad: {device.name}") logger.info(f"Found gamepad: {device.name}")
self.detect_gamepad_axes(device)
return device return device
except Exception as e: except Exception as e:
@@ -2147,6 +2314,34 @@ class InputManager(QObject):
logger.error(f"Error finding gamepad: {e}", exc_info=True) logger.error(f"Error finding gamepad: {e}", exc_info=True)
return None return None
def detect_gamepad_axes(self, device: InputDevice) -> None:
"""Читаем параметры осей из ядра (диапазон и мёртвую зону)"""
try:
caps = device.capabilities()
if ecodes.EV_ABS not in caps:
return
abs_axes = caps[ecodes.EV_ABS]
for code, absinfo in cast(Any, abs_axes):
if code == ecodes.ABS_X:
self.min_value = absinfo.min
self.max_value = absinfo.max
self.center_x = (absinfo.min + absinfo.max) // 2
self.center_y = (absinfo.min + absinfo.max) // 2
self.stick_x_raw = self.center_x
self.stick_y_raw = self.center_y
# Берём мёртвую зону из ядра (flat параметр)
self.deadzone_value = absinfo.flat if absinfo.flat > 0 else 15
logger.info(
f"Gamepad axes: min={self.min_value}, max={self.max_value}, "
f"center={self.center_x}, deadzone={self.deadzone_value}"
)
break
except Exception as ex:
logger.error(f"Error detecting gamepad axes: {ex}")
def monitor_gamepad(self) -> None: def monitor_gamepad(self) -> None:
try: try:
while self.running: while self.running:
@@ -2180,8 +2375,12 @@ class InputManager(QObject):
self.button_event.emit(event.code, event.value) self.button_event.emit(event.code, event.value)
# Special handling for menu on press only # Special handling for menu on press only
if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session: # Only handle menu button if our main window is currently active
self.toggle_fullscreen.emit(not self._is_fullscreen) if (event.value == 1 and event.code in BUTTONS['menu'] and
not self._is_gamescope_session and not self.in_guide_combination_attempt):
# Check if our main window is the currently active window
if self._parent.isActiveWindow():
self.toggle_fullscreen.emit(not self._is_fullscreen)
elif event.type == ecodes.EV_ABS: elif event.type == ecodes.EV_ABS:
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}: if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
# Trigger handling for UI # Trigger handling for UI
@@ -2222,9 +2421,12 @@ class InputManager(QObject):
elif event.code == ecodes.ABS_X: elif event.code == ecodes.ABS_X:
self.stick_x_raw = event.value self.stick_x_raw = event.value
elif event.code == ecodes.ABS_Y: elif event.code == ecodes.ABS_Y:
self.stick_y_raw = event.value if event.code not in (ecodes.ABS_GAS, ecodes.ABS_BRAKE):
self.stick_y_raw = event.value
elif event.code == ecodes.ABS_RY: elif event.code == ecodes.ABS_RY:
self.handle_scroll(event.value) self.handle_scroll(event.value)
elif event.code in (ecodes.ABS_GAS, ecodes.ABS_BRAKE):
pass # Триггеры - не обрабатываем
elif event.type == ecodes.EV_KEY: elif event.type == ecodes.EV_KEY:
if event.code in (ecodes.BTN_SOUTH, ecodes.BTN_A) and event.value == 1: if event.code in (ecodes.BTN_SOUTH, ecodes.BTN_A) and event.value == 1:
self.click_left() self.click_left()
@@ -2242,8 +2444,8 @@ class InputManager(QObject):
else: else:
logger.error(f"IOError in gamepad monitoring: {e}") logger.error(f"IOError in gamepad monitoring: {e}")
self.gamepad = None self.gamepad = None
self.stick_x_raw = 0 self.stick_x_raw = self.center_x
self.stick_y_raw = 0 self.stick_y_raw = self.center_y
self.scroll_accumulator = 0.0 self.scroll_accumulator = 0.0
self.start_held = False self.start_held = False
self.guide_held = False self.guide_held = False

View File

@@ -1,15 +1,15 @@
# German (Germany) translations for PortProtonQt. # German (Germany) translations for PortProtonQt.
# Copyright (C) 2025 boria138 # Copyright (C) 2026 boria138
# This file is distributed under the same license as the PortProtonQt # This file is distributed under the same license as the PortProtonQt
# project. # project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025. # FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-11-23 15:42+0500\n" "POT-Creation-Date: 2026-01-03 20:33+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"
@@ -217,6 +217,10 @@ msgstr ""
msgid "Failed to copy cover image: {error}" msgid "Failed to copy cover image: {error}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Unsupported image format: {extension}"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}" msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "" msgstr ""
@@ -252,6 +256,101 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Delete Wine"
msgstr ""
msgid "Selected WINE:"
msgstr ""
msgid "No WINE selected"
msgstr ""
msgid "Delete Selected"
msgstr ""
msgid "Clear All"
msgstr ""
#, python-brace-format
msgid "Selected {} WINE:\n"
msgstr ""
msgid "No Selection"
msgstr ""
msgid "Please select at least one WINE to delete."
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete the following WINE versions?\n"
"\n"
"{}"
msgstr ""
#, python-brace-format
msgid "Failed to delete WINE '{}': {}"
msgstr ""
msgid "Some Deletions Failed"
msgstr ""
#, python-brace-format
msgid ""
"Some WINE versions could not be deleted:\n"
"\n"
"{}"
msgstr ""
msgid "Selected WINE versions deleted successfully."
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Reinstall"
msgstr ""
msgid "Install"
msgstr ""
msgid "Open" msgid "Open"
msgstr "" msgstr ""
@@ -267,9 +366,6 @@ msgstr ""
msgid "Toggle" msgid "Toggle"
msgstr "" msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install" msgid "Force Install"
msgstr "" msgstr ""
@@ -320,6 +416,9 @@ msgstr ""
msgid "Custom Cover:" msgid "Custom Cover:"
msgstr "" msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Cover Preview:" msgid "Cover Preview:"
msgstr "" msgstr ""
@@ -353,9 +452,6 @@ msgstr ""
msgid "Fonts" msgid "Fonts"
msgstr "" msgstr ""
msgid "Settings"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -449,9 +545,85 @@ msgstr ""
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
msgid "Get other Wine"
msgstr ""
msgid "Selected assets:"
msgstr ""
msgid "No assets selected"
msgstr ""
msgid "Downloading: "
msgstr ""
msgid "Download Selected"
msgstr ""
msgid "Asset Name"
msgstr ""
#, python-brace-format
msgid "Selected {} assets:\n"
msgstr ""
msgid "Downloading in Progress"
msgstr ""
msgid "Cannot clear selection while extraction is in progress."
msgstr ""
msgid "Please select at least one archive to download."
msgstr ""
msgid "Please wait for current downloading to complete."
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "All selected archives have been downloaded!"
msgstr ""
#, python-brace-format
msgid "Downloading: {0} ({1}%)"
msgstr ""
#, python-brace-format
msgid "Extracting: {0}"
msgstr ""
#, python-brace-format
msgid ", ETA: {}s"
msgstr ""
#, python-brace-format
msgid ", Speed: {:.1f}MB/s"
msgstr ""
#, python-brace-format
msgid "Extracting: {0}{1}{2}"
msgstr ""
msgid "Extraction Error"
msgstr ""
#, python-brace-format
msgid "Failed to extract archive: {0}"
msgstr ""
msgid "Operation Cancelled"
msgstr ""
msgid "Download or extraction has been cancelled."
msgstr ""
msgid "Unknown Game" msgid "Unknown Game"
msgstr "" msgstr ""
msgid "Starting PortProton..."
msgstr ""
msgid "Library" msgid "Library"
msgstr "" msgstr ""
@@ -467,10 +639,10 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back" msgid "Fullscreen"
msgstr "" msgstr ""
msgid "Fullscreen" msgid "Refresh Grid"
msgstr "" msgstr ""
msgid "Installation already in progress." msgid "Installation already in progress."
@@ -492,9 +664,6 @@ msgstr ""
msgid "Installation error." msgid "Installation error."
msgstr "" msgstr ""
msgid "Refresh Grid"
msgstr ""
msgid "Game library refreshed" msgid "Game library refreshed"
msgstr "" msgstr ""
@@ -556,6 +725,9 @@ msgstr ""
msgid "Clear Prefix" msgid "Clear Prefix"
msgstr "" msgstr ""
msgid "Download other WINE"
msgstr ""
msgid "Launching tool..." msgid "Launching tool..."
msgstr "" msgstr ""
@@ -616,18 +788,6 @@ msgstr ""
msgid "Failed to delete prefix: {}" msgid "Failed to delete prefix: {}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..." msgid "Main PortProton parameters..."
msgstr "" msgstr ""
@@ -772,40 +932,6 @@ msgstr ""
msgid "Executable not found: {0}" msgid "Executable not found: {0}"
msgstr "" msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Executable not found for EGS game: {0}" msgid "Executable not found for EGS game: {0}"
msgstr "" msgstr ""
@@ -896,9 +1022,6 @@ msgstr ""
msgid "Run the application in a terminal" msgid "Run the application in a terminal"
msgstr "" msgstr ""
msgid "Disable startup mode and WINE version selector window"
msgstr ""
msgid "Use system GameMode for performance optimization" msgid "Use system GameMode for performance optimization"
msgstr "" msgstr ""
@@ -971,10 +1094,10 @@ msgstr ""
msgid "Prefix Name" msgid "Prefix Name"
msgstr "" msgstr ""
msgid "Select the Wine prefix to use." msgid "Specify the Wine prefix to run this game with"
msgstr "" msgstr ""
msgid "Latest" msgid "Newest"
msgstr "" msgstr ""
msgid "Stable" msgid "Stable"
@@ -984,31 +1107,15 @@ msgid "Vulkan Backend"
msgstr "" msgstr ""
msgid "" msgid ""
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n" "Select the DirectX → Vulkan/OpenGL backend:\n"
"\n" "\n"
"• Auto latest DXVK + VKD3D (recommended)\n" "• Newest latest DXVK + VKD3D (best compatibility/performance, requires "
" The newest versions from the developers. Give the best compatibility " "modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"and performance in modern games.\n" "• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
" Require up-to-date drivers:\n" "driver)\n"
" AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n" "• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
" NVIDIA: driver 550.54.14 or newer\n" "Vulkan 1.1+)\n"
" Intel: Mesa 24.2+\n" "• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
"\n"
"• Stable proven DXVK + VKD3D\n"
" Older but extremely well-tested versions. Work on any drivers that "
"support Vulkan 1.3+.\n"
" The best choice if you have problems with the newest versions.\n"
"\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek\n"
" Work even on older drivers and video cards that support at least "
"Vulkan 1.1.\n"
"\n"
"• WINED3D OpenGL translation (fallback)\n"
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in "
"WineD3D.\n"
" Works on absolutely any hardware, but performance is significantly "
"lower.\n"
" Use only as a last resort when nothing else starts."
msgstr "" msgstr ""
msgid "Windows version" msgid "Windows version"

View File

@@ -1,15 +1,15 @@
# Spanish (Spain) translations for PortProtonQt. # Spanish (Spain) translations for PortProtonQt.
# Copyright (C) 2025 boria138 # Copyright (C) 2026 boria138
# This file is distributed under the same license as the PortProtonQt # This file is distributed under the same license as the PortProtonQt
# project. # project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025. # FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-11-23 15:42+0500\n" "POT-Creation-Date: 2026-01-03 20:33+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"
@@ -217,6 +217,10 @@ msgstr ""
msgid "Failed to copy cover image: {error}" msgid "Failed to copy cover image: {error}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Unsupported image format: {extension}"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}" msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "" msgstr ""
@@ -252,6 +256,101 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Delete Wine"
msgstr ""
msgid "Selected WINE:"
msgstr ""
msgid "No WINE selected"
msgstr ""
msgid "Delete Selected"
msgstr ""
msgid "Clear All"
msgstr ""
#, python-brace-format
msgid "Selected {} WINE:\n"
msgstr ""
msgid "No Selection"
msgstr ""
msgid "Please select at least one WINE to delete."
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete the following WINE versions?\n"
"\n"
"{}"
msgstr ""
#, python-brace-format
msgid "Failed to delete WINE '{}': {}"
msgstr ""
msgid "Some Deletions Failed"
msgstr ""
#, python-brace-format
msgid ""
"Some WINE versions could not be deleted:\n"
"\n"
"{}"
msgstr ""
msgid "Selected WINE versions deleted successfully."
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Reinstall"
msgstr ""
msgid "Install"
msgstr ""
msgid "Open" msgid "Open"
msgstr "" msgstr ""
@@ -267,9 +366,6 @@ msgstr ""
msgid "Toggle" msgid "Toggle"
msgstr "" msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install" msgid "Force Install"
msgstr "" msgstr ""
@@ -320,6 +416,9 @@ msgstr ""
msgid "Custom Cover:" msgid "Custom Cover:"
msgstr "" msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Cover Preview:" msgid "Cover Preview:"
msgstr "" msgstr ""
@@ -353,9 +452,6 @@ msgstr ""
msgid "Fonts" msgid "Fonts"
msgstr "" msgstr ""
msgid "Settings"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -449,9 +545,85 @@ msgstr ""
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
msgid "Get other Wine"
msgstr ""
msgid "Selected assets:"
msgstr ""
msgid "No assets selected"
msgstr ""
msgid "Downloading: "
msgstr ""
msgid "Download Selected"
msgstr ""
msgid "Asset Name"
msgstr ""
#, python-brace-format
msgid "Selected {} assets:\n"
msgstr ""
msgid "Downloading in Progress"
msgstr ""
msgid "Cannot clear selection while extraction is in progress."
msgstr ""
msgid "Please select at least one archive to download."
msgstr ""
msgid "Please wait for current downloading to complete."
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "All selected archives have been downloaded!"
msgstr ""
#, python-brace-format
msgid "Downloading: {0} ({1}%)"
msgstr ""
#, python-brace-format
msgid "Extracting: {0}"
msgstr ""
#, python-brace-format
msgid ", ETA: {}s"
msgstr ""
#, python-brace-format
msgid ", Speed: {:.1f}MB/s"
msgstr ""
#, python-brace-format
msgid "Extracting: {0}{1}{2}"
msgstr ""
msgid "Extraction Error"
msgstr ""
#, python-brace-format
msgid "Failed to extract archive: {0}"
msgstr ""
msgid "Operation Cancelled"
msgstr ""
msgid "Download or extraction has been cancelled."
msgstr ""
msgid "Unknown Game" msgid "Unknown Game"
msgstr "" msgstr ""
msgid "Starting PortProton..."
msgstr ""
msgid "Library" msgid "Library"
msgstr "" msgstr ""
@@ -467,10 +639,10 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back" msgid "Fullscreen"
msgstr "" msgstr ""
msgid "Fullscreen" msgid "Refresh Grid"
msgstr "" msgstr ""
msgid "Installation already in progress." msgid "Installation already in progress."
@@ -492,9 +664,6 @@ msgstr ""
msgid "Installation error." msgid "Installation error."
msgstr "" msgstr ""
msgid "Refresh Grid"
msgstr ""
msgid "Game library refreshed" msgid "Game library refreshed"
msgstr "" msgstr ""
@@ -556,6 +725,9 @@ msgstr ""
msgid "Clear Prefix" msgid "Clear Prefix"
msgstr "" msgstr ""
msgid "Download other WINE"
msgstr ""
msgid "Launching tool..." msgid "Launching tool..."
msgstr "" msgstr ""
@@ -616,18 +788,6 @@ msgstr ""
msgid "Failed to delete prefix: {}" msgid "Failed to delete prefix: {}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..." msgid "Main PortProton parameters..."
msgstr "" msgstr ""
@@ -772,40 +932,6 @@ msgstr ""
msgid "Executable not found: {0}" msgid "Executable not found: {0}"
msgstr "" msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Executable not found for EGS game: {0}" msgid "Executable not found for EGS game: {0}"
msgstr "" msgstr ""
@@ -896,9 +1022,6 @@ msgstr ""
msgid "Run the application in a terminal" msgid "Run the application in a terminal"
msgstr "" msgstr ""
msgid "Disable startup mode and WINE version selector window"
msgstr ""
msgid "Use system GameMode for performance optimization" msgid "Use system GameMode for performance optimization"
msgstr "" msgstr ""
@@ -971,10 +1094,10 @@ msgstr ""
msgid "Prefix Name" msgid "Prefix Name"
msgstr "" msgstr ""
msgid "Select the Wine prefix to use." msgid "Specify the Wine prefix to run this game with"
msgstr "" msgstr ""
msgid "Latest" msgid "Newest"
msgstr "" msgstr ""
msgid "Stable" msgid "Stable"
@@ -984,31 +1107,15 @@ msgid "Vulkan Backend"
msgstr "" msgstr ""
msgid "" msgid ""
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n" "Select the DirectX → Vulkan/OpenGL backend:\n"
"\n" "\n"
"• Auto latest DXVK + VKD3D (recommended)\n" "• Newest latest DXVK + VKD3D (best compatibility/performance, requires "
" The newest versions from the developers. Give the best compatibility " "modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"and performance in modern games.\n" "• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
" Require up-to-date drivers:\n" "driver)\n"
" AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n" "• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
" NVIDIA: driver 550.54.14 or newer\n" "Vulkan 1.1+)\n"
" Intel: Mesa 24.2+\n" "• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
"\n"
"• Stable proven DXVK + VKD3D\n"
" Older but extremely well-tested versions. Work on any drivers that "
"support Vulkan 1.3+.\n"
" The best choice if you have problems with the newest versions.\n"
"\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek\n"
" Work even on older drivers and video cards that support at least "
"Vulkan 1.1.\n"
"\n"
"• WINED3D OpenGL translation (fallback)\n"
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in "
"WineD3D.\n"
" Works on absolutely any hardware, but performance is significantly "
"lower.\n"
" Use only as a last resort when nothing else starts."
msgstr "" msgstr ""
msgid "Windows version" msgid "Windows version"

View File

@@ -1,15 +1,15 @@
# Translations template for PortProtonQt. # Translations template for PortProtonQt.
# Copyright (C) 2025 boria138 # Copyright (C) 2026 boria138
# This file is distributed under the same license as the PortProtonQt # This file is distributed under the same license as the PortProtonQt
# project. # project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025. # FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-11-23 15:43+0500\n" "POT-Creation-Date: 2026-01-03 20:33+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"
@@ -215,6 +215,10 @@ msgstr ""
msgid "Failed to copy cover image: {error}" msgid "Failed to copy cover image: {error}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Unsupported image format: {extension}"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}" msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "" msgstr ""
@@ -250,6 +254,101 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Delete Wine"
msgstr ""
msgid "Selected WINE:"
msgstr ""
msgid "No WINE selected"
msgstr ""
msgid "Delete Selected"
msgstr ""
msgid "Clear All"
msgstr ""
#, python-brace-format
msgid "Selected {} WINE:\n"
msgstr ""
msgid "No Selection"
msgstr ""
msgid "Please select at least one WINE to delete."
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete the following WINE versions?\n"
"\n"
"{}"
msgstr ""
#, python-brace-format
msgid "Failed to delete WINE '{}': {}"
msgstr ""
msgid "Some Deletions Failed"
msgstr ""
#, python-brace-format
msgid ""
"Some WINE versions could not be deleted:\n"
"\n"
"{}"
msgstr ""
msgid "Selected WINE versions deleted successfully."
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Reinstall"
msgstr ""
msgid "Install"
msgstr ""
msgid "Open" msgid "Open"
msgstr "" msgstr ""
@@ -265,9 +364,6 @@ msgstr ""
msgid "Toggle" msgid "Toggle"
msgstr "" msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install" msgid "Force Install"
msgstr "" msgstr ""
@@ -318,6 +414,9 @@ msgstr ""
msgid "Custom Cover:" msgid "Custom Cover:"
msgstr "" msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Cover Preview:" msgid "Cover Preview:"
msgstr "" msgstr ""
@@ -351,9 +450,6 @@ msgstr ""
msgid "Fonts" msgid "Fonts"
msgstr "" msgstr ""
msgid "Settings"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -447,9 +543,85 @@ msgstr ""
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
msgid "Get other Wine"
msgstr ""
msgid "Selected assets:"
msgstr ""
msgid "No assets selected"
msgstr ""
msgid "Downloading: "
msgstr ""
msgid "Download Selected"
msgstr ""
msgid "Asset Name"
msgstr ""
#, python-brace-format
msgid "Selected {} assets:\n"
msgstr ""
msgid "Downloading in Progress"
msgstr ""
msgid "Cannot clear selection while extraction is in progress."
msgstr ""
msgid "Please select at least one archive to download."
msgstr ""
msgid "Please wait for current downloading to complete."
msgstr ""
msgid "Downloading Complete"
msgstr ""
msgid "All selected archives have been downloaded!"
msgstr ""
#, python-brace-format
msgid "Downloading: {0} ({1}%)"
msgstr ""
#, python-brace-format
msgid "Extracting: {0}"
msgstr ""
#, python-brace-format
msgid ", ETA: {}s"
msgstr ""
#, python-brace-format
msgid ", Speed: {:.1f}MB/s"
msgstr ""
#, python-brace-format
msgid "Extracting: {0}{1}{2}"
msgstr ""
msgid "Extraction Error"
msgstr ""
#, python-brace-format
msgid "Failed to extract archive: {0}"
msgstr ""
msgid "Operation Cancelled"
msgstr ""
msgid "Download or extraction has been cancelled."
msgstr ""
msgid "Unknown Game" msgid "Unknown Game"
msgstr "" msgstr ""
msgid "Starting PortProton..."
msgstr ""
msgid "Library" msgid "Library"
msgstr "" msgstr ""
@@ -465,10 +637,10 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back" msgid "Fullscreen"
msgstr "" msgstr ""
msgid "Fullscreen" msgid "Refresh Grid"
msgstr "" msgstr ""
msgid "Installation already in progress." msgid "Installation already in progress."
@@ -490,9 +662,6 @@ msgstr ""
msgid "Installation error." msgid "Installation error."
msgstr "" msgstr ""
msgid "Refresh Grid"
msgstr ""
msgid "Game library refreshed" msgid "Game library refreshed"
msgstr "" msgstr ""
@@ -554,6 +723,9 @@ msgstr ""
msgid "Clear Prefix" msgid "Clear Prefix"
msgstr "" msgstr ""
msgid "Download other WINE"
msgstr ""
msgid "Launching tool..." msgid "Launching tool..."
msgstr "" msgstr ""
@@ -614,18 +786,6 @@ msgstr ""
msgid "Failed to delete prefix: {}" msgid "Failed to delete prefix: {}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..." msgid "Main PortProton parameters..."
msgstr "" msgstr ""
@@ -770,40 +930,6 @@ msgstr ""
msgid "Executable not found: {0}" msgid "Executable not found: {0}"
msgstr "" msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Executable not found for EGS game: {0}" msgid "Executable not found for EGS game: {0}"
msgstr "" msgstr ""
@@ -894,9 +1020,6 @@ msgstr ""
msgid "Run the application in a terminal" msgid "Run the application in a terminal"
msgstr "" msgstr ""
msgid "Disable startup mode and WINE version selector window"
msgstr ""
msgid "Use system GameMode for performance optimization" msgid "Use system GameMode for performance optimization"
msgstr "" msgstr ""
@@ -969,10 +1092,10 @@ msgstr ""
msgid "Prefix Name" msgid "Prefix Name"
msgstr "" msgstr ""
msgid "Select the Wine prefix to use." msgid "Specify the Wine prefix to run this game with"
msgstr "" msgstr ""
msgid "Latest" msgid "Newest"
msgstr "" msgstr ""
msgid "Stable" msgid "Stable"
@@ -982,31 +1105,15 @@ msgid "Vulkan Backend"
msgstr "" msgstr ""
msgid "" msgid ""
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n" "Select the DirectX → Vulkan/OpenGL backend:\n"
"\n" "\n"
"• Auto latest DXVK + VKD3D (recommended)\n" "• Newest latest DXVK + VKD3D (best compatibility/performance, requires "
" The newest versions from the developers. Give the best compatibility " "modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"and performance in modern games.\n" "• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
" Require up-to-date drivers:\n" "driver)\n"
" AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n" "• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
" NVIDIA: driver 550.54.14 or newer\n" "Vulkan 1.1+)\n"
" Intel: Mesa 24.2+\n" "• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
"\n"
"• Stable proven DXVK + VKD3D\n"
" Older but extremely well-tested versions. Work on any drivers that "
"support Vulkan 1.3+.\n"
" The best choice if you have problems with the newest versions.\n"
"\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek\n"
" Work even on older drivers and video cards that support at least "
"Vulkan 1.1.\n"
"\n"
"• WINED3D OpenGL translation (fallback)\n"
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in "
"WineD3D.\n"
" Works on absolutely any hardware, but performance is significantly "
"lower.\n"
" Use only as a last resort when nothing else starts."
msgstr "" msgstr ""
msgid "Windows version" msgid "Windows version"

View File

@@ -1,16 +1,16 @@
# Russian (Russia) translations for PortProtonQt. # Russian (Russia) translations for PortProtonQt.
# Copyright (C) 2025 boria138 # Copyright (C) 2026 boria138
# This file is distributed under the same license as the PortProtonQt # This file is distributed under the same license as the PortProtonQt
# project. # project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025. # FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-11-23 15:42+0500\n" "POT-Creation-Date: 2026-01-03 20:33+0500\n"
"PO-Revision-Date: 2025-11-23 15:42+0500\n" "PO-Revision-Date: 2026-01-03 20:32+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n" "Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
@@ -117,15 +117,15 @@ msgstr "Импортируется '{game_name}' в Legendary..."
#, python-brace-format #, python-brace-format
msgid "Added '{game_name}' to favorites" msgid "Added '{game_name}' to favorites"
msgstr "'{game_name}' был(а) добавлен(а) в избранное" msgstr "'{game_name}' добавлен(а) в избранное"
#, python-brace-format #, python-brace-format
msgid "Removed '{game_name}' from favorites" msgid "Removed '{game_name}' from favorites"
msgstr "'{game_name}' был(а) удалён(а) из избранного" msgstr "'{game_name}' удалён(а) из избранного"
#, python-brace-format #, python-brace-format
msgid "start.sh not found at {path}" msgid "start.sh not found at {path}"
msgstr "start.sh не найден по адресу {path}" msgstr "start.sh не найден в {path}"
#, python-brace-format #, python-brace-format
msgid "Launch game \"{name}\" with PortProton" msgid "Launch game \"{name}\" with PortProton"
@@ -137,7 +137,7 @@ msgstr "Не удалось создать файл .desktop: {error}"
#, python-brace-format #, python-brace-format
msgid "Added '{game_name}' to {location}" msgid "Added '{game_name}' to {location}"
msgstr "`'{game_name}' был(а) добавлен(а) в {location}`" msgstr "`'{game_name}' добавлен(а) в {location}`"
msgid "Desktop" msgid "Desktop"
msgstr "Рабочий стол" msgstr "Рабочий стол"
@@ -152,7 +152,7 @@ msgstr "Не удалось удалить '{game_name}' из {location}: {error
#, python-brace-format #, python-brace-format
msgid "Removed '{game_name}' from {location}" msgid "Removed '{game_name}' from {location}"
msgstr "'{game_name}' был(а) удалён(а) из {location}" msgstr "'{game_name}' удалён(а) из {location}"
msgid "Menu" msgid "Menu"
msgstr "Меню" msgstr "Меню"
@@ -171,7 +171,7 @@ 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}'"
msgstr "Файл .desktop для '{game_name}' не найден" msgstr "Не найден файл .desktop для '{game_name}'"
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "Подтвердите удаление" msgstr "Подтвердите удаление"
@@ -190,7 +190,7 @@ msgstr "Не удалось удалить файл .desktop: {error}"
#, python-brace-format #, python-brace-format
msgid "Deleted '{game_name}' successfully" msgid "Deleted '{game_name}' successfully"
msgstr "'{game_name}' был(а) успешно удалён(а)" msgstr "'{game_name}' успешно удалён(а)"
#, python-brace-format #, python-brace-format
msgid "Failed to delete custom data: {error}" msgid "Failed to delete custom data: {error}"
@@ -212,7 +212,7 @@ msgstr "Не удалось удалить старый файл .desktop: {erro
#, python-brace-format #, python-brace-format
msgid "Removed old .desktop file for '{game_name}'" msgid "Removed old .desktop file for '{game_name}'"
msgstr "Старый файл .desktop для '{game_name}' был(а) удалён(а)" msgstr "Удален старый файл .desktop для '{game_name}'"
#, python-brace-format #, python-brace-format
msgid "Failed to save .desktop file: {error}" msgid "Failed to save .desktop file: {error}"
@@ -222,6 +222,10 @@ msgstr "Не удалось сохранить файл .desktop: {error}"
msgid "Failed to copy cover image: {error}" msgid "Failed to copy cover image: {error}"
msgstr "Не удалось скопировать обложку: {error}" msgstr "Не удалось скопировать обложку: {error}"
#, python-brace-format
msgid "Unsupported image format: {extension}"
msgstr "Неподдерживаемый формат изображения: {extension}"
#, python-brace-format #, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}" msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "Не удалось добавить '{game_name}' в Steam: {error}" msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
@@ -259,11 +263,112 @@ msgstr "Удалить"
msgid "Select All" msgid "Select All"
msgstr "Выбрать всё" msgstr "Выбрать всё"
msgid "Delete Wine"
msgstr "Удалить Wine"
msgid "Selected WINE:"
msgstr "Выбранные WINE:"
msgid "No WINE selected"
msgstr "Обложка не выбрана"
msgid "Delete Selected"
msgstr "Удалить выбранное"
msgid "Clear All"
msgstr "Очистить всё"
#, python-brace-format
msgid "Selected {} WINE:\n"
msgstr "Выбранно {} WINE:\n"
msgid "No Selection"
msgstr "Не выбрано"
msgid "Please select at least one WINE to delete."
msgstr "Пожалуйста выберите хотя бы один WINE для удаления."
#, python-brace-format
msgid ""
"Are you sure you want to delete the following WINE versions?\n"
"\n"
"{}"
msgstr ""
"Вы уверены, что хотите удалить следующие версии WINE?\n"
"\n"
"{}"
#, python-brace-format
msgid "Failed to delete WINE '{}': {}"
msgstr "Не удалось удалить WINE '{}': {}"
msgid "Some Deletions Failed"
msgstr "Некоторые удаления не удалось выполнить"
#, python-brace-format
msgid ""
"Some WINE versions could not be deleted:\n"
"\n"
"{}"
msgstr ""
"Некоторые версии WINE не удалось удалить:\n"
"\n"
"{}"
msgid "Selected WINE versions deleted successfully."
msgstr "Выбранные версии WINE успешно удалены."
msgid "Back"
msgstr "Назад"
msgid "LAST LAUNCH"
msgstr "Последний запуск"
msgid "PLAY TIME"
msgstr "Время игры"
msgid "MAIN STORY"
msgstr "СЮЖЕТ"
msgid "MAIN + SIDES"
msgstr "СЮЖЕТ + ПОБОЧКИ"
msgid "COMPLETIONIST"
msgstr "100%"
msgid "full"
msgstr "полная"
msgid "partial"
msgstr "частичная"
msgid "none"
msgstr "отсутствует"
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr "Поддержка геймпадов: {0}"
msgid "Stop"
msgstr "Остановить"
msgid "Play"
msgstr "Играть"
msgid "Settings"
msgstr "Настройки"
msgid "Reinstall"
msgstr "Переустановить"
msgid "Install"
msgstr "Установить"
msgid "Open" msgid "Open"
msgstr "Открыть" msgstr "Открыть"
msgid "Select Dir" msgid "Select Dir"
msgstr "Выбрать папку" msgstr "Выбрать каталог"
msgid "Prev Dir" msgid "Prev Dir"
msgstr "Предыдущий каталог" msgstr "Предыдущий каталог"
@@ -274,9 +379,6 @@ msgstr "Отмена"
msgid "Toggle" msgid "Toggle"
msgstr "Переключить" msgstr "Переключить"
msgid "Install"
msgstr "Установить"
msgid "Force Install" msgid "Force Install"
msgstr "Принудительно установить" msgstr "Принудительно установить"
@@ -294,7 +396,7 @@ msgstr "Поиск"
#, python-brace-format #, python-brace-format
msgid "Launching {0}" msgid "Launching {0}"
msgstr "Идёт запуск {0}" msgstr "Запуск {0}"
msgid "File Explorer" msgid "File Explorer"
msgstr "Проводник" msgstr "Проводник"
@@ -325,7 +427,10 @@ msgid "Browse..."
msgstr "Обзор..." msgstr "Обзор..."
msgid "Custom Cover:" msgid "Custom Cover:"
msgstr "Обложка:" msgstr "Пользовательская обложка:"
msgid "Enter local path or URL for cover image"
msgstr "Введите локальный путь или URL обложки"
msgid "Cover Preview:" msgid "Cover Preview:"
msgstr "Предпросмотр обложки:" msgstr "Предпросмотр обложки:"
@@ -360,9 +465,6 @@ msgstr "Описание"
msgid "Fonts" msgid "Fonts"
msgstr "Шрифты" msgstr "Шрифты"
msgid "Settings"
msgstr "Настройки"
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку." msgstr "Winetricks не найден. Повторите попытку."
@@ -409,7 +511,7 @@ msgid "Info"
msgstr "Информация" msgstr "Информация"
msgid "No changes to apply." msgid "No changes to apply."
msgstr "Изменений для применения нет." msgstr "Нет изменений для применения."
msgid "Failed to apply changes. Check logs." msgid "Failed to apply changes. Check logs."
msgstr "Не удалось применить изменения. Проверьте логи." msgstr "Не удалось применить изменения. Проверьте логи."
@@ -456,9 +558,85 @@ msgstr "Бронза"
msgid "Pending" msgid "Pending"
msgstr "В ожидании" msgstr "В ожидании"
msgid "Get other Wine"
msgstr "Загрузка WINE"
msgid "Selected assets:"
msgstr "Выбранные WINE:"
msgid "No assets selected"
msgstr "WINE не выбраны"
msgid "Downloading: "
msgstr "Скачивание: "
msgid "Download Selected"
msgstr "Скачать выбранное"
msgid "Asset Name"
msgstr "Наименование WINE"
#, python-brace-format
msgid "Selected {} assets:\n"
msgstr "Выбранно {} WINE:\n"
msgid "Downloading in Progress"
msgstr "Скачивание"
msgid "Cannot clear selection while extraction is in progress."
msgstr "Невозможно очистить выделение во время распаковки."
msgid "Please select at least one archive to download."
msgstr "Пожалуйста выберите хотя бы один WINE для скачивания."
msgid "Please wait for current downloading to complete."
msgstr "Пожалуйста подождите завершения скачивания."
msgid "Downloading Complete"
msgstr "Скачивание завершено"
msgid "All selected archives have been downloaded!"
msgstr "Все выбранные WINE успешно загружены!"
#, python-brace-format
msgid "Downloading: {0} ({1}%)"
msgstr "Загрузка: {0} ({1}%)"
#, python-brace-format
msgid "Extracting: {0}"
msgstr "Распаковка {0}"
#, python-brace-format
msgid ", ETA: {}s"
msgstr ", Расчетное время: {}с"
#, python-brace-format
msgid ", Speed: {:.1f}MB/s"
msgstr ", Скорость: {:.1f}МБ/с"
#, python-brace-format
msgid "Extracting: {0}{1}{2}"
msgstr "Распаковка: {0}{1}{2}"
msgid "Extraction Error"
msgstr "Ошибка распаковки"
#, python-brace-format
msgid "Failed to extract archive: {0}"
msgstr "Не удалось извлечь архив: {0}"
msgid "Operation Cancelled"
msgstr "Операция отменена"
msgid "Download or extraction has been cancelled."
msgstr "Скачивание или распаковка успешно отменена."
msgid "Unknown Game" msgid "Unknown Game"
msgstr "Неизвестная игра" msgstr "Неизвестная игра"
msgid "Starting PortProton..."
msgstr "Инициализация PortProton..."
msgid "Library" msgid "Library"
msgstr "Библиотека" msgstr "Библиотека"
@@ -466,7 +644,7 @@ msgid "Auto Install"
msgstr "Автоустановка" msgstr "Автоустановка"
msgid "Wine Settings" msgid "Wine Settings"
msgstr "Настройки wine" msgstr "Настройки WINE"
msgid "PortProton Settings" msgid "PortProton Settings"
msgstr "Настройки PortProton" msgstr "Настройки PortProton"
@@ -474,12 +652,12 @@ msgstr "Настройки PortProton"
msgid "Themes" msgid "Themes"
msgstr "Темы" msgstr "Темы"
msgid "Back"
msgstr "Назад"
msgid "Fullscreen" msgid "Fullscreen"
msgstr "Полный экран" msgstr "Полный экран"
msgid "Refresh Grid"
msgstr "Обновить"
msgid "Installation already in progress." msgid "Installation already in progress."
msgstr "Установка уже выполняется." msgstr "Установка уже выполняется."
@@ -488,7 +666,7 @@ msgstr "Не удалось запустить установку."
#, python-brace-format #, python-brace-format
msgid "Processed {} installation..." msgid "Processed {} installation..."
msgstr "В процессе установки {}..." msgstr "{} в процессе установки..."
msgid "Installation completed successfully." msgid "Installation completed successfully."
msgstr "Установка завершена успешно." msgstr "Установка завершена успешно."
@@ -499,17 +677,14 @@ msgstr "Установка не удалась."
msgid "Installation error." msgid "Installation error."
msgstr "Ошибка установки." msgstr "Ошибка установки."
msgid "Refresh Grid"
msgstr "Обновить"
msgid "Game library refreshed" msgid "Game library refreshed"
msgstr "Игровая библиотека обновлена" msgstr "Игровая библиотека обновлена"
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..." msgstr "Загрузка игр Steam..."
msgid "Loading PortProton games..." msgid "Loading PortProton games..."
msgstr "Загрузка игр из PortProton..." msgstr "Загрузка игр PortProton..."
msgid "Game Library" msgid "Game Library"
msgstr "Игровая библиотека" msgstr "Игровая библиотека"
@@ -531,13 +706,13 @@ msgid "Added '{name}'"
msgstr "'{name}' добавлен(а)" msgstr "'{name}' добавлен(а)"
msgid "Compatibility tool:" msgid "Compatibility tool:"
msgstr "Инструмент совместимости:" msgstr "WINE:"
msgid "Prefix:" msgid "Prefix:"
msgstr "Префикс:" msgstr "Префикс:"
msgid "Wine Configuration" msgid "Wine Configuration"
msgstr "Конфигурация Wine" msgstr "Конфигурация WINE"
msgid "Registry Editor" msgid "Registry Editor"
msgstr "Редактор реестра" msgstr "Редактор реестра"
@@ -555,7 +730,7 @@ msgid "Load Prefix Backup"
msgstr "Загрузить резервную копию префикса" msgstr "Загрузить резервную копию префикса"
msgid "Delete Compatibility Tool" msgid "Delete Compatibility Tool"
msgstr "Удалить Инструмент совместимости" msgstr "Удалить WINE"
msgid "Delete Prefix" msgid "Delete Prefix"
msgstr "Удалить Префикс" msgstr "Удалить Префикс"
@@ -563,6 +738,9 @@ msgstr "Удалить Префикс"
msgid "Clear Prefix" msgid "Clear Prefix"
msgstr "Очистить Префикс" msgstr "Очистить Префикс"
msgid "Download other WINE"
msgstr "Скачать другие WINE"
msgid "Launching tool..." msgid "Launching tool..."
msgstr "Запуск инструмента..." msgstr "Запуск инструмента..."
@@ -583,11 +761,11 @@ msgid "Failed to start prefix clear process."
msgstr "Не удалось запустить процесс очистки префикса." msgstr "Не удалось запустить процесс очистки префикса."
msgid "Prefix cleared successfully." msgid "Prefix cleared successfully."
msgstr "Префикс удален успешно." msgstr "Префикс очищен успешно."
#, python-brace-format #, python-brace-format
msgid "Prefix clear failed with exit code {}." msgid "Prefix clear failed with exit code {}."
msgstr "Очистка префикса завершилась с кодом завершения {}." msgstr "Очистка префикса завершилась с кодом ошибки {}."
#, python-brace-format #, python-brace-format
msgid "Failed to run clear prefix command: {}" msgid "Failed to run clear prefix command: {}"
@@ -623,47 +801,35 @@ msgstr "Префикс «{}» удален."
msgid "Failed to delete prefix: {}" msgid "Failed to delete prefix: {}"
msgstr "Не удалось удалить префикс: {}" msgstr "Не удалось удалить префикс: {}"
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?"
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr "Инструмент совместимости «{}» удален."
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr "Не удалось удалить инструмент совместимости: {}"
msgid "Main PortProton parameters..." msgid "Main PortProton parameters..."
msgstr "Основные параметры PortProton..." msgstr "Основные параметры PortProton..."
msgid "detailed" msgid "detailed"
msgstr "детальный" msgstr "Детальный"
msgid "brief" msgid "brief"
msgstr "упрощённый" msgstr "Упрощённый"
msgid "Time Detail Level:" msgid "Time Detail Level:"
msgstr "Уровень детализации вывода времени:" msgstr "Уровень детализации вывода времени:"
msgid "last launch" msgid "last launch"
msgstr "последний запуск" msgstr "Последний запуск"
msgid "playtime" msgid "playtime"
msgstr "время игры" msgstr "Время игры"
msgid "alphabetical" msgid "alphabetical"
msgstr "алфавитный" msgstr "Алфавитный"
msgid "favorites" msgid "favorites"
msgstr "избранное" msgstr "Избранное"
msgid "Games Sort Method:" msgid "Games Sort Method:"
msgstr "Метод сортировки игр:" msgstr "Метод сортировки игр:"
msgid "all" msgid "all"
msgstr "все" msgstr "Все"
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "Фильтр игр:" msgstr "Фильтр игр:"
@@ -775,46 +941,12 @@ msgstr "Тема '{0}' применена успешно"
#, python-brace-format #, python-brace-format
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'" msgstr "Ошибка применения темы '{0}'"
#, python-brace-format #, python-brace-format
msgid "Executable not found: {0}" msgid "Executable not found: {0}"
msgstr "Исполняемый файл не найден: {0}" msgstr "Исполняемый файл не найден: {0}"
msgid "LAST LAUNCH"
msgstr "Последний запуск"
msgid "PLAY TIME"
msgstr "Время игры"
msgid "MAIN STORY"
msgstr "СЮЖЕТ"
msgid "MAIN + SIDES"
msgstr "СЮЖЕТ + ПОБОЧКИ"
msgid "COMPLETIONIST"
msgstr "100%"
msgid "full"
msgstr "полная"
msgid "partial"
msgstr "частичная"
msgid "none"
msgstr "отсутствует"
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr "Поддержка геймпадов: {0}"
msgid "Stop"
msgstr "Остановить"
msgid "Play"
msgstr "Играть"
#, python-brace-format #, python-brace-format
msgid "Executable not found for EGS game: {0}" msgid "Executable not found for EGS game: {0}"
msgstr "Не найден исполняемый файл для игры EGS: {0}" msgstr "Не найден исполняемый файл для игры EGS: {0}"
@@ -843,8 +975,8 @@ msgid ""
"Using FPS and system load monitoring (Turns on and off by the key " "Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)" "combination - right Shift + F12)"
msgstr "" msgstr ""
"Использование мониторинга FPS и нагрузки системы (включается и " "Использование мониторинга производительности и FPS(включается и "
"выключается комбинацией клавиш - правая Shift + F12)" "выключается комбинацией клавиш - R_Shift + F12)"
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)" msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr "Принудительное использование системных настроек MANGOHUD (GOverlay и т.д.)" msgstr "Принудительное использование системных настроек MANGOHUD (GOverlay и т.д.)"
@@ -853,11 +985,11 @@ msgid ""
"Enable vkBasalt by default to improve graphics in games running on " "Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)" "Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr "" msgstr ""
"Включить vkBasalt по умолчанию для улучшения графики в играх на Vulkan. " "Включить vkBasalt по умолчанию, для улучшения графики в играх на Vulkan. "
"(Горячая клавиша HOME отключает vkbasalt)" "(Горячая клавиша HOME отключает vkbasalt)"
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)" msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr "Принудительное использование системных настроек VKBASALT (GOverlay и т.д.)" msgstr "Принудительное использование системных настроек vkBasalt (GOverlay и т.д.)"
msgid "" msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, " "Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
@@ -877,16 +1009,16 @@ msgid ""
"Super + G : Toggle keyboard grab\n" "Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard" "Super + C : Update clipboard"
msgstr "" msgstr ""
"Super + F: Переключить полноэкранный режим\n" "Super + F: Переключение полноэкранного режима\n"
"Super + N: Переключить фильтрацию ближайшего соседа\n" "Super + N: Переключение фильтрации\n"
"Super + U: Переключить апскейлинг FSR\n" "Super + U: Переключение режима масштабирования на FSR\n"
"Super + Y: Переключить апскейлинг NIS\n" "Super + Y: Переключение режима масштабирования на NIS\n"
"Super + I: Увеличить резкость FSR на 1\n" "Super + I: Увеличение резкости FSR на 1\n"
"Super + O: Уменьшить резкость FSR на 1\n" "Super + O: Уменьшение резкости FSR на 1\n"
"Super + S: Сделать скриншот (сейчас сохраняется в " "Super + S: Сделать скриншот (сейчас сохраняется в "
"/tmp/gamescope_DATE.png)\n" "/tmp/gamescope_DATE.png)\n"
"Super + G: Переключить захват клавиатуры\n" "Super + G: Переключение захвата клавиатуры\n"
"Super + C: Обновить буфер обмена" "Super + C: Обновление буфера обмена"
msgid "Enable in-process synchronization primitives based on eventfd." msgid "Enable in-process synchronization primitives based on eventfd."
msgstr "Включить примитивы синхронизации в процессе на основе eventfd." msgstr "Включить примитивы синхронизации в процессе на основе eventfd."
@@ -907,7 +1039,7 @@ msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr "Включить OptiScaler (замена апскейлера / генератора кадров)" msgstr "Включить OptiScaler (замена апскейлера / генератора кадров)"
msgid "Enable Lossless Scaling frame generation (experimental)" msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr "Включить генерацию кадров Lossless Scaling (экспериментально)" msgstr "Включить генерацию кадров Lossless Scaling + lsfg-vk (экспериментально)"
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution" msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr "Апскейлинг FSR в полноэкранном режиме с ProtonGE ниже родного разрешения" msgstr "Апскейлинг FSR в полноэкранном режиме с ProtonGE ниже родного разрешения"
@@ -921,9 +1053,6 @@ msgstr "Запускать приложение в виртуальном раб
msgid "Run the application in a terminal" msgid "Run the application in a terminal"
msgstr "Запускать приложение в терминале" msgstr "Запускать приложение в терминале"
msgid "Disable startup mode and WINE version selector window"
msgstr "Отключить окно выбора режима запуска и версии WINE"
msgid "Use system GameMode for performance optimization" msgid "Use system GameMode for performance optimization"
msgstr "Использовать системный GameMode для оптимизации производительности" msgstr "Использовать системный GameMode для оптимизации производительности"
@@ -949,10 +1078,10 @@ msgid "Force use of built-in DXGI library"
msgstr "Принудительно использовать встроенную библиотеку DXGI" msgstr "Принудительно использовать встроенную библиотеку DXGI"
msgid "Enable Easy Anti-Cheat and BattlEye runtimes" msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr "Включить среды выполнения Easy Anti-Cheat и BattlEye" msgstr "Включить поддержку Easy Anti-Cheat и BattlEye"
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)" msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr "Использовать системные слои Vulkan (MangoHud, vkBasalt, OBS и т.д.)" msgstr "Использовать системные Vulkan layers (MangoHud, vkBasalt, OBS и т.д.)"
msgid "Enable OBS Studio capture via obs-vkcapture" msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr "Включить захват OBS Studio через obs-vkcapture" msgstr "Включить захват OBS Studio через obs-vkcapture"
@@ -982,82 +1111,54 @@ msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr "Использовать бэкенд Vulkan WineD3D (Damavand)" msgstr "Использовать бэкенд Vulkan WineD3D (Damavand)"
msgid "Use bundled dxvk/vkd3d from Wine/Proton" msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr "Использовать встроенные dxvk/vkd3d из Wine/Proton" msgstr "Использовать встроенные dxvk/vkd3d из WINE/Proton"
msgid "Use async dxvk-sarek (experimental)" msgid "Use async dxvk-sarek (experimental)"
msgstr "Использовать асинхронный dxvk-sarek (экспериментально)" msgstr "Использовать асинхронный dxvk-sarek (экспериментально)"
msgid "Wine Version" msgid "Wine Version"
msgstr "Версия Wine" msgstr "Версия WINE"
msgid "Select the Wine or Proton version to use for this executable." msgid "Select the Wine or Proton version to use for this executable."
msgstr "Выбор версии Wine или Proton для использования с этим исполняемым файлом." msgstr "Выбор версии WINE или Proton для использования с этим исполняемым файлом."
msgid "Prefix Name" msgid "Prefix Name"
msgstr "Имя префикса" msgstr "Имя префикса"
msgid "Select the Wine prefix to use." msgid "Specify the Wine prefix to run this game with"
msgstr "Выбор версии Wine для использования." msgstr "Укажите префикс WINE для запуска этой игры"
msgid "Latest" msgid "Newest"
msgstr "Последние" msgstr "Новейший"
msgid "Stable" msgid "Stable"
msgstr "Стабильные" msgstr "Стабильный"
msgid "Vulkan Backend" msgid "Vulkan Backend"
msgstr "Vulkan рендеринг" msgstr "Vulkan рендеринг"
msgid "" msgid ""
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n" "Select the DirectX → Vulkan/OpenGL backend:\n"
"\n" "\n"
"• Auto latest DXVK + VKD3D (recommended)\n" "• Newest latest DXVK + VKD3D (best compatibility/performance, requires "
" The newest versions from the developers. Give the best compatibility " "modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"and performance in modern games.\n" "• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
" Require up-to-date drivers:\n" "driver)\n"
" AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n" "• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
" NVIDIA: driver 550.54.14 or newer\n" "Vulkan 1.1+)\n"
" Intel: Mesa 24.2+\n" "• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
"\n"
"• Stable proven DXVK + VKD3D\n"
" Older but extremely well-tested versions. Work on any drivers that "
"support Vulkan 1.3+.\n"
" The best choice if you have problems with the newest versions.\n"
"\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek\n"
" Work even on older drivers and video cards that support at least "
"Vulkan 1.1.\n"
"\n"
"• WINED3D OpenGL translation (fallback)\n"
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in "
"WineD3D.\n"
" Works on absolutely any hardware, but performance is significantly "
"lower.\n"
" Use only as a last resort when nothing else starts."
msgstr "" msgstr ""
"Выбор рендеринга для трансляции DirectX → Vulkan/OpenGL:\n" "Выберите бэкэнд DirectX → Vulkan/OpenGL:\n"
"\n" "\n"
"• Авто последние версии DXVK + VKD3D (рекомендуется)\n" "• Новейший — последние версии DXVK + VKD3D (наилучшая "
" Новейшие версии от разработчиков. Обеспечивают наилучшую совместимость и" "совместимость/производительность, требует современных драйверов: AMD Mesa"
" производительность в современных играх.\n" " 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
" Требуются актуальные драйверы:\n" "• Стабильный — более старая, хорошо протестированная версия DXVK + VKD3D "
" AMD: Mesa 25.0+ или проприетарный AMDVLK 2024.Q4+\n" "(работает с любыми драйверами Vulkan 1.3+)\n"
" NVIDIA: 550.54.14 или новее\n" "• Sarek — экспериментальная версия DXVK-Sarek + VKD3D-Sarek (поддерживает"
" Intel: Mesa 24.2+\n" " более старые драйверы, Vulkan 1.1+)\n"
"\n" "• WINED3D — резервный вариант OpenGL (наименьшая производительность, "
"• Стабильный проверенные версии DXVK + VKD3D\n" "используйте только в случае сбоя других вариантов)"
" Более старые, но тщательно протестированные версии. Работают с любыми "
"драйверами, поддерживающие Vulkan 1.3+.\n"
" Лучший выбор, если у вас возникли проблемы с последними версиями.\n"
"\n"
"• Sarek экспериментальная версия DXVK-Sarek + VKD3D-Sarek\n"
"Работает даже на старых драйверах и видеокартах, поддерживающих как "
"минимум Vulkan 1.1.\n"
"• WINED3D трансляция OpenGL (для видеокарт без поддержки Vulkan)\n"
"DXVK/VKD3D не используется. DirectX транслируется в OpenGL через "
"встроенную WineD3D.Работает абсолютно на любом оборудовании, но "
"производительность значительно снижается.Используйте только в крайнем "
"случае, когда ничего другое не запускается."
msgid "Windows version" msgid "Windows version"
msgstr "Версия Windows" msgstr "Версия Windows"
@@ -1209,7 +1310,7 @@ msgid "Return to Desktop"
msgstr "Вернуться на рабочий стол" msgstr "Вернуться на рабочий стол"
msgid "portprotonqt-session-select file not found at /usr/bin/" msgid "portprotonqt-session-select file not found at /usr/bin/"
msgstr "portprotonqt-session-select не найдет" msgstr "portprotonqt-session-select файл не найден в /usr/bin/"
msgid "Failed to reboot the system" msgid "Failed to reboot the system"
msgstr "Не удалось перезагрузить систему" msgstr "Не удалось перезагрузить систему"

View File

@@ -1,4 +1,5 @@
import gettext import gettext
import configparser
from pathlib import Path from pathlib import Path
import locale import locale
import os import os
@@ -102,3 +103,97 @@ def read_metadata_translations(metadata_file, language_code):
translations['description'] = line[len('description='):].strip() translations['description'] = line[len('description='):].strip()
return translations return translations
def get_screenshot_caption(base_filename, metainfo_file, language_code=None):
"""
Возвращает перевод названия скриншота на основе языка пользователя.
Args:
base_filename: Имя файла без расширения
metainfo_file: Путь к файлу metainfo.ini
language_code: Код языка (если None, будет определен автоматически)
Returns:
Переведенное название скриншота
"""
if language_code is None:
system_locale = get_system_locale()
language_code = system_locale.split('_')[0] if '_' in system_locale else system_locale
# Загружаем переводы из metainfo.ini
screenshot_translations = {}
if metainfo_file and os.path.exists(metainfo_file):
cp = configparser.ConfigParser()
cp.read(metainfo_file, encoding="utf-8")
if "Screenshots" in cp:
for key in cp.options("Screenshots"):
screenshot_translations[key] = cp.get("Screenshots", key)
# Ищем перевод в формате: base_filename_languagecode
caption = base_filename # По умолчанию используем базовое имя файла
if screenshot_translations:
# Попробуем перевод для конкретного языка (например, "library_ru")
lang_specific_key = f"{base_filename}_{language_code}"
# Попробуем английский перевод (например, "library_en")
english_key = f"{base_filename}_en"
if lang_specific_key in screenshot_translations:
caption = screenshot_translations[lang_specific_key]
elif english_key in screenshot_translations:
caption = screenshot_translations[english_key]
elif base_filename in screenshot_translations:
caption = screenshot_translations[base_filename] # fallback to untranslated key
return caption
def get_theme_translations(metainfo_file, language_code=None):
"""
Возвращает переводы названия и описания темы на основе языка пользователя.
Args:
metainfo_file: Путь к файлу metainfo.ini
language_code: Код языка (если None, будет определен автоматически)
Returns:
Словарь с полями 'name' и 'description' с переведенными значениями
"""
if language_code is None:
system_locale = get_system_locale()
language_code = system_locale.split('_')[0] if '_' in system_locale else system_locale
# Загружаем переводы из metainfo.ini
translations = {'name': '', 'description': ''}
if metainfo_file and os.path.exists(metainfo_file):
cp = configparser.ConfigParser()
cp.read(metainfo_file, encoding="utf-8")
if "Metainfo" in cp:
# Попробуем перевод названия для конкретного языка (например, "name_ru")
lang_specific_name_key = f"name_{language_code}"
# Попробуем английский перевод названия (например, "name_en")
english_name_key = "name_en"
# Ищем перевод названия
if cp.has_option("Metainfo", lang_specific_name_key):
translations['name'] = cp.get("Metainfo", lang_specific_name_key)
elif cp.has_option("Metainfo", english_name_key):
translations['name'] = cp.get("Metainfo", english_name_key)
elif cp.has_option("Metainfo", "name"):
translations['name'] = cp.get("Metainfo", "name")
# Попробуем перевод описания для конкретного языка (например, "description_ru")
lang_specific_desc_key = f"description_{language_code}"
# Попробуем английский перевод описания (например, "description_en")
english_desc_key = "description_en"
# Ищем перевод описания
if cp.has_option("Metainfo", lang_specific_desc_key):
translations['description'] = cp.get("Metainfo", lang_specific_desc_key)
elif cp.has_option("Metainfo", english_desc_key):
translations['description'] = cp.get("Metainfo", english_desc_key)
elif cp.has_option("Metainfo", "description"):
translations['description'] = cp.get("Metainfo", "description")
return translations

File diff suppressed because it is too large Load Diff

View File

@@ -124,8 +124,12 @@ class PortProtonAPI:
) )
break break
if self._check_file_exists(metadata_url, timeout): # Check if metadata already exists locally before attempting download
local_metadata_path = os.path.join(game_dir, "metadata.txt") local_metadata_path = os.path.join(game_dir, "metadata.txt")
if os.path.exists(local_metadata_path):
logger.debug(f"Metadata already exists locally for {exe_name}: {local_metadata_path}")
results["metadata"] = local_metadata_path
elif self._check_file_exists(metadata_url, timeout):
pending_downloads += 1 pending_downloads += 1
self.downloader.download_async( self.downloader.download_async(
metadata_url, metadata_url,
@@ -152,9 +156,17 @@ class PortProtonAPI:
except FileExistsError: except FileExistsError:
pass pass
cover_url = f"{self.base_url}/{exe_name}/cover.png"
local_cover_path = os.path.join(user_game_folder, "cover.png") local_cover_path = os.path.join(user_game_folder, "cover.png")
# Check if the cover already exists locally before attempting download
if os.path.exists(local_cover_path):
logger.debug(f"Async autoinstall cover already exists locally for {exe_name}: {local_cover_path}")
if callback:
callback(local_cover_path)
return
cover_url = f"{self.base_url}/{exe_name}/cover.png"
def on_cover_downloaded(local_path: str | None): def on_cover_downloaded(local_path: str | None):
if local_path: if local_path:
logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}") logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
@@ -175,6 +187,102 @@ class PortProtonAPI:
if callback: if callback:
callback(None) callback(None)
def download_autoinstall_metadata_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
"""Download autoinstall metadata.txt file."""
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
user_game_folder = os.path.join(autoinstall_root, exe_name)
if not os.path.isdir(user_game_folder):
try:
os.makedirs(user_game_folder, exist_ok=True)
except FileExistsError:
pass
local_metadata_path = os.path.join(user_game_folder, "metadata.txt")
# Check if the file already exists locally before attempting download
if os.path.exists(local_metadata_path):
logger.debug(f"Async autoinstall metadata already exists locally for {exe_name}: {local_metadata_path}")
if callback:
callback(local_metadata_path)
return
metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
def on_metadata_downloaded(local_path: str | None):
if local_path:
logger.info(f"Async autoinstall metadata downloaded for {exe_name}: {local_path}")
else:
logger.debug(f"No autoinstall metadata downloaded for {exe_name}")
if callback:
callback(local_path)
if self._check_file_exists(metadata_url, timeout):
self.downloader.download_async(
metadata_url,
local_metadata_path,
timeout=timeout,
callback=on_metadata_downloaded
)
else:
logger.debug(f"No autoinstall metadata found for {exe_name}")
if callback:
callback(None)
def get_autoinstall_description(self, exe_name: str, lang_code: str = "en") -> str | None:
"""Read description from downloaded metadata.txt file for autoinstall game.
Args:
exe_name: The executable name/script name
lang_code: Language code ("en" or "ru" for description)
Returns:
Description string or None if not found
"""
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
metadata_path = os.path.join(autoinstall_root, exe_name, "metadata.txt")
if not os.path.exists(metadata_path):
return None
try:
with open(metadata_path, encoding='utf-8') as f:
content = f.read()
# Parse the metadata content to extract description
# Format: description_en=... or description_ru=...
if lang_code == "ru":
pattern = r'^description_ru=(.*)$'
else:
pattern = r'^description_en=(.*)$'
import re
match = re.search(pattern, content, re.MULTILINE)
if match:
description = match.group(1).strip()
# Handle potential quoted strings
if description.startswith('"') and description.endswith('"'):
description = description[1:-1]
return description
else:
# Try fallback to the other language if the requested one is not found
fallback_lang = "ru" if lang_code == "en" else "en"
fallback_pattern = rf'^description_{fallback_lang}=(.*)$'
fallback_match = re.search(fallback_pattern, content, re.MULTILINE)
if fallback_match:
description = fallback_match.group(1).strip()
if description.startswith('"') and description.endswith('"'):
description = description[1:-1]
return description
except Exception as e:
logger.error(f"Error reading metadata for {exe_name}: {e}")
return None
def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]: def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]:
"""Extract display_name from # name comment and exe_name from autoinstall bash script.""" """Extract display_name from # name comment and exe_name from autoinstall bash script."""
try: try:
@@ -254,6 +362,8 @@ class PortProtonAPI:
try: try:
mod_time = os.path.getmtime(cache_file) mod_time = os.path.getmtime(cache_file)
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION: if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
# Add timeout protection for file operations
start_time = time.time()
with open(cache_file, "rb") as f: with open(cache_file, "rb") as f:
data = orjson.loads(f.read()) data = orjson.loads(f.read())
# Check signature # Check signature
@@ -261,6 +371,10 @@ class PortProtonAPI:
current_signature = self._compute_scripts_signature( current_signature = self._compute_scripts_signature(
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
) )
# Check for timeout during signature computation
if time.time() - start_time > 3: # 3 second timeout
logger.warning("Cache loading took too long, skipping cache")
return None
if cached_signature != current_signature: if cached_signature != current_signature:
logger.info("Scripts signature mismatch; invalidating cache") logger.info("Scripts signature mismatch; invalidating cache")
return None return None
@@ -287,21 +401,26 @@ class PortProtonAPI:
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None: def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
"""Start loading auto-install games in a background thread. Returns the thread for management.""" """Start loading auto-install games in a background thread. Returns the thread for management."""
# Check cache first (sync, fast)
cached_games = self._load_autoinstall_cache()
if cached_games is not None:
# Emit via callback immediately if cached
QThread.msleep(0) # Yield to Qt event loop
callback(cached_games)
return None # No thread needed
# No cache: Start background thread
class AutoinstallWorker(QThread): class AutoinstallWorker(QThread):
finished = Signal(list) finished = Signal(list)
api: "PortProtonAPI" api: "PortProtonAPI"
portproton_location: str | None portproton_location: str | None
def run(self): def run(self):
import time
# Check cache in this background thread, not in main thread
start_time = time.time()
cached_games = self.api._load_autoinstall_cache()
# If cache loading took too long (>2 seconds), skip cache and load directly
if time.time() - start_time > 2:
logger.warning("Cache loading took too long, proceeding without cache")
cached_games = None
if cached_games is not None:
self.finished.emit(cached_games)
return
# No cache: Load games from scratch
games = [] games = []
auto_dir = os.path.join( auto_dir = os.path.join(
self.portproton_location or "", "data", "scripts", "pw_autoinstall" self.portproton_location or "", "data", "scripts", "pw_autoinstall"
@@ -351,8 +470,23 @@ class PortProtonAPI:
if not cover_path: if not cover_path:
logger.debug(f"No local cover found for autoinstall {exe_name}") logger.debug(f"No local cover found for autoinstall {exe_name}")
# Try to get the description from metadata file
description = ""
# Look for metadata in the expected location
try:
import locale
current_locale = locale.getlocale()[0] or 'en'
except (AttributeError, IndexError, TypeError):
current_locale = 'en'
lang_code = 'ru' if current_locale and 'ru' in current_locale.lower() else 'en'
# Try to read description from downloaded metadata
metadata_description = self.api.get_autoinstall_description(exe_name, lang_code)
if metadata_description:
description = metadata_description
game_tuple = ( game_tuple = (
display_name, "", cover_path, "", f"autoinstall:{script_name}", display_name, description, cover_path, "", f"autoinstall:{script_name}",
"", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name "", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
) )
games.append(game_tuple) games.append(game_tuple)

View File

@@ -0,0 +1,240 @@
"""
Utility module for search optimizations including Trie, hash tables, and fuzzy matching.
"""
from typing import Any
from rapidfuzz import fuzz
from threading import Lock
from portprotonqt.logger import get_logger
from PySide6.QtCore import QThread, Signal, QObject
logger = get_logger(__name__)
class TrieNode:
"""Node in the Trie data structure."""
def __init__(self):
self.children = {}
self.is_end_word = False
self.payload = None # Store the original data in leaf nodes
class Trie:
"""Trie data structure for efficient prefix-based searching."""
def __init__(self):
self.root = TrieNode()
self._lock = Lock() # Thread safety for concurrent access
def insert(self, key: str, payload: Any):
"""Insert a key with payload into the Trie."""
with self._lock:
node = self.root
for char in key.lower():
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_end_word = True
node.payload = payload
def search_prefix(self, prefix: str) -> list[tuple[str, Any]]:
"""Find all entries with the given prefix."""
with self._lock:
node = self.root
for char in prefix.lower():
if char not in node.children:
return []
node = node.children[char]
results = []
self._collect_all(node, prefix.lower(), results)
return results
def _collect_all(self, node: TrieNode, current_prefix: str, results: list[tuple[str, Any]]):
"""Collect all entries from the current node."""
if node.is_end_word:
results.append((current_prefix, node.payload))
for char, child_node in node.children.items():
self._collect_all(child_node, current_prefix + char, results)
class FuzzySearchIndex:
"""Index for fuzzy string matching with rapidfuzz."""
def __init__(self, items: list[tuple[str, Any]] | None = None):
self.items: list[tuple[str, Any]] = items or []
self.normalized_items: list[tuple[str, Any]] = []
self._lock = Lock()
self._build_normalized_index()
def _build_normalized_index(self):
"""Build a normalized index for fuzzy matching."""
with self._lock:
self.normalized_items = [(self._normalize(item[0]), item[1]) for item in self.items]
def _normalize(self, s: str) -> str:
"""Normalize string for fuzzy matching."""
s = s.lower()
for ch in ["", "®"]:
s = s.replace(ch, "")
for ch in ["-", ":", ","]:
s = s.replace(ch, " ")
s = " ".join(s.split())
for suffix in ["bin", "app"]:
if s.endswith(suffix):
s = s[:-len(suffix)].strip()
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
words = s.split()
filtered_words = [word for word in words if word not in keywords_to_remove]
return " ".join(filtered_words)
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
"""Perform fuzzy search using rapidfuzz."""
with self._lock:
if not query or not self.normalized_items:
return []
query_normalized = self._normalize(query)
results = []
for i, (item_text, item_data) in enumerate(self.normalized_items):
score = fuzz.ratio(query_normalized, item_text)
if score >= min_score:
results.append((self.items[i][0], item_data, score))
# Sort by score descending
results.sort(key=lambda x: x[2], reverse=True)
return results[:limit]
class SearchOptimizer:
"""Main search optimization class combining multiple approaches."""
def __init__(self):
self.hash_index: dict[str, Any] = {}
self.trie_index = Trie()
self.fuzzy_index = None
self._lock = Lock()
def build_indices(self, items: list[tuple[str, Any]]):
"""Build all search indices from items."""
with self._lock:
self.hash_index = {item[0].lower(): item[1] for item in items}
self.trie_index = Trie()
for key, value in self.hash_index.items():
self.trie_index.insert(key, value)
self.fuzzy_index = FuzzySearchIndex(items)
def exact_search(self, key: str) -> Any | None:
"""Perform exact hash-based lookup."""
with self._lock:
return self.hash_index.get(key.lower())
def prefix_search(self, prefix: str) -> list[tuple[str, Any]]:
"""Perform prefix search using Trie."""
with self._lock:
return self.trie_index.search_prefix(prefix)
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
"""Perform fuzzy search."""
if self.fuzzy_index:
return self.fuzzy_index.fuzzy_search(query, limit, min_score)
return []
# Threaded search implementation using QThread for performance optimization
class ThreadedSearchWorker(QObject):
"""
A threaded worker for performing search operations without blocking the UI.
"""
search_started = Signal()
search_finished = Signal(list)
search_error = Signal(str)
def __init__(self):
super().__init__()
self.search_optimizer = SearchOptimizer()
self.games_data = []
def set_games_data(self, games_data: list):
"""Set the games data to be searched."""
self.games_data = games_data
# Build indices from the games data (name, description, etc.)
items = [(game[0], game) for game in games_data] # game[0] is the name
self.search_optimizer.build_indices(items)
def execute_search(self, search_text: str, search_type: str = "auto"):
"""
Execute search in a separate thread.
Args:
search_text: Text to search for
search_type: Type of search ("exact", "prefix", "fuzzy", "auto")
"""
try:
self.search_started.emit()
import time
start_time = time.time()
results = []
if search_type == "exact" or (search_type == "auto" and len(search_text) > 2):
exact_result = self.search_optimizer.exact_search(search_text)
if exact_result:
results = [exact_result]
elif search_type == "prefix":
results = self.search_optimizer.prefix_search(search_text)
elif search_type == "fuzzy" or search_type == "auto":
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
else:
# Auto-detect search type based on input
if len(search_text) < 3:
results = self.search_optimizer.prefix_search(search_text)
else:
# Try exact first, then fuzzy
exact_result = self.search_optimizer.exact_search(search_text)
if exact_result:
results = [exact_result]
else:
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
end_time = time.time()
print(f"Search completed in {end_time - start_time:.4f} seconds")
self.search_finished.emit(results)
except Exception as e:
self.search_error.emit(str(e))
class ThreadedSearch(QThread):
"""
QThread implementation for running search operations in the background.
"""
search_started = Signal()
search_finished = Signal(list)
search_error = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.worker = ThreadedSearchWorker()
self.search_text = ""
self.search_type = "auto"
self.games_data = []
# Connect worker signals to thread signals
self.worker.search_started.connect(self.search_started)
self.worker.search_finished.connect(self.search_finished)
self.worker.search_error.connect(self.search_error)
def set_games_data(self, games_data: list):
"""Set the games data to be searched."""
self.games_data = games_data
self.worker.set_games_data(games_data)
def run(self):
"""Run the search operation in the thread."""
self.worker.execute_search(self.search_text, self.search_type)

View File

@@ -20,7 +20,6 @@ def get_toggle_settings():
'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"), 'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"), 'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
'PW_USE_TERMINAL': _("Run the application in a terminal"), 'PW_USE_TERMINAL': _("Run the application in a terminal"),
'PW_GUI_DISABLED_CS': _("Disable startup mode and WINE version selector window"),
'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"), 'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"), 'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"), 'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
@@ -70,7 +69,7 @@ def get_advanced_settings(disabled_text, logical_core_options, locale_options,
advanced_settings.append({ advanced_settings.append({
'key': 'PW_PREFIX_NAME', 'key': 'PW_PREFIX_NAME',
'name': _("Prefix Name"), 'name': _("Prefix Name"),
'description': _("Select the Wine prefix to use."), 'description': _("Specify the Wine prefix to run this game with"),
'type': 'combo', 'type': 'combo',
'options': prefix_options, 'options': prefix_options,
'default': 'DEFAULT' 'default': 'DEFAULT'
@@ -78,7 +77,7 @@ def get_advanced_settings(disabled_text, logical_core_options, locale_options,
# 3. Vulkan Backend # 3. Vulkan Backend
vulkan_options = [ vulkan_options = [
_("Latest"), # → 6 _("Newest"), # → 6
_("Stable"), # → 2 _("Stable"), # → 2
("Sarek"), # → 1 ("Sarek"), # → 1
("WINED3D OpenGL") # → 0 ("WINED3D OpenGL") # → 0
@@ -96,22 +95,11 @@ def get_advanced_settings(disabled_text, logical_core_options, locale_options,
'key': 'PW_VULKAN_USE', 'key': 'PW_VULKAN_USE',
'name': _("Vulkan Backend"), 'name': _("Vulkan Backend"),
'description': _( 'description': _(
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n\n" "Select the DirectX → Vulkan/OpenGL backend:\n\n"
"Auto latest DXVK + VKD3D (recommended)\n" "Newest latest DXVK + VKD3D (best compatibility/performance, requires modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
" The newest versions from the developers. Give the best compatibility and performance in modern games.\n" "• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ driver)\n"
" Require up-to-date drivers:\n" "• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, Vulkan 1.1+)\n"
" AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n" "• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
" NVIDIA: driver 550.54.14 or newer\n"
" Intel: Mesa 24.2+\n\n"
"• Stable proven DXVK + VKD3D\n"
" Older but extremely well-tested versions. Work on any drivers that support Vulkan 1.3+.\n"
" The best choice if you have problems with the newest versions.\n\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek\n"
" Work even on older drivers and video cards that support at least Vulkan 1.1.\n\n"
"• WINED3D OpenGL translation (fallback)\n"
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in WineD3D.\n"
" Works on absolutely any hardware, but performance is significantly lower.\n"
" Use only as a last resort when nothing else starts."
), ),
'type': 'combo', 'type': 'combo',
'options': vulkan_options, 'options': vulkan_options,

View File

@@ -1,4 +1,3 @@
import functools
import os import os
import shlex import shlex
import subprocess import subprocess
@@ -262,21 +261,58 @@ def remove_duplicates(candidates):
""" """
return list(dict.fromkeys(candidates)) return list(dict.fromkeys(candidates))
@functools.lru_cache(maxsize=256) # Simple TTL cache for exiftool data with max entries to control memory usage
_EXIFTOOL_CACHE = {}
_CACHE_MAX_ENTRIES = 64 # Limit cache size to control memory
_CACHE_TTL = 300 # 5 minutes TTL
def get_exiftool_data(game_exe): def get_exiftool_data(game_exe):
"""Retrieves metadata using exiftool.""" """Retrieves metadata using exiftool with TTL-based caching."""
import time
current_time = time.time()
# Clean up expired entries periodically
if len(_EXIFTOOL_CACHE) > _CACHE_MAX_ENTRIES // 2: # Clean when half full
# Remove expired entries
expired_keys = [
key for key, (data, timestamp) in _EXIFTOOL_CACHE.items()
if current_time - timestamp > _CACHE_TTL
]
for key in expired_keys:
del _EXIFTOOL_CACHE[key]
# Check cache first
if game_exe in _EXIFTOOL_CACHE:
data, timestamp = _EXIFTOOL_CACHE[game_exe]
if current_time - timestamp <= _CACHE_TTL:
return data
else:
# Entry expired, remove it
del _EXIFTOOL_CACHE[game_exe]
try: try:
proc = subprocess.run( proc = subprocess.run(
["exiftool", "-j", game_exe], ["exiftool", "-j", game_exe],
capture_output=True, capture_output=True,
text=True, text=True,
check=False check=False,
timeout=10 # Add timeout to prevent hanging
) )
if proc.returncode != 0: if proc.returncode != 0:
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}") logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
return {} return {}
meta_data_list = orjson.loads(proc.stdout.encode("utf-8")) meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
return meta_data_list[0] if meta_data_list else {} result = meta_data_list[0] if meta_data_list else {}
# Add to cache if we have a reasonable result
if result and len(_EXIFTOOL_CACHE) < _CACHE_MAX_ENTRIES:
_EXIFTOOL_CACHE[game_exe] = (result, current_time)
return result
except subprocess.TimeoutExpired:
logger.error(f"exiftool timed out for {game_exe}")
return {}
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}") logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
return {} return {}
@@ -323,6 +359,17 @@ def load_steam_apps_async(callback: Callable[[list], None]):
logger.info("Deleted archive: %s", cache_tar) logger.info("Deleted archive: %s", cache_tar)
# Delete all cached app detail files (steam_app_*.json) # Delete all cached app detail files (steam_app_*.json)
delete_cached_app_files(cache_dir, "steam_app_*.json") delete_cached_app_files(cache_dir, "steam_app_*.json")
# Build the new index in the background and atomically update the cache
new_index = build_index(data) if isinstance(data, list) else {}
current_time = time.time()
# Atomically update the cache
with _STEAM_APPS_LOCK:
_STEAM_APPS_CACHE['data'] = data if isinstance(data, list) else []
_STEAM_APPS_CACHE['index'] = new_index
_STEAM_APPS_CACHE['timestamp'] = current_time
steam_apps = data if isinstance(data, list) else [] steam_apps = data if isinstance(data, list) else []
logger.info("Loaded %d apps from archive", len(steam_apps)) logger.info("Loaded %d apps from archive", len(steam_apps))
callback(steam_apps) callback(steam_apps)
@@ -373,25 +420,31 @@ def build_index(steam_apps):
return steam_apps_index return steam_apps_index
logger.info("Building Steam apps index") logger.info("Building Steam apps index")
for app in steam_apps: for app in steam_apps:
normalized = app["normalized_name"] normalized = app.get("normalized_name", "")
steam_apps_index[normalized] = app if normalized: # Only add if normalized_name exists
steam_apps_index[normalized] = app
return steam_apps_index return steam_apps_index
def search_app(candidate, steam_apps_index): def search_app(candidate, steam_apps_index):
""" """
Searches for an application by candidate: tries exact match first, then substring match. Searches for an application by candidate: tries exact match first, then partial match.
""" """
candidate_norm = normalize_name(candidate) candidate_norm = normalize_name(candidate)
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm) logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
# Exact match first (O(1) lookup)
if candidate_norm in steam_apps_index: if candidate_norm in steam_apps_index:
logger.info("Found exact match: '%s'", candidate_norm) logger.info("Found exact match: '%s'", candidate_norm)
return steam_apps_index[candidate_norm] return steam_apps_index[candidate_norm]
# If no exact match, try partial matching
for name_norm, app in steam_apps_index.items(): for name_norm, app in steam_apps_index.items():
if candidate_norm in name_norm: if candidate_norm in name_norm:
ratio = len(candidate_norm) / len(name_norm) ratio = len(candidate_norm) / len(name_norm)
if ratio > 0.8: if ratio > 0.8:
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio) logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
return app return app
logger.info("No app found for candidate '%s'", candidate_norm) logger.info("No app found for candidate '%s'", candidate_norm)
return None return None
@@ -420,7 +473,7 @@ def fetch_sgdb_cover(game_name: str) -> str:
try: try:
encoded = urllib.parse.quote(game_name) encoded = urllib.parse.quote(game_name)
url = f"https://steamgrid.usebottles.com/api/search/{encoded}" url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
resp = requests.get(url, timeout=5) resp = requests.get(url, timeout=10)
if resp.status_code != 200: if resp.status_code != 200:
logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code) logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
return "" return ""
@@ -431,17 +484,30 @@ def fetch_sgdb_cover(game_name: str) -> str:
if text: if text:
logger.info("Fetched SGDB cover for %s: %s", game_name, text) logger.info("Fetched SGDB cover for %s: %s", game_name, text)
return text return text
except requests.exceptions.Timeout:
logger.warning(f"SGDB request timed out for {game_name}")
return ""
except requests.exceptions.RequestException as e:
logger.warning(f"SGDB request error for {game_name}: {e}")
return ""
except Exception as e: except Exception as e:
logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e) logger.warning(f"Unexpected error while fetching SGDB cover for {game_name}: {e}")
return "" return ""
def check_url_exists(url: str) -> bool: def check_url_exists(url: str) -> bool:
"""Check whether a URL returns HTTP 200.""" """Check whether a URL returns HTTP 200."""
try: try:
r = requests.head(url, timeout=3) r = requests.head(url, timeout=5)
return r.status_code == 200 return r.status_code == 200
except Exception: except requests.exceptions.Timeout:
logger.warning(f"URL check timed out for: {url}")
return False
except requests.exceptions.RequestException as e:
logger.warning(f"Request error when checking URL {url}: {e}")
return False
except Exception as e:
logger.warning(f"Unexpected error when checking URL {url}: {e}")
return False return False
@@ -518,6 +584,16 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
if os.path.exists(cache_tar): if os.path.exists(cache_tar):
os.remove(cache_tar) os.remove(cache_tar)
logger.info("Deleted archive: %s", cache_tar) logger.info("Deleted archive: %s", cache_tar)
# Build the new index in the background and atomically update the cache
new_index = build_weanticheatyet_index(data) if isinstance(data, list) else {}
current_time = time.time()
# Atomically update the cache
with _ANTICHEAT_LOCK:
_ANTICHEAT_CACHE['data'] = data if isinstance(data, list) else []
_ANTICHEAT_CACHE['index'] = new_index
_ANTICHEAT_CACHE['timestamp'] = current_time
anti_cheat_data = data or [] anti_cheat_data = data or []
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data)) logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
callback(anti_cheat_data) callback(anti_cheat_data)
@@ -564,17 +640,25 @@ def build_weanticheatyet_index(anti_cheat_data):
return anti_cheat_index return anti_cheat_index
logger.info("Building WeAntiCheatYet data index") logger.info("Building WeAntiCheatYet data index")
for entry in anti_cheat_data: for entry in anti_cheat_data:
normalized = entry["normalized_name"] normalized = entry.get("normalized_name", "")
anti_cheat_index[normalized] = entry if normalized: # Only add if normalized_name exists
anti_cheat_index[normalized] = entry
return anti_cheat_index return anti_cheat_index
def search_anticheat_status(candidate, anti_cheat_index): def search_anticheat_status(candidate, anti_cheat_index):
"""
Searches for anti-cheat status by candidate: tries exact match first, then partial match.
"""
candidate_norm = normalize_name(candidate) candidate_norm = normalize_name(candidate)
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm) logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
# Exact match first (O(1) lookup)
if candidate_norm in anti_cheat_index: if candidate_norm in anti_cheat_index:
status = anti_cheat_index[candidate_norm]["status"] status = anti_cheat_index[candidate_norm]["status"]
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status) logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
return status return status
# If no exact match, try partial matching
for name_norm, entry in anti_cheat_index.items(): for name_norm, entry in anti_cheat_index.items():
if candidate_norm in name_norm: if candidate_norm in name_norm:
ratio = len(candidate_norm) / len(name_norm) ratio = len(candidate_norm) / len(name_norm)
@@ -582,20 +666,122 @@ def search_anticheat_status(candidate, anti_cheat_index):
status = entry["status"] status = entry["status"]
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status) logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
return status return status
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm) logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
return "" return ""
# Cache for WeAntiCheatYet data with timestamp for expiration
_ANTICHEAT_CACHE = {
'data': None,
'index': None,
'timestamp': 0
}
_ANTICHEAT_LOCK = threading.RLock() # Use RLock to allow reentrant calls
# Use a class to track loading state instead of dynamic function attributes
class AntiCheatDataLoader:
def __init__(self):
self._loading = False
self._pending_callbacks = []
def get_anticheat_data_and_index_async(self, callback: Callable[[tuple[list | None, dict | None]], None]):
"""
Asynchronously loads and caches anti-cheat data and their index.
Calls the callback with (anti_cheat_data, anti_cheat_index).
Implements proper cache expiration and thread safety with single index building.
"""
cache_duration = CACHE_DURATION
current_time = time.time()
with _ANTICHEAT_LOCK:
# Check if we have valid cached data
if (_ANTICHEAT_CACHE['data'] is not None and
_ANTICHEAT_CACHE['index'] is not None and
current_time - _ANTICHEAT_CACHE['timestamp'] < cache_duration):
callback((_ANTICHEAT_CACHE['data'], _ANTICHEAT_CACHE['index']))
return
# Check if there's already a loading operation in progress
if self._loading:
# Add this callback to the pending list to be called when loading completes
self._pending_callbacks.append(callback)
return
# Mark that loading is in progress
self._loading = True
self._pending_callbacks = []
def on_anticheat_data(anti_cheat_data: list):
current_time = time.time()
with _ANTICHEAT_LOCK:
# Only update cache if data is valid
if anti_cheat_data:
_ANTICHEAT_CACHE['data'] = anti_cheat_data
_ANTICHEAT_CACHE['index'] = build_weanticheatyet_index(anti_cheat_data)
_ANTICHEAT_CACHE['timestamp'] = current_time
cached_data = (_ANTICHEAT_CACHE['data'], _ANTICHEAT_CACHE['index'])
else:
# If loading failed, clear the cache to force reload on next attempt
_ANTICHEAT_CACHE['data'] = None
_ANTICHEAT_CACHE['index'] = None
_ANTICHEAT_CACHE['timestamp'] = 0
cached_data = (None, None)
# Mark loading as complete
self._loading = False
pending_callbacks = self._pending_callbacks
self._pending_callbacks = []
# Call the original callback
callback(cached_data)
# Call any pending callbacks that accumulated during loading
for pending_callback in pending_callbacks:
pending_callback(cached_data)
load_weanticheatyet_data_async(on_anticheat_data)
# Create a global instance for the anti-cheat data loader
_anticheat_loader = AntiCheatDataLoader()
def get_anticheat_data_and_index_async(callback: Callable[[tuple[list | None, dict | None]], None]):
"""
Asynchronously loads and caches anti-cheat data and their index.
Calls the callback with (anti_cheat_data, anti_cheat_index).
Implements proper cache expiration and thread safety with single index building.
"""
_anticheat_loader.get_anticheat_data_and_index_async(callback)
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]): def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
""" """
Asynchronously retrieves WeAntiCheatYet status for a game by name. Asynchronously retrieves WeAntiCheatYet status for a game by name.
Calls the callback with the status string or empty string if not found. Calls the callback with the status string or empty string if not found.
""" """
def on_anticheat_data(anti_cheat_data: list): def on_anticheat_data_and_index(data_and_index: tuple[list | None, dict | None]):
anti_cheat_index = build_weanticheatyet_index(anti_cheat_data) anti_cheat_data, anti_cheat_index = data_and_index
status = search_anticheat_status(game_name, anti_cheat_index) if anti_cheat_data and anti_cheat_index:
status = search_anticheat_status(game_name, anti_cheat_index)
else:
status = ""
callback(status) callback(status)
load_weanticheatyet_data_async(on_anticheat_data) get_anticheat_data_and_index_async(on_anticheat_data_and_index)
def clear_steam_api_caches():
"""Clears all cached data to force reload from files."""
global _STEAM_APPS_CACHE, _ANTICHEAT_CACHE
with _STEAM_APPS_LOCK:
_STEAM_APPS_CACHE = {
'data': None,
'index': None,
'timestamp': 0
}
with _ANTICHEAT_LOCK:
_ANTICHEAT_CACHE = {
'data': None,
'index': None,
'timestamp': 0
}
logger.info("Cleared Steam API caches")
def load_protondb_status(appid): def load_protondb_status(appid):
"""Loads cached ProtonDB data for a game by appid if not outdated.""" """Loads cached ProtonDB data for a game by appid if not outdated."""
@@ -747,9 +933,30 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True) candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True)
logger.info("Sorted candidates: %s", candidates_ordered) logger.info("Sorted candidates: %s", candidates_ordered)
def on_steam_apps(steam_apps: list): def on_steam_apps_and_index(data_and_index: tuple[list | None, dict | None]):
steam_apps_index = build_index(steam_apps) steam_apps, steam_apps_index = data_and_index
matching_app = None matching_app = None
if not steam_apps or not steam_apps_index:
# Handle case where data loading failed
game_name = desktop_name or exe_name
cover = fetch_sgdb_cover(game_name) or ""
logger.info("Using SGDB cover for non-Steam game due to data loading failure: %s", game_name)
def on_anticheat_status(anticheat_status: str):
callback({
"appid": "",
"name": decode_text(game_name),
"description": "",
"cover": cover,
"controller_support": "",
"protondb_tier": "",
"steam_game": "false",
"anticheat_status": anticheat_status
})
get_weanticheatyet_status_async(game_name, on_anticheat_status)
return
for candidate in candidates_ordered: for candidate in candidates_ordered:
if not candidate: if not candidate:
continue continue
@@ -826,31 +1033,88 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
fetch_app_info_async(appid, on_app_info) fetch_app_info_async(appid, on_app_info)
load_steam_apps_async(on_steam_apps) get_steam_apps_and_index_async(on_steam_apps_and_index)
_STEAM_APPS = None # Cache for Steam apps data with timestamp for expiration
_STEAM_APPS_INDEX = None _STEAM_APPS_CACHE = {
_STEAM_APPS_LOCK = threading.Lock() 'data': None,
'index': None,
'timestamp': 0
}
_STEAM_APPS_LOCK = threading.RLock() # Use RLock to allow reentrant calls
def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]): # Use a class to track loading state instead of dynamic function attributes
class SteamAppsLoader:
def __init__(self):
self._loading = False
self._pending_callbacks = []
def get_steam_apps_and_index_async(self, callback: Callable[[tuple[list | None, dict | None]], None]):
"""
Asynchronously loads and caches Steam apps and their index.
Calls the callback with (steam_apps, steam_apps_index).
Implements proper cache expiration and thread safety with single index building.
"""
cache_duration = CACHE_DURATION
current_time = time.time()
with _STEAM_APPS_LOCK:
# Check if we have valid cached data
if (_STEAM_APPS_CACHE['data'] is not None and
_STEAM_APPS_CACHE['index'] is not None and
current_time - _STEAM_APPS_CACHE['timestamp'] < cache_duration):
callback((_STEAM_APPS_CACHE['data'], _STEAM_APPS_CACHE['index']))
return
# Check if there's already a loading operation in progress
if self._loading:
# Add this callback to the pending list to be called when loading completes
self._pending_callbacks.append(callback)
return
# Mark that loading is in progress
self._loading = True
self._pending_callbacks = []
def on_steam_apps(steam_apps: list):
current_time = time.time()
with _STEAM_APPS_LOCK:
# Only update cache if data is valid
if steam_apps:
_STEAM_APPS_CACHE['data'] = steam_apps
_STEAM_APPS_CACHE['index'] = build_index(steam_apps)
_STEAM_APPS_CACHE['timestamp'] = current_time
cached_data = (_STEAM_APPS_CACHE['data'], _STEAM_APPS_CACHE['index'])
else:
# If loading failed, clear the cache to force reload on next attempt
_STEAM_APPS_CACHE['data'] = None
_STEAM_APPS_CACHE['index'] = None
_STEAM_APPS_CACHE['timestamp'] = 0
cached_data = (None, None)
# Mark loading as complete
self._loading = False
pending_callbacks = self._pending_callbacks
self._pending_callbacks = []
# Call the original callback
callback(cached_data)
# Call any pending callbacks that accumulated during loading
for pending_callback in pending_callbacks:
pending_callback(cached_data)
load_steam_apps_async(on_steam_apps)
# Create a global instance for the Steam apps loader
_steam_apps_loader = SteamAppsLoader()
def get_steam_apps_and_index_async(callback: Callable[[tuple[list | None, dict | None]], None]):
""" """
Asynchronously loads and caches Steam apps and their index. Asynchronously loads and caches Steam apps and their index.
Calls the callback with (steam_apps, steam_apps_index). Calls the callback with (steam_apps, steam_apps_index).
Implements proper cache expiration and thread safety with single index building.
""" """
global _STEAM_APPS, _STEAM_APPS_INDEX _steam_apps_loader.get_steam_apps_and_index_async(callback)
with _STEAM_APPS_LOCK:
if _STEAM_APPS is not None and _STEAM_APPS_INDEX is not None:
callback((_STEAM_APPS, _STEAM_APPS_INDEX))
return
def on_steam_apps(steam_apps: list):
global _STEAM_APPS, _STEAM_APPS_INDEX
with _STEAM_APPS_LOCK:
_STEAM_APPS = steam_apps
_STEAM_APPS_INDEX = build_index(steam_apps)
callback((_STEAM_APPS, _STEAM_APPS_INDEX))
load_steam_apps_async(on_steam_apps)
def enable_steam_cef() -> tuple[bool, str]: def enable_steam_cef() -> tuple[bool, str]:
""" """

View File

@@ -1,9 +1,10 @@
import importlib.util import importlib.util
import os import os
import ast
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.theme_security import check_theme_safety, is_safe_image_file
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
from portprotonqt.localization import get_screenshot_caption
# Icon caching for performance optimization # Icon caching for performance optimization
_icon_cache = {} _icon_cache = {}
@@ -18,57 +19,6 @@ THEMES_DIRS = [
] ]
_loaded_theme = None _loaded_theme = None
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_theme_safety(theme_file: str) -> bool:
"""
Проверяет файл темы на наличие запрещённых модулей и функций.
Возвращает True, если файл безопасен, иначе False.
"""
has_errors = False
try:
with open(theme_file) as f:
content = f.read()
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, ast.Import | ast.ImportFrom):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
has_errors = True
except SyntaxError as e:
logger.error(f"Syntax error in file {theme_file}: {e}")
has_errors = True
except Exception as e:
logger.error(f"Failed to check theme safety for {theme_file}: {e}")
has_errors = True
return not has_errors
def list_themes(): def list_themes():
""" """
@@ -86,20 +36,36 @@ def list_themes():
def load_theme_screenshots(theme_name): def load_theme_screenshots(theme_name):
""" """
Загружает все скриншоты из папки "screenshots", расположенной в папке темы. Загружает все скриншоты из папки "screenshots", расположенной в папке темы.
Возвращает список кортежей (pixmap, filename). Возвращает список кортежей (pixmap, caption), где caption - это перевод названия скриншота.
Если папка отсутствует или пуста, возвращается пустой список. Если папка отсутствует или пуста, возвращается пустой список.
""" """
screenshots = [] screenshots = []
# Find the metainfo file for the theme
metainfo_file = None
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
temp_metainfo_file = os.path.join(theme_folder, "metainfo.ini")
if os.path.exists(temp_metainfo_file):
metainfo_file = temp_metainfo_file
break
for themes_dir in THEMES_DIRS: for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name) theme_folder = os.path.join(themes_dir, theme_name)
screenshots_folder = os.path.join(theme_folder, "images", "screenshots") screenshots_folder = os.path.join(theme_folder, "images", "screenshots")
if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder): if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder):
for file in os.listdir(screenshots_folder): for file in os.listdir(screenshots_folder):
screenshot_path = os.path.join(screenshots_folder, file) screenshot_path = os.path.join(screenshots_folder, file)
if os.path.isfile(screenshot_path): if os.path.isfile(screenshot_path) and is_safe_image_file(screenshot_path):
pixmap = QPixmap(screenshot_path) pixmap = QPixmap(screenshot_path)
if not pixmap.isNull(): if not pixmap.isNull():
screenshots.append((pixmap, file)) # Get the base filename without extension
base_filename = os.path.splitext(file)[0]
# Get translated caption using localization function
caption = get_screenshot_caption(base_filename, metainfo_file)
screenshots.append((pixmap, caption))
return screenshots return screenshots
def load_theme_fonts(theme_name): def load_theme_fonts(theme_name):
@@ -111,34 +77,65 @@ def load_theme_fonts(theme_name):
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping") logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
return return
QFontDatabase.removeAllApplicationFonts() def load_fonts_delayed():
fonts_folder = None global _loaded_theme
if theme_name == "standart": try:
base_dir = os.path.dirname(os.path.abspath(__file__)) # Only remove fonts if this is a theme change (not initial load)
fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts") current_loaded_theme = _loaded_theme # Capture the current value
else: if current_loaded_theme is not None and current_loaded_theme != theme_name:
for themes_dir in THEMES_DIRS: # Run font removal in the GUI thread with delay
theme_folder = os.path.join(themes_dir, theme_name) QFontDatabase.removeAllApplicationFonts()
possible_fonts_folder = os.path.join(theme_folder, "fonts")
if os.path.exists(possible_fonts_folder):
fonts_folder = possible_fonts_folder
break
if not fonts_folder or not os.path.exists(fonts_folder): import time
logger.error(f"Fonts folder not found for theme '{theme_name}'") import os
return start_time = time.time()
timeout = 3 # Reduced timeout to 3 seconds for faster loading
for filename in os.listdir(fonts_folder): fonts_folder = None
if filename.lower().endswith((".ttf", ".otf")): if theme_name == "standart":
font_path = os.path.join(fonts_folder, filename) base_dir = os.path.dirname(os.path.abspath(__file__))
font_id = QFontDatabase.addApplicationFont(font_path) fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Font {filename} successfully loaded: {families}")
else: else:
logger.error(f"Error loading font: {filename}") for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
possible_fonts_folder = os.path.join(theme_folder, "fonts")
if os.path.exists(possible_fonts_folder):
fonts_folder = possible_fonts_folder
break
_loaded_theme = theme_name if not fonts_folder or not os.path.exists(fonts_folder):
logger.error(f"Fonts folder not found for theme '{theme_name}'")
return
font_files = []
for filename in os.listdir(fonts_folder):
if filename.lower().endswith((".ttf", ".otf")):
font_files.append(filename)
# Limit number of fonts loaded to prevent too much blocking
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
for filename in font_files:
if time.time() - start_time > timeout:
logger.warning(f"Font loading timed out for theme '{theme_name}' after loading {len(font_files)} fonts")
break
font_path = os.path.join(fonts_folder, filename)
font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Font {filename} successfully loaded: {families}")
else:
logger.error(f"Error loading font: {filename}")
# Update the global variable in the main thread
_loaded_theme = theme_name
except Exception as e:
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
# Use QTimer to delay font loading until after the UI is rendered
from PySide6.QtCore import QTimer
QTimer.singleShot(100, load_fonts_delayed) # Delay font loading by 100ms
class ThemeWrapper: class ThemeWrapper:
""" """
@@ -257,14 +254,14 @@ class ThemeManager:
# Если передано имя с расширением, проверяем только этот файл # Если передано имя с расширением, проверяем только этот файл
if has_extension: if has_extension:
candidate = os.path.join(icons_folder, str(base_name)) candidate = os.path.join(icons_folder, str(base_name))
if os.path.exists(candidate): if os.path.exists(candidate) and is_safe_image_file(candidate):
icon_path = candidate icon_path = candidate
break break
else: else:
# Проверяем все поддерживаемые расширения # Проверяем все поддерживаемые расширения
for ext in supported_extensions: for ext in supported_extensions:
candidate = os.path.join(icons_folder, str(base_name) + str(ext)) candidate = os.path.join(icons_folder, str(base_name) + str(ext))
if os.path.exists(candidate): if os.path.exists(candidate) and is_safe_image_file(candidate):
icon_path = candidate icon_path = candidate
break break
if icon_path: if icon_path:
@@ -278,12 +275,12 @@ class ThemeManager:
# Аналогично проверяем в стандартной теме # Аналогично проверяем в стандартной теме
if has_extension: if has_extension:
icon_path = os.path.join(standard_icons_folder, base_name) icon_path = os.path.join(standard_icons_folder, base_name)
if not os.path.exists(icon_path): if not os.path.exists(icon_path) or not is_safe_image_file(icon_path):
icon_path = None icon_path = None
else: else:
for ext in supported_extensions: for ext in supported_extensions:
candidate = os.path.join(standard_icons_folder, base_name + ext) candidate = os.path.join(standard_icons_folder, base_name + ext)
if os.path.exists(candidate): if os.path.exists(candidate) and is_safe_image_file(candidate):
icon_path = candidate icon_path = candidate
break break
@@ -326,13 +323,13 @@ class ThemeManager:
if has_extension: if has_extension:
candidate = os.path.join(images_folder, str(base_name)) candidate = os.path.join(images_folder, str(base_name))
if os.path.exists(candidate): if os.path.exists(candidate) and is_safe_image_file(candidate):
image_path = candidate image_path = candidate
break break
else: else:
for ext in supported_extensions: for ext in supported_extensions:
candidate = os.path.join(images_folder, str(base_name) + str(ext)) candidate = os.path.join(images_folder, str(base_name) + str(ext))
if os.path.exists(candidate): if os.path.exists(candidate) and is_safe_image_file(candidate):
image_path = candidate image_path = candidate
break break
if image_path: if image_path:
@@ -345,12 +342,12 @@ class ThemeManager:
if has_extension: if has_extension:
image_path = os.path.join(standard_images_folder, base_name) image_path = os.path.join(standard_images_folder, base_name)
if not os.path.exists(image_path): if not os.path.exists(image_path) or not is_safe_image_file(image_path):
image_path = None image_path = None
else: else:
for ext in supported_extensions: for ext in supported_extensions:
candidate = os.path.join(standard_images_folder, base_name + ext) candidate = os.path.join(standard_images_folder, base_name + ext)
if os.path.exists(candidate): if os.path.exists(candidate) and is_safe_image_file(candidate):
image_path = candidate image_path = candidate
break break

View File

@@ -0,0 +1,444 @@
"""
Theme security module for PortProtonQt.
Provides enhanced security checks for theme files to prevent malicious code execution.
"""
import ast
import os
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
class ThemeSecurityChecker:
"""
Enhanced security checker for theme files.
Identifies and blocks various attack vectors in theme Python files.
"""
# Basic forbidden modules that could allow dangerous operations
FORBIDDEN_MODULES = {
# File system operations
"os", "shutil", "pathlib", "glob", "tempfile", "filecmp", "fileinput",
"linecache", "io", "mmap", "fnmatch", "difflib",
# Process and system operations
"subprocess", "sys", "ctypes", "cffi", "platform", "resource", "signal",
"multiprocessing", "concurrent", "threading", "asyncio", "select",
"selectors", "queue", "sched", "contextvars",
# Network operations
"socket", "urllib", "urllib2", "urllib.request", "urllib.parse",
"urllib.error", "urllib.robotparser", "http", "http.client",
"http.cookies", "http.cookiejar", "ftplib", "telnetlib", "smtplib",
"poplib", "imaplib", "nntplib", "socketserver", "xmlrpc", "xmlrpc.client",
"xmlrpc.server", "ipaddress", "webbrowser", "ssl", "uuid",
# Code execution and dynamic imports
"code", "codeop", "compileall", "py_compile", "runpy", "zipimport",
"pkgutil", "pkg_resources", "importlib", "importlib.util",
"importlib.import_module", "importlib.resources", "importlib.metadata",
"builtins", "exec", "eval", "__import__", "compile", "execfile",
"imp", "importlib.machinery", "importlib.abc", "importlib.load_module",
"importlib.reload", "imp.load_source", "imp.load_compiled", "imp.find_module",
"imp.get_suffixes", "imp.init_builtin", "imp.init_frozen", "imp.is_builtin",
"imp.is_frozen", "imp.lock_held", "imp.lock", "imp.reload", "imp.load_module",
# Data serialization and code execution
"pickle", "marshal", "shelve", "json", "yaml", "configparser", "binascii", "base64",
# Databases and storage
"sqlite3", "dbapi2", "sqlite_web", "dataset", "records", "tinydb",
# Cryptography and security
"hashlib", "hmac", "secrets", "crypt", "cryptography",
# External libraries that could be dangerous
"requests", "aiohttp", "selenium", "paramiko", "fabric", "docker",
"boto", "boto3", "pymongo", "pymysql", "psycopg2", "redis", "pika",
"kafka", "celery", "rq", "playwright", "mechanize", "scrapy",
"beautifulsoup4", "lxml", "html5lib", "pyautogui",
"keyboard", "mouse", "pynput", "psutil", "wmi", "pywin32",
# GUI and UI libraries that could be used for malicious purposes
"tkinter", "PyQt4", "PyQt5", "PyQt6", "PySide", "PySide2", "PySide6",
"kivy", "kivymd", "wx", "wxPython", "pygame", "flask", "django",
"fastapi", "tornado", "bottle", "cherrypy", "falcon", "sanic",
}
# Forbidden functions that could allow dangerous operations
FORBIDDEN_FUNCTIONS = {
# Code execution
"exec", "eval", "compile", "execfile", "__import__",
# Import-related functions that allow dynamic imports
"importlib.import_module", "importlib.util", "importlib.resources",
"importlib.metadata", "builtins.__import__", "builtins.eval",
"builtins.exec", "builtins.compile", "builtins.open",
# File system operations
"open", "file", "os.open", "os.fdopen", "io.open", "tempfile.mktemp",
"tempfile.mkdtemp", "tempfile.NamedTemporaryFile", "tempfile.SpooledTemporaryFile",
# System operations
"os.system", "os.popen", "os.spawnl", "os.spawnle", "os.spawnlp",
"os.spawnlpe", "os.spawnv", "os.spawnve", "os.spawnvp", "os.spawnvpe",
"os.startfile", "os.execv", "os.execve", "os.execl", "os.execle", "os.execlp",
"os.execlpe", "subprocess.run", "subprocess.call",
"subprocess.check_call", "subprocess.check_output", "subprocess.Popen",
# Network operations
"socket.socket", "socket.create_connection", "urllib.request.urlopen",
"urllib.request.Request", "requests.get", "requests.post", "requests.put",
"requests.delete", "requests.patch", "requests.head", "requests.options",
"aiohttp.ClientSession", "http.client.HTTPConnection", "http.client.HTTPSConnection",
# Reflection and introspection that could be dangerous
"getattr", "setattr", "hasattr", "delattr", "globals", "locals", "vars",
"dir", "type", "id", "object", "issubclass", "isinstance", "callable",
"iter", "next", "reversed", "slice", "sorted", "filter", "map", "reduce",
# Input functions
"input", "raw_input",
# Built-in functions that could be dangerous in certain contexts
"breakpoint", "quit", "exit", "copyright", "credits", "license", "help",
# Dynamic attribute access that could be dangerous
"operator.attrgetter", "operator.itemgetter", "operator.methodcaller",
"apply", "buffer", "coerce", "intern", "long", "unichr",
"unicode", "xrange", "cmp", "reload", "basestring",
}
# Forbidden attributes that could be dangerous
FORBIDDEN_ATTRIBUTES = {
# Special methods and attributes that could be used for code execution
"__class__", "__dict__", "__module__", "__subclasses__", "__bases__",
"__mro__", "__call__", "__func__", "__self__", "__code__", "__closure__",
"__globals__", "__name__", "__file__", "__path__", "__package__",
"__loader__", "__spec__", "__builtins__", "__import__", "__new__",
"__init__", "__del__", "__repr__", "__str__", "__bytes__", "__format__",
"__lt__", "__le__", "__eq__", "__ne__", "__gt__", "__ge__", "__hash__",
"__bool__", "__dir__", "__delattr__", "__getattribute__",
"__setattr__", "__delete__", "__set__", "__get__", "__set_name__",
"__prepare__", "__init_subclass__", "__instancecheck__", "__subclasscheck__",
"__subclasshook__", "__class_getitem__", "__annotations__", "__weakref__",
}
def __init__(self):
self.has_errors = False
self.errors = []
def check_theme_safety(self, theme_file: str) -> tuple[bool, list[str]]:
"""
Enhanced security check for theme files.
Returns (is_safe, list_of_errors).
"""
self.has_errors = False
self.errors = []
try:
with open(theme_file, encoding='utf-8') as f:
content = f.read()
# Check for syntax errors first
try:
tree = ast.parse(content)
except SyntaxError as e:
self.errors.append(f"Syntax error in file {theme_file}: {e}")
self.has_errors = True
return not self.has_errors, self.errors
# Walk through the AST and check for dangerous patterns
for node in ast.walk(tree):
self._check_node_safety(node, theme_file)
except Exception as e:
self.errors.append(f"Failed to check theme safety for {theme_file}: {e}")
self.has_errors = True
return not self.has_errors, self.errors
def _check_node_safety(self, node, theme_file: str):
"""Check individual AST nodes for security issues."""
# Check for forbidden imports
if isinstance(node, (ast.Import, ast.ImportFrom)):
for alias in node.names:
module_name = alias.name
# Handle from ... import ... cases
if isinstance(node, ast.ImportFrom) and node.module:
module_name = node.module
# Check if the module is in the forbidden list
if module_name in self.FORBIDDEN_MODULES:
error_msg = f"Forbidden module '{module_name}' found in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Also check submodules (e.g., "os.path" should trigger on "os")
for forbidden_module in self.FORBIDDEN_MODULES:
if module_name.startswith(forbidden_module + "."):
error_msg = f"Forbidden submodule '{module_name}' found in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
break
# Check for forbidden function calls
elif isinstance(node, ast.Call):
# Check for direct function calls (e.g., eval(), exec())
if isinstance(node.func, ast.Name):
if node.func.id in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden function '{node.func.id}' found in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check for method calls (e.g., os.system(), requests.get())
elif isinstance(node.func, ast.Attribute):
# Get the full function path (e.g., "os.system")
full_func_name = self._get_attribute_path(node.func)
if full_func_name in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden function '{full_func_name}' found in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check just the attribute name
elif node.func.attr in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden method '{node.func.attr}' called in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check for import expressions that might be used dynamically
elif isinstance(node, ast.Expr):
# Check if the expression is a call to an import-related function
if isinstance(node.value, ast.Call):
if isinstance(node.value.func, ast.Name):
if node.value.func.id in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden function '{node.value.func.id}' found in expression in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
elif isinstance(node.value.func, ast.Attribute):
full_func_name = self._get_attribute_path(node.value.func)
if full_func_name in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden function '{full_func_name}' found in expression in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check for forbidden attributes
elif isinstance(node, ast.Attribute):
if node.attr in self.FORBIDDEN_ATTRIBUTES:
error_msg = f"Forbidden attribute access '{node.attr}' found in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check for dangerous expressions (like accessing builtins)
elif isinstance(node, ast.Name):
if node.id in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden function '{node.id}' found in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check for potentially dangerous f-strings that might execute code
elif isinstance(node, ast.FormattedValue):
# Check if the format value contains dangerous expressions
if hasattr(node, 'value'):
# Recursively check the value for dangerous patterns
if isinstance(node.value, ast.Call):
func_name = self._get_attribute_path(node.value.func)
if func_name in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden function '{func_name}' found in f-string in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
elif isinstance(node.value, ast.Attribute) and node.value.attr in self.FORBIDDEN_ATTRIBUTES:
error_msg = f"Forbidden attribute access '{node.value.attr}' found in f-string in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
elif isinstance(node.value, ast.Name) and node.value.id in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden function '{node.value.id}' found in f-string in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Recursively check nested expressions in f-strings
elif isinstance(node.value, (ast.BinOp, ast.UnaryOp, ast.BoolOp)):
# Check for complex expressions that might contain dangerous operations
self._check_node_safety(node.value, theme_file)
# Check for nested function calls that might be dangerous
elif isinstance(node.value, ast.Subscript):
# Check if we're accessing something potentially dangerous
if hasattr(node.value, 'value') and isinstance(node.value.value, ast.Call):
func_name = self._get_attribute_path(node.value.value.func)
if func_name in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Forbidden function '{func_name}' found in f-string subscript in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check for string concatenation attacks (e.g., "im" + "port", "exec", etc.)
elif isinstance(node, ast.BinOp):
# Check for string concatenations that might be used to obfuscate dangerous code
if isinstance(node.op, ast.Add): # String concatenation with +
left_val = self._get_constant_value(node.left)
right_val = self._get_constant_value(node.right)
if left_val is not None and right_val is not None:
concatenated = str(left_val) + str(right_val)
# Check if concatenated string forms a dangerous module/function name
if concatenated in self.FORBIDDEN_MODULES or concatenated in self.FORBIDDEN_FUNCTIONS:
error_msg = f"Potential string concatenation attack detected: '{concatenated}' in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Also check if it's a substring of forbidden items
for forbidden_module in self.FORBIDDEN_MODULES:
if concatenated in forbidden_module or forbidden_module in concatenated:
error_msg = f"Potential string concatenation attack detected: '{concatenated}' matches forbidden module '{forbidden_module}' in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
if concatenated in forbidden_func or forbidden_func in concatenated:
error_msg = f"Potential string concatenation attack detected: '{concatenated}' matches forbidden function '{forbidden_func}' in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check for common obfuscation techniques
elif isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in ['eval', 'exec']:
# Check if eval/exec is being called with obfuscated content
if len(node.args) > 0:
first_arg = node.args[0]
arg_value = self._get_constant_value(first_arg)
if arg_value:
# Check if eval/exec argument contains dangerous content
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
if forbidden_func in str(arg_value):
error_msg = f"Potential obfuscated code execution detected: '{forbidden_func}' found in eval/exec argument in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
# Check for character code arrays (another obfuscation method)
elif isinstance(node, ast.List) or isinstance(node, ast.Tuple):
# Check if it's a list of character codes that might be converted to dangerous strings
if all(isinstance(elt, (ast.Num, ast.Constant)) and isinstance(self._get_constant_value(elt), int) for elt in node.elts):
# This might be an array of ASCII codes
try:
char_codes = [self._get_constant_value(elt) for elt in node.elts if self._get_constant_value(elt) is not None]
# Filter to only include actual integers for character codes
int_char_codes = [code for code in char_codes if isinstance(code, int)]
if int_char_codes and all(isinstance(code, int) and 32 <= code <= 126 for code in int_char_codes): # Printable ASCII range
decoded_str = ''.join(chr(code) for code in int_char_codes)
# Check if decoded string contains dangerous content
for forbidden_module in self.FORBIDDEN_MODULES:
if forbidden_module in decoded_str:
error_msg = f"Potential character code obfuscation detected: '{forbidden_module}' found in decoded array in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
if forbidden_func in decoded_str:
error_msg = f"Potential character code obfuscation detected: '{forbidden_func}' found in decoded array in file {theme_file}"
self.errors.append(error_msg)
self.has_errors = True
except (ValueError, TypeError, AttributeError):
# If conversion fails, continue
pass
def _get_attribute_path(self, attr_node):
"""Extract the full attribute path from an AST node (e.g., 'os.path.join')."""
if isinstance(attr_node, ast.Name):
return attr_node.id
elif isinstance(attr_node, ast.Attribute):
parent_path = self._get_attribute_path(attr_node.value)
return f"{parent_path}.{attr_node.attr}"
return ""
def _get_constant_value(self, node):
"""Extract the constant value from an AST node if it's a constant."""
if isinstance(node, ast.Str): # Python < 3.8
return node.s
elif isinstance(node, ast.Constant): # Python 3.8+
return node.value
elif isinstance(node, ast.Num): # Python < 3.8 for numbers
return node.n
elif isinstance(node, ast.Bytes): # For bytes
return node.s
return None
def check_theme_safety(theme_file: str) -> bool:
"""
Convenience function to check theme safety.
Returns True if the theme is safe, False otherwise.
"""
checker = ThemeSecurityChecker()
is_safe, errors = checker.check_theme_safety(theme_file)
for error in errors:
logger.error(error)
return is_safe
def is_safe_image_file(file_path: str) -> bool:
"""
Check if an image file is safe to load by verifying its extension and basic file properties.
This helps prevent loading malicious files that might be disguised as images.
"""
# Check file extension first
safe_extensions = {'.png', '.jpg', '.jpeg', '.svg', '.bmp', '.gif', '.webp', '.ico'}
_, ext = os.path.splitext(file_path.lower())
if ext not in safe_extensions:
logger.warning(f"Unsafe image file extension for {file_path}: {ext}")
return False
# Check file size (prevent loading extremely large files)
try:
file_size = os.path.getsize(file_path)
# Limit to 50MB to prevent memory exhaustion attacks
if file_size > 50 * 1024 * 1024: # 50MB
logger.warning(f"Image file too large ({file_size} bytes): {file_path}")
return False
except OSError:
logger.error(f"Could not get file size for {file_path}")
return False
# For security, we can also check the file's magic bytes (first few bytes)
# to ensure it's actually an image file and not a disguised executable
try:
with open(file_path, 'rb') as f:
header = f.read(32) # Read first 32 bytes
# Check for common image file signatures (magic bytes)
if ext == '.png':
# PNG signature: 89 50 4E 47 0D 0A 1A 0A
if not header.startswith(b'\x89PNG\r\n\x1a\n'):
logger.warning(f"File {file_path} does not have PNG signature")
return False
elif ext in ['.jpg', '.jpeg']:
# JPEG signature: FF D8 FF
if not header.startswith(b'\xff\xd8\xff'):
logger.warning(f"File {file_path} does not have JPEG signature")
return False
elif ext == '.gif':
# GIF signature: 47 49 46 38 (GIF8)
if not header.startswith(b'GIF8'):
logger.warning(f"File {file_path} does not have GIF signature")
return False
elif ext == '.bmp':
# BMP signature: 42 4D (BM)
if not header.startswith(b'BM'):
logger.warning(f"File {file_path} does not have BMP signature")
return False
# SVG is text-based, so we just check if it contains XML-like structure
elif ext == '.svg':
try:
header_str = header.decode('utf-8', errors='ignore')
# Basic check for SVG XML structure
if not ('<svg' in header_str or '<?xml' in header_str):
logger.warning(f"File {file_path} does not appear to be a valid SVG")
return False
except UnicodeDecodeError:
logger.warning(f"SVG file {file_path} contains invalid UTF-8")
return False
except Exception as e:
logger.error(f"Error checking image file signature for {file_path}: {e}")
return False
return True

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="m24 9.185c-2.0302 0-3.7027 1.6725-3.7027 3.7027v7.4096h-7.4096c-2.0302 0-3.7027 1.6725-3.7027 3.7027s1.6725 3.7027 3.7027 3.7027h7.4096v7.4096c0 2.0302 1.6725 3.7027 3.7027 3.7027s3.7027-1.6725 3.7027-3.7027v-7.4096h7.4096c2.0302 0 3.7027-1.6725 3.7027-3.7027s-1.6725-3.7027-3.7027-3.7027h-7.4096v-7.4096c0-2.0302-1.6725-3.7027-3.7027-3.7027zm0 2.9613c0.41396 0 0.74137 0.32742 0.74137 0.74137v10.371h10.371c0.41396 0 0.74137 0.32742 0.74137 0.74137s-0.32742 0.74137-0.74137 0.74137h-10.371v10.371c0 0.41396-0.32742 0.74137-0.74137 0.74137s-0.74137-0.32742-0.74137-0.74137v-10.371h-10.371c-0.41396 0-0.74137-0.32742-0.74137-0.74137s0.32742-0.74137 0.74137-0.74137h10.371v-10.371c0-0.41396 0.32742-0.74137 0.74137-0.74137z" fill="#3f424d" stop-color="#000000" stroke-width="1.0662"/><path d="m24 11.494c1.1462 0 2.0844 0.93819 2.0844 2.0844v8.3375h8.3375c1.1462 0 2.0844 0.93819 2.0844 2.0844s-0.93819 2.0844-2.0844 2.0844h-8.3375v8.3375c0 1.1462-0.93819 2.0844-2.0844 2.0844s-2.0844-0.93819-2.0844-2.0844v-8.3375h-8.3375c-1.1462 0-2.0844-0.93819-2.0844-2.0844s0.93819-2.0844 2.0844-2.0844h8.3375v-8.3375c0-1.1462 0.93819-2.0844 2.0844-2.0844z" fill="#fff" stop-color="#000000" stroke-width="0"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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="m15.222 18.538h8.5002v1.8684h-5.8744v2.6763h5.3189v1.8684h-5.3189v4.511h-2.6258zm13.6 11.092q-1.1614 0-3.4842-0.18515v-2.0367q2.0872 0.38714 3.1476 0.38714 0.92576 0 1.3466-0.20198 0.4208-0.21882 0.4208-0.67328v-1.6327q0-0.45446-0.30298-0.63962-0.30298-0.20198-0.99309-0.20198h-3.3664v-5.908h6.9011v1.8852h-4.4436v2.2218h1.7169q0.97626 0 1.902 0.3703 0.50496 0.21882 0.80794 0.67328t0.30298 1.0604v2.2892q0 0.65645-0.25248 1.1782-0.25248 0.50496-0.63962 0.77427-0.33664 0.25248-0.90893 0.40397-0.55546 0.15149-1.0772 0.20198-0.60595 0.03366-1.0772 0.03366z" fill="#3f424d" stroke-width="0" aria-label="F5"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

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="m20.375 10.836v21.898l4.4513 1.4106v-18.447c0-1.1417 0.63761-1.8498 1.4528-1.5795 1.2195 0.33732 1.2205 1.7172 1.2205 2.2383v7.34c0.69194 0.30705 1.3479 0.46456 1.9511 0.46456 0.90817 0 1.6684-0.36594 2.2003-1.06 0.57517-0.75032 0.87844-1.8745 0.87844-3.2519 0-3.9851-1.3499-5.7267-5.562-7.1711-1.3341-0.45192-4.3372-1.3851-6.5925-1.8413zm-1.6344 13.688-6.8797 2.441c-0.02379 0.0087-1.7358 0.57835-2.724 1.3092-0.35029 0.25948-0.50605 0.57737-0.44766 0.89955 0.11028 0.60112 0.89455 1.164 2.099 1.5035 2.5191 0.83248 5.1622 1.0445 7.7159 0.62504l0.23228-0.03801v-1.9131l-2.0863 0.75596c-0.96438 0.34597-2.458 0.42428-3.2646 0.16049-0.5795-0.19028-0.70734-0.49524-0.70951-0.71795-0.0043-0.26596 0.17441-0.64535 1.022-0.95023l5.0426-1.8033zm13.269 1.1529c-0.59973-0.0075-1.1869 0.016-1.7442 0.07603-2.1515 0.23785-3.7118 0.78326-3.7291 0.78975l-0.09714 0.0338v2.3692l5.0299-1.7695c0.96439-0.34597 2.4538-0.42428 3.2604-0.16048 0.5795 0.19028 0.70734 0.49523 0.70951 0.71795 0.0021 0.26596-0.17234 0.64329-1.0178 0.94601l-7.9819 2.8465v2.2594l10.706-3.8432c0.01297-0.0043 1.421-0.53074 1.968-1.2205 0.19245-0.24218 0.25306-0.4904 0.17737-0.73907-0.09298-0.30488-0.49051-0.89192-2.0863-1.3979-1.4887-0.56436-3.3954-0.88519-5.1946-0.908z" fill="#3f424d" stroke-width="2.1623"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 225 KiB

View File

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 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="m22.671 37.279c-2.0532-0.1967-4.1317-0.93396-5.9172-2.099-1.4962-0.97578-1.8338-1.3776-1.8338-2.1783 0-1.6086 1.7688-4.4266 4.7953-7.6383 1.7183-1.8246 4.1122-3.962 4.3709-3.9044 0.50369 0.11245 4.5276 4.0376 6.0343 5.8857 2.3821 2.9211 3.4769 5.3138 2.9205 6.3804-0.42284 0.81067-3.0472 2.3955-4.9749 3.0042-1.5891 0.50183-3.6761 0.71433-5.395 0.54984zm-9.772-5.9498c-1.2434-1.9076-1.8716-3.7854-2.1746-6.5015-0.10037-0.89679-0.06505-1.4095 0.22706-3.2507 0.36336-2.2923 1.6697-4.947 3.2402-6.5795 0.66849-0.69389 0.72796-0.71247 1.5433-0.43678 0.98817 0.33455 2.0445 1.0644 3.6832 2.5463l0.95719 0.8655-0.52351 0.64123c-2.4249 2.9769-4.9842 7.1991-5.9476 9.8105-0.52351 1.4188-0.73416 2.8437-0.50802 3.4369 0.15179 0.40084 0.01239 0.25153-0.49873-0.53095zm21.824 0.32433c0.12298-0.59972-0.03253-1.7006-0.39651-2.8115-0.78837-2.4054-3.4242-6.88-5.8445-9.9226l-0.76204-0.95781 0.82461-0.75677c1.0761-0.98817 1.8233-1.5798 2.63-2.0826 0.63596-0.39651 1.5451-0.74748 1.9361-0.74748 0.24069 0 1.0892 0.88285 1.7738 1.8431 1.0607 1.4869 1.8407 3.2929 2.2359 5.1701 0.25556 1.2143 0.27694 3.8102 0.0412 5.0214-0.19516 0.99375-0.60405 2.2818-1.0006 3.1556-0.30048 0.65455-1.0408 1.9262-1.3661 2.34-0.16728 0.21281-0.16728 0.2125-0.07435-0.24658zm-11.832-17.733c-1.117-0.56688-2.84-1.1756-3.7916-1.3398-0.33331-0.05731-0.90236-0.08983-1.2639-0.07125-0.78558 0.03965-0.75058-0.0012 0.50895-0.59631 1.047-0.4947 1.9206-0.78558 3.107-1.0346 1.3336-0.28034 3.8412-0.28344 5.1537-0.0068 1.4172 0.29893 3.0866 0.92002 4.027 1.4993l0.28003 0.17161-0.64123-0.03222c-1.275-0.06443-3.133 0.45072-5.128 1.4209-0.60158 0.29304-1.1245 0.52661-1.1629 0.52042-0.0381-0.0074-0.52847-0.24627-1.0904-0.53126z" fill="#3f424d" stroke-width=".30977"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,5 +1,23 @@
[Metainfo] [Metainfo]
author = Dervart author = Dervart
author_link = author_link =
description = Стандартная тема PortProtonQt (тёмный вариант) name_en = Clean Dark
name = Clean Dark name_ru = Чистая темная
description_en = Standard PortProtonQt theme (dark variant)
description_ru = Стандартная тема PortProtonQt (тёмный вариант)
[Screenshots]
auto_installs_en = Auto-installs
auto_installs_ru = Автоустановки
library_en = Library
library_ru = Библиотека
game_card_en = Game Card
game_card_ru = Карточка
context_menu_en = Context Menu
context_menu_ru = Контекстное меню
portproton_settings_en = PortProton Settings
portproton_settings_ru = Настройки PortProton
wine_settings_en = Wine Settings
wine_settings_ru = Настройки Wine
themes_en = Themes
themes_ru = Темы

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
from .constants import *
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
MAIN_WINDOW_STYLE = f"""
QWidget {{
background: {color_b};
}}
QLabel {{
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QPushButton {{
background: {color_c};
border: {border_c} rgba(255, 255, 255, 0.01);
border-radius: {border_radius_a};
color: {color_f};
font-size: {font_size_a};
font-family: '{font_family}';
padding: 8px 16px;
}}
QPushButton:hover {{
background: {color_a};
border: {border_c} {color_a};
}}
QPushButton:pressed {{
background: {color_b};
}}
QPushButton:focus {{
border: {border_c} {color_a};
background-color: {color_a};
}}
"""
# СТИЛЬ ПРОГРЕСС-БАРА
PROGRESS_BAR_STYLE = f"""
QProgressBar {{
color: {color_f};
background-color: {color_c};
text-align: center;
}}
QProgressBar::chunk {{
background-color: {color_a};
}}
"""
# СТИЛЬ СТАТУС-БАРА
STATUS_BAR_STYLE = f"""
QStatusBar {{
color: {color_f};
}}
"""
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
MAIN_WINDOW_HEADER_STYLE = f"""
QFrame {{
background: {color_h};
border: 10px solid {color_g};
border-bottom: 0px solid {color_g};
border-top-left-radius: 30px;
border-top-right-radius: 30px;
border: none;
}}
"""
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
NAV_WIDGET_STYLE = f"""
QWidget {{
background: {color_h};
border: {border_a};
}}
"""
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
NAV_BUTTON_STYLE = f"""
NavLabel {{
background: rgba(0,0,0,0);
padding: 12px 3px;
margin: 10px 0 10px 10px;
color: #7f7f7f;
font-family: '{font_family}';
font-size: {font_size_a};
text-transform: uppercase;
border: {color_a};
border-radius: {border_radius_b};
}}
NavLabel[checked = true] {{
background: rgba(0,0,0,0);
color: {color_a};
font-weight: normal;
text-decoration: underline;
border-radius: {border_radius_b};
}}
NavLabel:hover {{
background: none;
color: {color_a};
}}
"""
# СТИЛЬ ПОЛЯ ПОИСКА
SEARCH_EDIT_STYLE = f"""
QLineEdit {{
background-color: rgba(30, 30, 30, 0.50);
border: {border_b} rgba(255, 255, 255, 0.5);
border-radius: {border_radius_a};
padding: 7px 14px;
font-family: '{font_family}';
font-size: {font_size_a};
color: {color_f};
}}
QLineEdit:focus {{
border: {border_b} {color_a};
}}
"""
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
SCROLL_AREA_STYLE = f"""
QWidget {{
background: {color_h};
}}
QScrollBar:vertical {{
width: 10px;
border: {border_a};
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}}
QScrollBar::handle:vertical {{
background: #bebebe;
border: {border_a};
border-radius: 5px;
}}
QScrollBar::add-line:vertical {{
border: {border_a};
background: none;
}}
QScrollBar::sub-line:vertical {{
border: {border_a};
background: none;
}}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
border: {border_a};
width: 3px;
height: 3px;
background: none;
}}
QScrollBar:horizontal {{
height: 10px;
border: {border_a};
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}}
QScrollBar::handle:horizontal {{
background: #bebebe;
border: {border_a};
border-radius: 5px;
}}
QScrollBar::add-line:horizontal {{
border: {border_a};
background: none;
}}
QScrollBar::sub-line:horizontal {{
border: {border_a};
background: none;
}}
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {{
border: {border_a};
width: 3px;
height: 3px;
background: none;
}}
"""
# SLIDER_SIZE_STYLE
SLIDER_SIZE_STYLE= f"""
QWidget {{
background: {color_h};
height: 25px;
}}
QSlider::groove:horizontal {{
border: {border_a};
border-radius: 3px;
height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
background: rgba(20, 20, 20, 0.30);
margin: 6px 0;
}}
QSlider::handle:horizontal {{
background: #bebebe;
border: {border_a};
width: 18px;
height: 18px;
margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
border-radius: 9px;
}}
"""
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
LIST_WIDGET_STYLE = """
QWidget {
background: none;
border: {border_a} {color_g};
border-radius: 25px;
}
"""
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
INSTALLED_TAB_TITLE_STYLE = f"""
QLabel {{
font-family: '{font_family}';
font-size: {font_size_b};
color: {color_f};
}}
"""
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
ACTION_BUTTON_STYLE = f"""
QPushButton {{
background: {color_c};
border: {border_c} {color_g};
border-radius: {border_radius_a};
color: {color_f};
font-size: {font_size_a};
font-family: '{font_family}';
padding: 8px 16px;
}}
QPushButton:hover {{
background: {color_a};
border: {border_c} {color_a};
}}
QPushButton:pressed {{
background: {color_b};
}}
QPushButton:focus {{
border: {border_c} {color_a};
background-color: {color_a};
}}
"""
# СТИЛЬ ОВЕРЛЕЯ
OVERLAY_WINDOW_STYLE = f"background: {color_b};"
OVERLAY_BUTTON_STYLE = f"""
QPushButton {{
background: {color_c};
border: {border_c} {color_g};
border-radius: {border_radius_a};
color: {color_f};
font-size: {font_size_a};
font-family: '{font_family}';
padding: 8px 16px;
}}
QPushButton:hover {{
background: {color_a};
border: {border_c} {color_a};
}}
QPushButton:pressed {{
background: {color_b};
}}
QPushButton:focus {{
border: {border_c} {color_a};
background-color: {color_a};
}}
"""
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
TAB_TITLE_STYLE = f"font-family: '{font_family}'; font-size: {font_size_b}; color: {color_f}; background-color: none;"
CONTENT_STYLE = f"""
QLabel {{
font-family: '{font_family}';
font-size: {font_size_a};
color: {color_f};
background-color: none;
border-bottom: {border_b} rgba(255, 255, 255, 0.2);
padding-bottom: 15px;
}}
"""
PREVIEW_WIDGET_STYLE = f"""
QWidget {{
margin-top: 3px;
background-color: {color_c};
border-radius: {border_radius_a};
}}
"""
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
# LIBRARY_WIDGET_STYLE
LIBRARY_WIDGET_STYLE= """
QWidget {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(112,20,132,1),
stop:1 rgba(50,134,182,1));
border-radius: 0px;
}
"""
# CONTAINER_STYLE
CONTAINER_STYLE= """
QWidget {
background-color: none;
}
"""
# OTHER_PAGES_WIDGET_STYLE
OTHER_PAGES_WIDGET_STYLE= f"""
QWidget {{
background: {color_d};
border-radius: 0px;
}}
"""
# CAROUSEL_WIDGET_STYLE
CAROUSEL_WIDGET_STYLE= f"""
QWidget {{
background: {color_c};
border-radius: 0px;
}}
"""
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
# PARAMS_TITLE_STYLE
PARAMS_TITLE_STYLE = f"color: {color_f}; font-family: '{font_family}'; font-size: {font_size_a}; padding: 10px; background: {color_h};"
PROXY_INPUT_STYLE = f"""
QLineEdit {{
background: {color_b};
border: {border_c} rgba(255, 255, 255, 0.01);
border-radius: {border_radius_a};
height: 34px;
padding-left: 12px;
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QLineEdit:hover {{
background: {color_c};
border: {border_c} {color_a};
}}
QLineEdit:focus {{
border: {border_c} {color_a};
background-color: {color_e};
}}
"""
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
MESSAGE_BOX_STYLE = f"""
QMessageBox {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(40, 40, 40, 0.95),
stop:1 rgba(25, 25, 25, 0.95));
border: {border_b} rgba(255, 255, 255, 0.15);
border-radius: 12px;
}}
QMessageBox QLabel {{
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QMessageBox QPushButton {{
background: rgba(30, 30, 30, 0.6);
border: {border_b} rgba(255, 255, 255, 0.2);
border-radius: {border_radius_a};
color: {color_f};
font-family: '{font_family}';
padding: 8px 20px;
min-width: 80px;
}}
QMessageBox QPushButton:hover {{
background: #09bec8;
border-color: rgba(255, 255, 255, 0.3);
}}
QMessageBox QPushButton:focus {{
border: {border_c} {color_a};
background: {color_e};
}}
"""
# Favorite Star
FAVORITE_LABEL_STYLE = f"color: gold; font-size: 32px; background: {color_h};"

View File

@@ -0,0 +1,176 @@
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
theme_manager = ThemeManager()
current_theme_name = read_theme_from_config()
# КОНСТАНТЫ
favoriteLabelSize = 48, 48
# VARS
font_family = "Play"
font_size_a = "16px"
font_size_b = "24px"
border_a = "0px solid"
border_b = "1px solid"
border_c = "2px solid"
border_radius_a = "10px"
border_radius_b = "15px"
color_a = "#409EFF"
color_b = "#282a33"
color_c = "#3f424d"
color_d = "#32343d"
color_e = "#404554"
color_f = "#ffffff"
color_g = "rgba(0, 0, 0, 0)"
color_h = "transparent"
color_i = "rgba(40, 42, 51, 0.9)"
GAME_CARD_ANIMATION = {
# Тип анимации при входе и выходе на детальную страницу
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Определяет, как детальная страница появляется и исчезает
"detail_page_animation_type": "fade",
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
# Влияет на толщину рамки вокруг карточки, когда она не выделена
# Значение в пикселях
"default_border_width": 2,
# Ширина обводки при наведении курсора
# Увеличивает толщину рамки, когда курсор находится над карточкой
# Значение в пикселях
"hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры)
# Увеличивает толщину рамки, когда карточка в фокусе
# Значение в пикселях
"focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
# Значение в пикселях
"pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации
# Определяет максимальную толщину рамки при пульсации
# Значение в пикселях
"pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
# Влияет на скорость перехода от одной ширины обводки к другой
# Значение в миллисекундах
"thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации
# Определяет, как быстро рамка "пульсирует" между min и max значениями
# Значение в миллисекундах
"pulse_anim_duration": 800,
# Длительность анимации вращения градиента
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
# Значение в миллисекундах
"gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах)
# Определяет начальную точку вращения градиента при старте анимации
"gradient_start_angle": 360,
# Конечный угол градиента (в градусах)
# Определяет конечную точку вращения градиента
# Значение 0 означает полный поворот на 360 градусов
"gradient_end_angle": 0,
# Тип анимации для карточки при наведении или фокусе
# Возможные значения: "gradient", "scale"
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
"card_animation_type": "gradient",
# Масштаб карточки в состоянии покоя
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
# Значение в долях (например, 1.0 для нормального размера)
"default_scale": 1.0,
# Масштаб карточки при наведении курсора
# Увеличивает размер карточки при наведении
# Значение в долях (например, 1.1 = 110% от исходного размера)
"hover_scale": 1.1,
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
# Увеличивает размер карточки при фокусе
# Значение в долях (например, 1.05 = 105% от исходного размера)
"focus_scale": 1.05,
# Длительность анимации масштабирования
# Влияет на скорость изменения размера карточки при наведении или фокусе
# Значение в миллисекундах
"scale_anim_duration": 200,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходной ширине обводки
"thickness_easing_curve_out": "InBack",
# Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
# Возможные значения: строки, соответствующие QEasingCurve.Type
"scale_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходному масштабу
"scale_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
"gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
],
# Длительность анимации fade при входе на детальную страницу
# Влияет на скорость появления страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration": 350,
# Длительность анимации slide при входе на детальную страницу
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration": 500,
# Длительность анимации bounce при входе на детальную страницу
# Влияет на скорость "прыжка" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration": 400,
# Длительность анимации fade при выходе из детальной страницы
# Влияет на скорость исчезновения страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration_exit": 350,
# Длительность анимации slide при выходе из детальной страницы
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration_exit": 500,
# Длительность анимации bounce при выходе из детальной страницы
# Влияет на скорость "сжатия" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration_exit": 400,
# Тип кривой сглаживания для анимации при входе на детальную страницу
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve": "OutCubic",
# Тип кривой сглаживания для анимации при выходе из детальной страницы
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic"
}

View File

@@ -0,0 +1,115 @@
from .constants import *
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
DETAIL_PAGE_NO_COVER_STYLE = f"background: rgba(20,20,20,0.95); border-radius: {border_radius_b};"
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
ADDGAME_BACK_BUTTON_STYLE = f"""
QPushButton {{
background: rgba(20, 20, 20, 0.40);
border: {border_b} rgba(255, 255, 255, 0.5);
border-radius: {border_radius_a};
color: {color_f};
font-size: {font_size_a};
font-family: '{font_family}';
padding: 8px 16px;
}}
QPushButton:hover {{
background: {color_a};
}}
QPushButton:pressed {{
background: {color_a};
}}
"""
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
DETAIL_CONTENT_FRAME_STYLE = f"""
QFrame {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(20, 20, 20, 0.40),
stop:1 rgba(20, 20, 20, 0.35));
border: {border_a} {color_g};
border-radius: {border_radius_b};
}}
"""
# ФРЕЙМ ПОД ОБЛОЖКОЙ
COVER_FRAME_STYLE = f"""
QFrame {{
background: rgba(30, 30, 30, 0.80);
border-radius: {border_radius_b};
border: {border_a} {color_g};
}}
"""
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
COVER_LABEL_STYLE = f"border-radius: {border_radius_b};"
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
DETAILS_WIDGET_STYLE = f"background: rgba(20,20,20,0.40); border-radius: {border_radius_b}; padding: 10px;"
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
DETAIL_PAGE_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 32px; color: #007AFF;"
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
# ТЕКСТ ОПИСАНИЯ
DETAIL_PAGE_DESC_STYLE = f"font-family: '{font_family}'; font-size: {font_size_a}; color: {color_f}; line-height: 1.5;"
# СТИЛЬ КНОПКИ "ИГРАТЬ"
PLAY_BUTTON_STYLE = f"""
QPushButton {{
background: rgba(20, 20, 20, 0.40);
border: {border_b} rgba(255, 255, 255, 0.5);
border-radius: {border_radius_a};
font-size: 18px;
color: {color_f};
font-weight: bold;
font-family: '{font_family}';
padding: 8px 16px;
min-width: 120px;
min-height: 40px;
}}
QPushButton:hover {{
background: {color_a};
}}
QPushButton:pressed {{
background: {color_a};
}}
QPushButton:focus {{
background: {color_a};
}}
"""
ADDGAME_INPUT_STYLE = f"""
QLineEdit {{
background: {color_c};
border: {border_c} {color_g};
border-radius: {border_radius_a};
height: 34px;
padding-left: 12px;
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QLineEdit:hover {{
background: {color_c};
border: {border_c} {color_a};
}}
QLineEdit:focus {{
border: {border_c} {color_a};
background-color: {color_e};
}}
"""
# ФУНКЦИЯ ДЛЯ ДИНАМИЧЕСКОГО ГРАДИЕНТА (ДЕТАЛИ ИГР)
# Функции из этой темы срабатывает всегда вне зависимости от выбранной темы, функции из других тем работают только в этих темах
def detail_page_style(stops):
return f"""
QWidget {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
{stops});
border-radius: {border_radius_b};
}}
"""

View File

@@ -0,0 +1,87 @@
from .constants import *
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
GAME_CARD_WINDOW_STYLE = f"""
QFrame {{
border-radius: 20px;
background: rgba(20, 20, 20, 0.40);
border: {border_a} {color_g};
}}
"""
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
GAME_CARD_NAME_LABEL_STYLE = f"""
QLabel {{
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
font-weight: bold;
background-color: {color_g};
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
padding: 14px, 7px, 3px, 7px;
qproperty-wordWrap: true;
}}
"""
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
def get_protondb_badge_style(tier):
tier = tier.lower()
tier_colors = {
"platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
"gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
"silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
"bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
"borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
"pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
}
colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
return f"""
qproperty-alignment: AlignCenter;
background-color: {colors["background"]};
color: {colors["color"]};
border-radius: 5px;
font-family: '{font_family}';
font-weight: bold;
"""
# СТИЛИ БЕЙДЖА WEANTICHEATYET
def get_anticheat_badge_style(status):
status = status.lower()
status_colors = {
"supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
"running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
"planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
"broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
"denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
}
colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
return f"""
qproperty-alignment: AlignCenter;
background-color: {colors["background"]};
color: {colors["color"]};
font-size: {font_size_a};
border-radius: 5px;
font-weight: bold;
"""
# СТИЛИ БЕЙДЖА STEAM
STEAM_BADGE_STYLE= f"""
qproperty-alignment: AlignCenter;
background: rgba(0, 0, 0, 0.5);
color: white;
border-radius: 5px;
font-family: '{font_family}';
font-weight: bold;
"""
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
LAST_LAUNCH_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
LAST_LAUNCH_VALUE_STYLE = f"font-family: '{font_family}'; font-size: 13px; color: {color_f}; font-weight: 600; letter-spacing: 0.75px;"
PLAY_TIME_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
PLAY_TIME_VALUE_STYLE = f"font-family: '{font_family}'; font-size: 13px; color: {color_f}; font-weight: 600; letter-spacing: 0.75px;"
GAMEPAD_SUPPORT_VALUE_STYLE = f"""
font-family: '{font_family}'; font-size: {font_size_a}; color: #00ff00;
font-weight: bold; background: {color_g};
border-radius: 5px; padding: 4px 8px;
"""

View File

@@ -0,0 +1,111 @@
from .constants import *
SETTINGS_COMBO_STYLE = f"""
QComboBox {{
background: {color_c};
border: {border_c} {color_g};
border-radius: {border_radius_a};
height: 34px;
padding-left: 12px;
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
min-width: 120px;
combobox-popup: 0;
}}
QComboBox:on {{
background: {color_b};
border: {border_c} {color_a};
border-bottom-style: none;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}}
QComboBox:hover {{
border: {border_c} {color_a};
background: {color_a};
}}
/* Состояние фокуса */
QComboBox:focus {{
border: {border_c} {color_a};
background-color: {color_a};
}}
QComboBox::drop-down {{
subcontrol-origin: padding;
subcontrol-position: center right;
border-left: {border_b} rgba(255, 255, 255, 0.05);
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow {{
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow:on {{
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
/* Список при открытом комбобоксе */
QComboBox QAbstractItemView {{
outline: none;
border: {border_c} {color_a};
border-top-style: none;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}}
QListView {{
background: {color_c};
}}
QListView::item {{
padding: 7px 7px 7px 12px;
margin: 3px;
border-radius: {border_radius_a};
color: {color_f};
}}
QListView::item:hover {{
background: {color_b};
}}
QListView::item:selected {{
background: {color_b};
}}
/* Выделение в списке при фокусе на элементе */
QListView::item:focus {{
background: {color_a};
color: {color_f};
}}
"""
SETTINGS_CHECKBOX_STYLE = f"""
QCheckBox {{
height: 34px;
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QCheckBox::indicator {{
width: 24px;
height: 24px;
border: {border_c} {color_g};
border-radius: {border_radius_a};
background: {color_b};
}}
QCheckBox::indicator:hover {{
background: {color_c};
border: {border_c} {color_a};
}}
QCheckBox::indicator:focus {{
border: {border_c} {color_a};
}}
QCheckBox::indicator:checked {{
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
border: {border_c} {color_a};
}}
"""

View File

@@ -0,0 +1,84 @@
from .constants import *
CONTEXT_MENU_STYLE = f"""
QMenu {{
background: {color_b};
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
padding: 5px;
min-width: 150px;
}}
QMenu::icon {{
margin-left: 15px;
}}
QMenu::item {{
padding: 10px 20px 10px 10px;
background: {color_h};
border-radius: {border_radius_a};
color: {color_f};
}}
QMenu::item:selected {{
background: {color_a};
color: {color_f};
}}
QMenu::item:disabled {{
color: #7f7f7f;
}}
QMenu::item:hover {{
background: {color_a};
color: {color_f};
}}
QMenu::item:focus {{
background: {color_a};
color: {color_f};
border: {border_b} rgba(255, 255, 255, 0.3);
border-radius: {border_radius_a};
}}
QMenu::separator {{
height: 1px;
background-color: #7f7f7f;
margin: 3px 6px;
}}
"""
VIRTUAL_KEYBOARD_STYLE = f"""
QWidget {{
background: {color_i};
}}
QPushButton {{
font-size: 14px;
border: {border_a} {color_h};
border-radius: {border_radius_a};
min-width: 30px;
min-height: 30px;
padding: 5px;
background-color: {color_c};
color: {color_f};
}}
QPushButton:hover {{
background-color: {color_a};
border: {border_b} {color_a};
}}
QPushButton:focus {{
border: {border_b} {color_a};
background-color: {color_a};
}}
QPushButton:pressed {{
background-color: {color_c};
border: {border_a} {color_h};
}}
QPushButton[checked="true"] {{
background-color: {color_a};
color: {color_f};
border: {border_a} {color_h};
}}
QPushButton[checked="true"]:focus {{
border: {border_b} {color_f};
}}
"""
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
CAPTION_LABEL_STYLE=f"color: white; font-size: {font_size_a};"

View File

@@ -0,0 +1,317 @@
from .constants import *
WINETRICKS_TAB_STYLE = f"""
QTabWidget::pane {{
border-top: 1px solid {color_c};
background: {color_h};
}}
QTabBar::tab {{
background: {color_c};
color: {color_f};
padding: 8px 16px;
border-top-left-radius: {border_radius_a};
border-top-right-radius: {border_radius_a};
margin-right: 2px;
}}
QTabBar::tab:selected {{
background: {color_a};
color: {color_f};
}}
QTabBar::tab:hover {{
background: {color_a};
}}
"""
WINETRICKS_TABBLE_STYLE = f"""
QComboBox {{
background: {color_c};
border: {border_c} {color_g};
border-radius: {border_radius_a};
padding-left: 12px;
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
min-width: 120px;
combobox-popup: 0;
}}
QComboBox:on {{
background: {color_b};
border: {border_c} {color_a};
border-bottom-style: none;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}}
QComboBox:hover {{
border: {border_c} {color_a};
background: {color_a};
}}
/* Состояние фокуса */
QComboBox:focus {{
border: {border_c} {color_a};
background-color: {color_a};
}}
QComboBox:disabled {{
background: #2a2c35;
border: {border_c} #2a2c35;
color: #777a84;
}}
QComboBox::drop-down {{
subcontrol-origin: padding;
subcontrol-position: center right;
border-left: {border_b} rgba(255, 255, 255, 0.05);
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow {{
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow:on {{
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
/* Список при открытом комбобоксе */
QComboBox QAbstractItemView {{
outline: none;
background: {color_c};
border: {border_c} {color_a};
border-top-style: none;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}}
QListView {{
background: {color_c};
}}
QListView::item {{
padding: 7px 7px 7px 12px;
margin: 3px;
border-radius: {border_radius_a};
color: {color_f};
}}
QListView::item:hover {{
background: {color_b};
}}
QListView::item:selected {{
background: {color_b};
}}
/* Выделение в списке при фокусе на элементе */
QListView::item:focus {{
background: {color_a};
color: {color_f};
}}
QLineEdit {{
background: {color_c};
border: {border_c} rgba(255, 255, 255, 0.01);
border-radius: {border_radius_a};
height: 34px;
padding-left: 12px;
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QLineEdit:hover {{
background: {color_c};
border: {border_c} {color_a};
}}
QLineEdit:focus {{
border: {border_c} {color_a};
background-color: {color_e};
}}
QTableWidget {{
background: {color_h};
color: {color_f};
gridline-color: {color_h};
alternate-background-color: {color_d};
border: {border_a};
border-radius: {border_radius_a};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QHeaderView::section {{
background: {color_d};
color: {color_f};
padding: 5px;
border: {border_a};
font-weight: bold;
}}
QTableWidget::item {{
padding: 8px;
border-bottom: {border_a } {color_c};
height: 36px;
}}
QTableWidget::item:selected,
QTableWidget::item:focus,
QTableWidget::item:selected:focus {{
background: {color_a};
color: {color_f};
selection-background-color: {color_a};
}}
QTableWidget::item:hover {{
background: {color_h};
}}
QTableWidget::indicator {{
width: 24px;
height: 24px;
border: {border_c} {color_h};
border-radius: {border_radius_a};
background: {color_b};
}}
QTableWidget::indicator:unchecked {{
background: rgba(255, 255, 255, 0.1);
image: none;
}}
QTableWidget::indicator:checked {{
background: {color_b};
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
border: {border_c} {color_a};
}}
QTableWidget::indicator:hover {{
background: rgba(255, 255, 255, 0.2);
border: {border_c} {color_a};
}}
QTableWidget::indicator:focus {{
background: rgba(255, 255, 255, 0.2);
border: {border_c} {color_a};
}}
QScrollBar:vertical {{
width: 10px;
border: {border_a};
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}}
QScrollBar::handle:vertical {{
background: #bebebe;
border: {border_a};
border-radius: 5px;
}}
QScrollBar::add-line:vertical {{
border: {border_a};
background: none;
}}
QScrollBar::sub-line:vertical {{
border: {border_a};
background: none;
}}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
border: {border_a};
width: 3px;
height: 3px;
background: none;
}}
QScrollBar:horizontal {{
height: 10px;
border: {border_a};
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}}
QScrollBar::handle:horizontal {{
background: #bebebe;
border: {border_a};
border-radius: 5px;
}}
QScrollBar::add-line:horizontal {{
border: {border_a};
background: none;
}}
QScrollBar::sub-line:horizontal {{
border: {border_a};
background: none;
}}
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {{
border: {border_a};
width: 3px;
height: 3px;
background: none;
}}
"""
WINETRICKS_LOG_STYLE = f"""
QTextEdit {{
background: {color_c};
border: {border_a};
border-radius: {border_radius_a};
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
padding: 5px;
}}
"""
FILE_EXPLORER_STYLE = f"""
QListView {{
font-size: {font_size_a};
font-family: {font_family};
background: {color_c};
alternate-background-color: {color_c};
color: {color_f};
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}}
QListView::item {{
padding: 8px;
margin: 0px 5px;
}}
QListView::item:alternate {{
margin: 0px 5px;
background: {color_d};
}}
QListView::item:selected {{
background: {color_a};
color: {color_f};
border-radius: {border_radius_a};
}}
QListView::item:hover {{
background: {color_a};
color: {color_f};
border-radius: {border_radius_a};
}}
QListView::item:focus {{
background: {color_a};
color: {color_f};
border-radius: {border_radius_a};
}}
QScrollBar:vertical {{
width: 10px;
border: {border_a};
border-radius: 5px;
background: {color_c};
}}
QScrollBar::handle:vertical {{
background: #bebebe;
border: {border_a};
border-radius: 5px;
}}
QScrollBar::add-line:vertical {{
border: {border_a};
background: {color_c};
border-bottom-right-radius: 5px;
}}
QScrollBar::sub-line:vertical {{
border: {border_a};
background: {color_c};
border-top-right-radius: 5px;
}}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
border: {border_a};
width: 3px;
height: 3px;
background: none;
}}
"""
FILE_EXPLORER_PATH_LABEL_STYLE = f"""
QLabel {{
color: {color_a};
font-size: {font_size_a};
font-family: {font_family};
}}
"""

View File

@@ -72,8 +72,6 @@ class TrayManager:
self.tray_icon.setContextMenu(self.tray_menu) self.tray_icon.setContextMenu(self.tray_menu)
self.tray_icon.show() self.tray_icon.show()
self.main_window.is_exiting = False
self.click_count = 0 self.click_count = 0
self.click_timer = QTimer() self.click_timer = QTimer()
self.click_timer.setSingleShot(True) self.click_timer.setSingleShot(True)
@@ -231,7 +229,6 @@ class TrayManager:
executable = sys.executable executable = sys.executable
args = sys.argv args = sys.argv
self.main_window.is_exiting = True
QApplication.quit() QApplication.quit()
subprocess.Popen([executable] + args) subprocess.Popen([executable] + args)
@@ -241,11 +238,9 @@ class TrayManager:
save_theme_to_config("standart") save_theme_to_config("standart")
executable = sys.executable executable = sys.executable
args = sys.argv args = sys.argv
self.main_window.is_exiting = True
QApplication.quit() QApplication.quit()
subprocess.Popen([executable] + args) subprocess.Popen([executable] + args)
def force_exit(self): def force_exit(self):
self.main_window.is_exiting = True
self.main_window.close() self.main_window.close()
sys.exit(0) sys.exit(0)

View File

@@ -66,8 +66,11 @@ class VirtualKeyboard(QFrame):
if not self.current_input_widget or not isinstance(self.current_input_widget, QLineEdit): if not self.current_input_widget or not isinstance(self.current_input_widget, QLineEdit):
return return
# Просто устанавливаем курсор на нужную позицию без выделения try:
self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition()) # Просто устанавливаем курсор на нужную позицию без выделения
self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition())
except RuntimeError:
self.current_input_widget = None
def initUI(self): def initUI(self):
layout = QVBoxLayout() layout = QVBoxLayout()
@@ -166,8 +169,10 @@ class VirtualKeyboard(QFrame):
# Очищаем предыдущие кнопки # Очищаем предыдущие кнопки
while self.keyboard_layout.count(): while self.keyboard_layout.count():
item = self.keyboard_layout.takeAt(0) item = self.keyboard_layout.takeAt(0)
if item.widget(): if item:
item.widget().deleteLater() widget = item.widget()
if widget:
widget.deleteLater()
fixed_w = self.button_width fixed_w = self.button_width
fixed_h = self.button_height fixed_h = self.button_height
@@ -280,37 +285,51 @@ class VirtualKeyboard(QFrame):
if coords: if coords:
row, col = coords row, col = coords
item = self.keyboard_layout.itemAtPosition(row, col) item = self.keyboard_layout.itemAtPosition(row, col)
if item and item.widget(): if item:
item.widget().setFocus() widget = item.widget()
if widget:
widget.setFocus()
def up_key(self): def up_key(self):
"""Перемещает курсор в QLineEdit вверх/в начало, если клавиатура видима""" """Перемещает курсор в QLineEdit вверх/в начало, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
self.current_input_widget.setCursorPosition(0) try:
self.current_input_widget.setFocus() self.current_input_widget.setCursorPosition(0)
self.current_input_widget.setFocus()
except RuntimeError:
self.current_input_widget = None
def down_key(self): def down_key(self):
"""Перемещает курсор в QLineEdit вниз/в конец, если клавиатура видима""" """Перемещает курсор в QLineEdit вниз/в конец, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
self.current_input_widget.setCursorPosition(len(self.current_input_widget.text())) try:
self.current_input_widget.setFocus() self.current_input_widget.setCursorPosition(len(self.current_input_widget.text()))
self.current_input_widget.setFocus()
except RuntimeError:
self.current_input_widget = None
def left_key(self): def left_key(self):
"""Перемещает курсор в QLineEdit влево, если клавиатура видима""" """Перемещает курсор в QLineEdit влево, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
pos = self.current_input_widget.cursorPosition() try:
if pos > 0: pos = self.current_input_widget.cursorPosition()
self.current_input_widget.setCursorPosition(pos - 1) if pos > 0:
self.current_input_widget.setFocus() self.current_input_widget.setCursorPosition(pos - 1)
self.current_input_widget.setFocus()
except RuntimeError:
self.current_input_widget = None
def right_key(self): def right_key(self):
"""Перемещает курсор в QLineEdit вправо, если клавиатура видима""" """Перемещает курсор в QLineEdit вправо, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
pos = self.current_input_widget.cursorPosition() try:
text_len = len(self.current_input_widget.text()) pos = self.current_input_widget.cursorPosition()
if pos < text_len: text_len = len(self.current_input_widget.text())
self.current_input_widget.setCursorPosition(pos + 1) if pos < text_len:
self.current_input_widget.setFocus() self.current_input_widget.setCursorPosition(pos + 1)
self.current_input_widget.setFocus()
except RuntimeError:
self.current_input_widget = None
def move_focus_up(self): def move_focus_up(self):
"""Перемещает фокус по кнопкам клавиатуры вверх с фиксированной скоростью""" """Перемещает фокус по кнопкам клавиатуры вверх с фиксированной скоростью"""
@@ -366,34 +385,41 @@ class VirtualKeyboard(QFrame):
self.on_shift_click(not self.shift_pressed) self.on_shift_click(not self.shift_pressed)
self.highlight_cursor_position() self.highlight_cursor_position()
elif self.current_input_widget is not None: elif self.current_input_widget is not None:
# Сохраняем текущую кнопку с фокусом try:
focused_button = self.focusWidget() # Сохраняем текущую кнопку с фокусом
key_to_restore = None focused_button = self.focusWidget()
if isinstance(focused_button, QPushButton) and focused_button in self.buttons.values(): key_to_restore = None
key_to_restore = next((k for k, btn in self.buttons.items() if btn == focused_button), None) if isinstance(focused_button, QPushButton) and focused_button in self.buttons.values():
key_to_restore = next((k for k, btn in self.buttons.items() if btn == focused_button), None)
key = "&" if key == "&&" else key key = "&" if key == "&&" else key
cursor_pos = self.current_input_widget.cursorPosition() cursor_pos = self.current_input_widget.cursorPosition()
text = self.current_input_widget.text() text = self.current_input_widget.text()
new_text = text[:cursor_pos] + key + text[cursor_pos:] new_text = text[:cursor_pos] + key + text[cursor_pos:]
self.current_input_widget.setText(new_text) self.current_input_widget.setText(new_text)
self.current_input_widget.setCursorPosition(cursor_pos + len(key)) self.current_input_widget.setCursorPosition(cursor_pos + len(key))
self.keyPressed.emit(key) self.keyPressed.emit(key)
self.highlight_cursor_position() self.highlight_cursor_position()
# Если был нажат SHIFT, но не CapsLock, отключаем его после ввода символа # Если был нажат SHIFT, но не CapsLock, отключаем его после ввода символа
if self.shift_pressed and not self.caps_lock: if self.shift_pressed and not self.caps_lock:
self.shift_pressed = False self.shift_pressed = False
self.update_keyboard() self.update_keyboard()
if key_to_restore and key_to_restore in self.buttons: if key_to_restore and key_to_restore in self.buttons:
self.buttons[key_to_restore].setFocus() self.buttons[key_to_restore].setFocus()
except RuntimeError:
self.current_input_widget = None
def on_tab_click(self): def on_tab_click(self):
if self.current_input_widget is not None: if self.current_input_widget is not None:
self.current_input_widget.insert('\t') try:
self.keyPressed.emit('Tab') self.current_input_widget.insert('\t')
self.current_input_widget.setFocus() self.keyPressed.emit('Tab')
self.highlight_cursor_position() if self.current_input_widget:
self.current_input_widget.setFocus()
self.highlight_cursor_position()
except RuntimeError:
self.current_input_widget = None
def on_caps_click(self): def on_caps_click(self):
"""Включаем/выключаем CapsLock""" """Включаем/выключаем CapsLock"""
@@ -412,15 +438,18 @@ class VirtualKeyboard(QFrame):
def on_backspace_click(self): def on_backspace_click(self):
"""Обработка одного нажатия Backspace""" """Обработка одного нажатия Backspace"""
if self.current_input_widget is not None: if self.current_input_widget is not None:
cursor_pos = self.current_input_widget.cursorPosition() try:
text = self.current_input_widget.text() cursor_pos = self.current_input_widget.cursorPosition()
text = self.current_input_widget.text()
if cursor_pos > 0: if cursor_pos > 0:
new_text = text[:cursor_pos - 1] + text[cursor_pos:] new_text = text[:cursor_pos - 1] + text[cursor_pos:]
self.current_input_widget.setText(new_text) self.current_input_widget.setText(new_text)
self.current_input_widget.setCursorPosition(cursor_pos - 1) self.current_input_widget.setCursorPosition(cursor_pos - 1)
self.keyPressed.emit('Backspace') self.keyPressed.emit('Backspace')
self.highlight_cursor_position() self.highlight_cursor_position()
except RuntimeError:
self.current_input_widget = None
def on_backspace_pressed(self): def on_backspace_pressed(self):
"""Обработка зажатого Backspace""" """Обработка зажатого Backspace"""
@@ -444,15 +473,21 @@ class VirtualKeyboard(QFrame):
# TODO: тут подумать, как обрабатывать нажатие. # TODO: тут подумать, как обрабатывать нажатие.
# Пока болванка перехода на новую строку, в QlineEdit работает как нажатие пробела # Пока болванка перехода на новую строку, в QlineEdit работает как нажатие пробела
if self.current_input_widget is not None: if self.current_input_widget is not None:
self.current_input_widget.insert('\n') try:
self.keyPressed.emit('Enter') self.current_input_widget.insert('\n')
self.keyPressed.emit('Enter')
except RuntimeError:
self.current_input_widget = None
def on_clear_click(self): def on_clear_click(self):
"""Чистим строку от введённого текста""" """Чистим строку от введённого текста"""
if self.current_input_widget is not None: if self.current_input_widget is not None:
self.current_input_widget.clear() try:
self.keyPressed.emit('Clear') self.current_input_widget.clear()
self.highlight_cursor_position() self.keyPressed.emit('Clear')
self.highlight_cursor_position()
except RuntimeError:
self.current_input_widget = None
def on_lang_click(self): def on_lang_click(self):
"""Переключение раскладки""" """Переключение раскладки"""
@@ -478,8 +513,11 @@ class VirtualKeyboard(QFrame):
def show_for_widget(self, widget): def show_for_widget(self, widget):
self.current_input_widget = widget self.current_input_widget = widget
if widget: if widget:
widget.setFocus() try:
self.highlight_cursor_position() widget.setFocus()
self.highlight_cursor_position()
except RuntimeError:
self.current_input_widget = None
# Позиционирование клавиатуры внизу родительского виджета # Позиционирование клавиатуры внизу родительского виджета
if self._parent and isinstance(self._parent, QWidget): if self._parent and isinstance(self._parent, QWidget):
@@ -530,8 +568,9 @@ class VirtualKeyboard(QFrame):
search_col = current_col + col_span search_col = current_col + col_span
while search_col < num_cols: while search_col < num_cols:
item = self.keyboard_layout.itemAtPosition(search_row, search_col) item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
next_button = cast(QPushButton, item.widget()) if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus() next_button.setFocus()
found = True found = True
break break
@@ -544,8 +583,9 @@ class VirtualKeyboard(QFrame):
# Ищем первую кнопку в этой строке # Ищем первую кнопку в этой строке
while search_col < num_cols: while search_col < num_cols:
item = self.keyboard_layout.itemAtPosition(search_row, search_col) item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
next_button = cast(QPushButton, item.widget()) if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus() next_button.setFocus()
found = True found = True
break break
@@ -557,8 +597,9 @@ class VirtualKeyboard(QFrame):
search_col = current_col - 1 search_col = current_col - 1
while search_col >= 0: while search_col >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col) item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
next_button = cast(QPushButton, item.widget()) if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus() next_button.setFocus()
found = True found = True
break break
@@ -571,8 +612,9 @@ class VirtualKeyboard(QFrame):
# Ищем последнюю кнопку в этой строке # Ищем последнюю кнопку в этой строке
while search_col >= 0: while search_col >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col) item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
next_button = cast(QPushButton, item.widget()) if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus() next_button.setFocus()
found = True found = True
break break
@@ -584,8 +626,9 @@ class VirtualKeyboard(QFrame):
search_row = current_row + row_span search_row = current_row + row_span
while search_row < num_rows: while search_row < num_rows:
item = self.keyboard_layout.itemAtPosition(search_row, search_col) item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
next_button = cast(QPushButton, item.widget()) if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus() next_button.setFocus()
found = True found = True
break break
@@ -598,8 +641,9 @@ class VirtualKeyboard(QFrame):
# Ищем первую кнопку в этом столбце # Ищем первую кнопку в этом столбце
while search_row < num_rows: while search_row < num_rows:
item = self.keyboard_layout.itemAtPosition(search_row, search_col) item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
next_button = cast(QPushButton, item.widget()) if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus() next_button.setFocus()
found = True found = True
break break
@@ -611,8 +655,9 @@ class VirtualKeyboard(QFrame):
search_row = current_row - 1 search_row = current_row - 1
while search_row >= 0: while search_row >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col) item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
next_button = cast(QPushButton, item.widget()) if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus() next_button.setFocus()
found = True found = True
break break
@@ -625,8 +670,9 @@ class VirtualKeyboard(QFrame):
# Ищем последнюю кнопку в этом столбце # Ищем последнюю кнопку в этом столбце
while search_row >= 0: while search_row >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col) item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
next_button = cast(QPushButton, item.widget()) if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus() next_button.setFocus()
found = True found = True
break break
@@ -637,6 +683,7 @@ class VirtualKeyboard(QFrame):
for row in range(self.keyboard_layout.rowCount()): for row in range(self.keyboard_layout.rowCount()):
for col in range(self.keyboard_layout.columnCount()): for col in range(self.keyboard_layout.columnCount()):
item = self.keyboard_layout.itemAtPosition(row, col) item = self.keyboard_layout.itemAtPosition(row, col)
if item and item.widget() and item.widget().isEnabled(): widget = item.widget() if item else None
return cast(QPushButton, item.widget()) if widget and widget.isEnabled():
return cast(QPushButton, widget)
return None return None

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.8" version = "0.1.9"
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" }
@@ -27,15 +27,17 @@ classifiers = [
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"babel>=2.17.0", "babel>=2.17.0",
"beautifulsoup4>=4.14.2", "beautifulsoup4>=4.14.3",
"evdev>=1.9.2", "evdev>=1.9.2",
"icoextract>=0.2.0", "icoextract>=0.2.0",
"libarchive-c>=5.3",
"numpy>=2.2.4", "numpy>=2.2.4",
"orjson>=3.11.3", "orjson>=3.11.5",
"pillow>=12.0.0", "pillow>=12.0.0",
"psutil>=7.1.0", "psutil>=7.2.1",
"pyside6==6.9.1", "pyside6>=6.10.1",
"pyudev>=0.24.3", "pyudev>=0.24.4",
"rapidfuzz>=3.14.3",
"requests>=2.32.5", "requests>=2.32.5",
"tqdm>=4.67.1", "tqdm>=4.67.1",
"vdf>=3.4", "vdf>=3.4",
@@ -103,7 +105,7 @@ ignore = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pre-commit>=4.3.0", "pre-commit>=4.5.1",
"pyaspeller>=2.0.2", "pyaspeller>=2.0.2",
"pyright>=1.1.406", "pyright>=1.1.407",
] ]

View File

@@ -52,7 +52,7 @@
"groupName": "Python dependencies" "groupName": "Python dependencies"
}, },
{ {
"matchPackageNames": ["numpy", "setuptools", "python", "pyside6"], "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)"
}, },

714
uv.lock generated
View File

@@ -17,97 +17,122 @@ wheels = [
[[package]] [[package]]
name = "beautifulsoup4" name = "beautifulsoup4"
version = "4.14.2" version = "4.14.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "soupsieve" }, { name = "soupsieve" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
] ]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.10.5" version = "2025.11.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
] ]
[[package]] [[package]]
name = "cfgv" name = "cfgv"
version = "3.4.0" version = "3.5.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
] ]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.3" version = "3.4.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
{ url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
{ url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
{ url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
] ]
[[package]] [[package]]
@@ -136,11 +161,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce8
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.19.1" version = "3.20.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" },
] ]
[[package]] [[package]]
@@ -163,20 +188,29 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.11"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "libarchive-c"
version = "5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/23/e72434d5457c24113e0c22605cbf7dd806a2561294a335047f5aa8ddc1ca/libarchive_c-5.3.tar.gz", hash = "sha256:5ddb42f1a245c927e7686545da77159859d5d4c6d00163c59daff4df314dae82", size = 54349, upload-time = "2025-05-22T08:08:04.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/3f/ff00c588ebd7eae46a9d6223389f5ae28a3af4b6d975c0f2a6d86b1342b9/libarchive_c-5.3-py3-none-any.whl", hash = "sha256:651550a6ec39266b78f81414140a1e04776c935e72dfc70f1d7c8e0a3672ffba", size = 17035, upload-time = "2025-05-22T08:08:03.045Z" },
] ]
[[package]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.9.1" version = "1.10.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
] ]
[[package]] [[package]]
@@ -246,163 +280,165 @@ wheels = [
[[package]] [[package]]
name = "numpy" name = "numpy"
version = "2.3.3" version = "2.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.11'", "python_full_version >= '3.11'",
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/45/e80d203ef6b267aa29b22714fb558930b27960a0c5ce3c19c999232bb3eb/numpy-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ffc4f5caba7dfcbe944ed674b7eef683c7e94874046454bb79ed7ee0236f59d", size = 21259253, upload-time = "2025-09-09T15:56:02.094Z" }, { url = "https://files.pythonhosted.org/packages/26/7e/7bae7cbcc2f8132271967aa03e03954fc1e48aa1f3bf32b29ca95fbef352/numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e", size = 16940166, upload-time = "2025-12-20T16:15:43.434Z" },
{ url = "https://files.pythonhosted.org/packages/52/18/cf2c648fccf339e59302e00e5f2bc87725a3ce1992f30f3f78c9044d7c43/numpy-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7e946c7170858a0295f79a60214424caac2ffdb0063d4d79cb681f9aa0aa569", size = 14450980, upload-time = "2025-09-09T15:56:05.926Z" }, { url = "https://files.pythonhosted.org/packages/0f/27/6c13f5b46776d6246ec884ac5817452672156a506d08a1f2abb39961930a/numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db", size = 12641781, upload-time = "2025-12-20T16:15:45.701Z" },
{ url = "https://files.pythonhosted.org/packages/93/fb/9af1082bec870188c42a1c239839915b74a5099c392389ff04215dcee812/numpy-2.3.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cd4260f64bc794c3390a63bf0728220dd1a68170c169088a1e0dfa2fde1be12f", size = 5379709, upload-time = "2025-09-09T15:56:07.95Z" }, { url = "https://files.pythonhosted.org/packages/14/1c/83b4998d4860d15283241d9e5215f28b40ac31f497c04b12fa7f428ff370/numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b", size = 5470247, upload-time = "2025-12-20T16:15:47.943Z" },
{ url = "https://files.pythonhosted.org/packages/75/0f/bfd7abca52bcbf9a4a65abc83fe18ef01ccdeb37bfb28bbd6ad613447c79/numpy-2.3.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f0ddb4b96a87b6728df9362135e764eac3cfa674499943ebc44ce96c478ab125", size = 6913923, upload-time = "2025-09-09T15:56:09.443Z" }, { url = "https://files.pythonhosted.org/packages/54/08/cbce72c835d937795571b0464b52069f869c9e78b0c076d416c5269d2718/numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7", size = 6799807, upload-time = "2025-12-20T16:15:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/79/55/d69adad255e87ab7afda1caf93ca997859092afeb697703e2f010f7c2e55/numpy-2.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afd07d377f478344ec6ca2b8d4ca08ae8bd44706763d1efb56397de606393f48", size = 14589591, upload-time = "2025-09-09T15:56:11.234Z" }, { url = "https://files.pythonhosted.org/packages/ff/be/2e647961cd8c980591d75cdcd9e8f647d69fbe05e2a25613dc0a2ea5fb1a/numpy-2.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548", size = 14701992, upload-time = "2025-12-20T16:15:51.615Z" },
{ url = "https://files.pythonhosted.org/packages/10/a2/010b0e27ddeacab7839957d7a8f00e91206e0c2c47abbb5f35a2630e5387/numpy-2.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc92a5dedcc53857249ca51ef29f5e5f2f8c513e22cfb90faeb20343b8c6f7a6", size = 16938714, upload-time = "2025-09-09T15:56:14.637Z" }, { url = "https://files.pythonhosted.org/packages/a2/fb/e1652fb8b6fd91ce6ed429143fe2e01ce714711e03e5b762615e7b36172c/numpy-2.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346", size = 16646871, upload-time = "2025-12-20T16:15:54.129Z" },
{ url = "https://files.pythonhosted.org/packages/1c/6b/12ce8ede632c7126eb2762b9e15e18e204b81725b81f35176eac14dc5b82/numpy-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7af05ed4dc19f308e1d9fc759f36f21921eb7bbfc82843eeec6b2a2863a0aefa", size = 16370592, upload-time = "2025-09-09T15:56:17.285Z" }, { url = "https://files.pythonhosted.org/packages/62/23/d841207e63c4322842f7cd042ae981cffe715c73376dcad8235fb31debf1/numpy-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25", size = 16487190, upload-time = "2025-12-20T16:15:56.147Z" },
{ url = "https://files.pythonhosted.org/packages/b4/35/aba8568b2593067bb6a8fe4c52babb23b4c3b9c80e1b49dff03a09925e4a/numpy-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:433bf137e338677cebdd5beac0199ac84712ad9d630b74eceeb759eaa45ddf30", size = 18884474, upload-time = "2025-09-09T15:56:20.943Z" }, { url = "https://files.pythonhosted.org/packages/bc/a0/6a842c8421ebfdec0a230e65f61e0dabda6edbef443d999d79b87c273965/numpy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a", size = 18580762, upload-time = "2025-12-20T16:15:58.524Z" },
{ url = "https://files.pythonhosted.org/packages/45/fa/7f43ba10c77575e8be7b0138d107e4f44ca4a1ef322cd16980ea3e8b8222/numpy-2.3.3-cp311-cp311-win32.whl", hash = "sha256:eb63d443d7b4ffd1e873f8155260d7f58e7e4b095961b01c91062935c2491e57", size = 6599794, upload-time = "2025-09-09T15:56:23.258Z" }, { url = "https://files.pythonhosted.org/packages/0a/d1/c79e0046641186f2134dde05e6181825b911f8bdcef31b19ddd16e232847/numpy-2.4.0-cp311-cp311-win32.whl", hash = "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53", size = 6233359, upload-time = "2025-12-20T16:16:00.938Z" },
{ url = "https://files.pythonhosted.org/packages/0a/a2/a4f78cb2241fe5664a22a10332f2be886dcdea8784c9f6a01c272da9b426/numpy-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:ec9d249840f6a565f58d8f913bccac2444235025bbb13e9a4681783572ee3caa", size = 13088104, upload-time = "2025-09-09T15:56:25.476Z" }, { url = "https://files.pythonhosted.org/packages/fc/f0/74965001d231f28184d6305b8cdc1b6fcd4bf23033f6cb039cfe76c9fca7/numpy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479", size = 12601132, upload-time = "2025-12-20T16:16:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/79/64/e424e975adbd38282ebcd4891661965b78783de893b381cbc4832fb9beb2/numpy-2.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:74c2a948d02f88c11a3c075d9733f1ae67d97c6bdb97f2bb542f980458b257e7", size = 10460772, upload-time = "2025-09-09T15:56:27.679Z" }, { url = "https://files.pythonhosted.org/packages/65/32/55408d0f46dfebce38017f5bd931affa7256ad6beac1a92a012e1fbc67a7/numpy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6", size = 10573977, upload-time = "2025-12-20T16:16:04.77Z" },
{ url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014, upload-time = "2025-09-09T15:56:29.966Z" }, { url = "https://files.pythonhosted.org/packages/8b/ff/f6400ffec95de41c74b8e73df32e3fff1830633193a7b1e409be7fb1bb8c/numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", size = 16653117, upload-time = "2025-12-20T16:16:06.709Z" },
{ url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220, upload-time = "2025-09-09T15:56:32.175Z" }, { url = "https://files.pythonhosted.org/packages/fd/28/6c23e97450035072e8d830a3c411bf1abd1f42c611ff9d29e3d8f55c6252/numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", size = 12369711, upload-time = "2025-12-20T16:16:08.758Z" },
{ url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918, upload-time = "2025-09-09T15:56:34.175Z" }, { url = "https://files.pythonhosted.org/packages/bc/af/acbef97b630ab1bb45e6a7d01d1452e4251aa88ce680ac36e56c272120ec/numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", size = 5198355, upload-time = "2025-12-20T16:16:10.902Z" },
{ url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922, upload-time = "2025-09-09T15:56:36.149Z" }, { url = "https://files.pythonhosted.org/packages/c1/c8/4e0d436b66b826f2e53330adaa6311f5cac9871a5b5c31ad773b27f25a74/numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6", size = 6545298, upload-time = "2025-12-20T16:16:12.607Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991, upload-time = "2025-09-09T15:56:40.548Z" }, { url = "https://files.pythonhosted.org/packages/ef/27/e1f5d144ab54eac34875e79037011d511ac57b21b220063310cb96c80fbc/numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb", size = 14398387, upload-time = "2025-12-20T16:16:14.257Z" },
{ url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643, upload-time = "2025-09-09T15:56:43.343Z" }, { url = "https://files.pythonhosted.org/packages/67/64/4cb909dd5ab09a9a5d086eff9586e69e827b88a5585517386879474f4cf7/numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63", size = 16363091, upload-time = "2025-12-20T16:16:17.32Z" },
{ url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787, upload-time = "2025-09-09T15:56:46.141Z" }, { url = "https://files.pythonhosted.org/packages/9d/9c/8efe24577523ec6809261859737cf117b0eb6fdb655abdfdc81b2e468ce4/numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95", size = 16176394, upload-time = "2025-12-20T16:16:19.524Z" },
{ url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598, upload-time = "2025-09-09T15:56:49.844Z" }, { url = "https://files.pythonhosted.org/packages/61/f0/1687441ece7b47a62e45a1f82015352c240765c707928edd8aef875d5951/numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6", size = 18287378, upload-time = "2025-12-20T16:16:22.866Z" },
{ url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800, upload-time = "2025-09-09T15:56:52.499Z" }, { url = "https://files.pythonhosted.org/packages/d3/6f/f868765d44e6fc466467ed810ba9d8d6db1add7d4a748abfa2a4c99a3194/numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", size = 5955432, upload-time = "2025-12-20T16:16:25.06Z" },
{ url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615, upload-time = "2025-09-09T15:56:54.422Z" }, { url = "https://files.pythonhosted.org/packages/d4/b5/94c1e79fcbab38d1ca15e13777477b2914dd2d559b410f96949d6637b085/numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", size = 12306201, upload-time = "2025-12-20T16:16:26.979Z" },
{ url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936, upload-time = "2025-09-09T15:56:56.541Z" }, { url = "https://files.pythonhosted.org/packages/70/09/c39dadf0b13bb0768cd29d6a3aaff1fb7c6905ac40e9aaeca26b1c086e06/numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", size = 10308234, upload-time = "2025-12-20T16:16:29.417Z" },
{ url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" }, { url = "https://files.pythonhosted.org/packages/a7/0d/853fd96372eda07c824d24adf02e8bc92bb3731b43a9b2a39161c3667cc4/numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea", size = 16649088, upload-time = "2025-12-20T16:16:31.421Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" }, { url = "https://files.pythonhosted.org/packages/e3/37/cc636f1f2a9f585434e20a3e6e63422f70bfe4f7f6698e941db52ea1ac9a/numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d", size = 12364065, upload-time = "2025-12-20T16:16:33.491Z" },
{ url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" }, { url = "https://files.pythonhosted.org/packages/ed/69/0b78f37ca3690969beee54103ce5f6021709134e8020767e93ba691a72f1/numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee", size = 5192640, upload-time = "2025-12-20T16:16:35.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" }, { url = "https://files.pythonhosted.org/packages/1d/2a/08569f8252abf590294dbb09a430543ec8f8cc710383abfb3e75cc73aeda/numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e", size = 6541556, upload-time = "2025-12-20T16:16:37.276Z" },
{ url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" }, { url = "https://files.pythonhosted.org/packages/93/e9/a949885a4e177493d61519377952186b6cbfdf1d6002764c664ba28349b5/numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2", size = 14396562, upload-time = "2025-12-20T16:16:38.953Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" }, { url = "https://files.pythonhosted.org/packages/99/98/9d4ad53b0e9ef901c2ef1d550d2136f5ac42d3fd2988390a6def32e23e48/numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a", size = 16351719, upload-time = "2025-12-20T16:16:41.503Z" },
{ url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" }, { url = "https://files.pythonhosted.org/packages/28/de/5f3711a38341d6e8dd619f6353251a0cdd07f3d6d101a8fd46f4ef87f895/numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681", size = 16176053, upload-time = "2025-12-20T16:16:44.552Z" },
{ url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" }, { url = "https://files.pythonhosted.org/packages/2a/5b/2a3753dc43916501b4183532e7ace862e13211042bceafa253afb5c71272/numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475", size = 18277859, upload-time = "2025-12-20T16:16:47.174Z" },
{ url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" }, { url = "https://files.pythonhosted.org/packages/2c/c5/a18bcdd07a941db3076ef489d036ab16d2bfc2eae0cf27e5a26e29189434/numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344", size = 5953849, upload-time = "2025-12-20T16:16:49.554Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" }, { url = "https://files.pythonhosted.org/packages/4f/f1/719010ff8061da6e8a26e1980cf090412d4f5f8060b31f0c45d77dd67a01/numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d", size = 12302840, upload-time = "2025-12-20T16:16:51.227Z" },
{ url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" }, { url = "https://files.pythonhosted.org/packages/f5/5a/b3d259083ed8b4d335270c76966cb6cf14a5d1b69e1a608994ac57a659e6/numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d", size = 10308509, upload-time = "2025-12-20T16:16:53.313Z" },
{ url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" }, { url = "https://files.pythonhosted.org/packages/31/01/95edcffd1bb6c0633df4e808130545c4f07383ab629ac7e316fb44fff677/numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6", size = 12491815, upload-time = "2025-12-20T16:16:55.496Z" },
{ url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" }, { url = "https://files.pythonhosted.org/packages/59/ea/5644b8baa92cc1c7163b4b4458c8679852733fa74ca49c942cfa82ded4e0/numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5", size = 5320321, upload-time = "2025-12-20T16:16:57.468Z" },
{ url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" }, { url = "https://files.pythonhosted.org/packages/26/4e/e10938106d70bc21319bd6a86ae726da37edc802ce35a3a71ecdf1fdfe7f/numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3", size = 6641635, upload-time = "2025-12-20T16:16:59.379Z" },
{ url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" }, { url = "https://files.pythonhosted.org/packages/b3/8d/a8828e3eaf5c0b4ab116924df82f24ce3416fa38d0674d8f708ddc6c8aac/numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d", size = 14456053, upload-time = "2025-12-20T16:17:01.768Z" },
{ url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" }, { url = "https://files.pythonhosted.org/packages/68/a1/17d97609d87d4520aa5ae2dcfb32305654550ac6a35effb946d303e594ce/numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3", size = 16401702, upload-time = "2025-12-20T16:17:04.235Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" }, { url = "https://files.pythonhosted.org/packages/18/32/0f13c1b2d22bea1118356b8b963195446f3af124ed7a5adfa8fdecb1b6ca/numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa", size = 16242493, upload-time = "2025-12-20T16:17:06.856Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" }, { url = "https://files.pythonhosted.org/packages/ae/23/48f21e3d309fbc137c068a1475358cbd3a901b3987dcfc97a029ab3068e2/numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c", size = 18324222, upload-time = "2025-12-20T16:17:09.392Z" },
{ url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" }, { url = "https://files.pythonhosted.org/packages/ac/52/41f3d71296a3dcaa4f456aaa3c6fc8e745b43d0552b6bde56571bb4b4a0f/numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93", size = 6076216, upload-time = "2025-12-20T16:17:11.437Z" },
{ url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" }, { url = "https://files.pythonhosted.org/packages/35/ff/46fbfe60ab0710d2a2b16995f708750307d30eccbb4c38371ea9e986866e/numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4", size = 12444263, upload-time = "2025-12-20T16:17:13.182Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" }, { url = "https://files.pythonhosted.org/packages/a3/e3/9189ab319c01d2ed556c932ccf55064c5d75bb5850d1df7a482ce0badead/numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c", size = 10378265, upload-time = "2025-12-20T16:17:15.211Z" },
{ url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" }, { url = "https://files.pythonhosted.org/packages/ab/ed/52eac27de39d5e5a6c9aadabe672bc06f55e24a3d9010cd1183948055d76/numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b", size = 16647476, upload-time = "2025-12-20T16:17:17.671Z" },
{ url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527, upload-time = "2025-09-09T15:57:52.006Z" }, { url = "https://files.pythonhosted.org/packages/77/c0/990ce1b7fcd4e09aeaa574e2a0a839589e4b08b2ca68070f1acb1fea6736/numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e", size = 12374563, upload-time = "2025-12-20T16:17:20.216Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159, upload-time = "2025-09-09T15:57:54.407Z" }, { url = "https://files.pythonhosted.org/packages/37/7c/8c5e389c6ae8f5fd2277a988600d79e9625db3fff011a2d87ac80b881a4c/numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e", size = 5203107, upload-time = "2025-12-20T16:17:22.47Z" },
{ url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624, upload-time = "2025-09-09T15:57:56.5Z" }, { url = "https://files.pythonhosted.org/packages/e6/94/ca5b3bd6a8a70a5eec9a0b8dd7f980c1eff4b8a54970a9a7fef248ef564f/numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51", size = 6538067, upload-time = "2025-12-20T16:17:24.001Z" },
{ url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627, upload-time = "2025-09-09T15:57:58.206Z" }, { url = "https://files.pythonhosted.org/packages/79/43/993eb7bb5be6761dde2b3a3a594d689cec83398e3f58f4758010f3b85727/numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce", size = 14411926, upload-time = "2025-12-20T16:17:25.822Z" },
{ url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926, upload-time = "2025-09-09T15:58:00.035Z" }, { url = "https://files.pythonhosted.org/packages/03/75/d4c43b61de473912496317a854dac54f1efec3eeb158438da6884b70bb90/numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f", size = 16354295, upload-time = "2025-12-20T16:17:28.308Z" },
{ url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958, upload-time = "2025-09-09T15:58:02.738Z" }, { url = "https://files.pythonhosted.org/packages/b8/0a/b54615b47ee8736a6461a4bb6749128dd3435c5a759d5663f11f0e9af4ac/numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded", size = 16190242, upload-time = "2025-12-20T16:17:30.993Z" },
{ url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920, upload-time = "2025-09-09T15:58:05.029Z" }, { url = "https://files.pythonhosted.org/packages/98/ce/ea207769aacad6246525ec6c6bbd66a2bf56c72443dc10e2f90feed29290/numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059", size = 18280875, upload-time = "2025-12-20T16:17:33.327Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076, upload-time = "2025-09-09T15:58:07.745Z" }, { url = "https://files.pythonhosted.org/packages/17/ef/ec409437aa962ea372ed601c519a2b141701683ff028f894b7466f0ab42b/numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db", size = 6002530, upload-time = "2025-12-20T16:17:35.729Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952, upload-time = "2025-09-09T15:58:10.096Z" }, { url = "https://files.pythonhosted.org/packages/5f/4a/5cb94c787a3ed1ac65e1271b968686521169a7b3ec0b6544bb3ca32960b0/numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e", size = 12435890, upload-time = "2025-12-20T16:17:37.599Z" },
{ url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322, upload-time = "2025-09-09T15:58:12.138Z" }, { url = "https://files.pythonhosted.org/packages/48/a0/04b89db963af9de1104975e2544f30de89adbf75b9e75f7dd2599be12c79/numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63", size = 10591892, upload-time = "2025-12-20T16:17:39.649Z" },
{ url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630, upload-time = "2025-09-09T15:58:14.64Z" }, { url = "https://files.pythonhosted.org/packages/53/e5/d74b5ccf6712c06c7a545025a6a71bfa03bdc7e0568b405b0d655232fd92/numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df", size = 12494312, upload-time = "2025-12-20T16:17:41.714Z" },
{ url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987, upload-time = "2025-09-09T15:58:16.889Z" }, { url = "https://files.pythonhosted.org/packages/c2/08/3ca9cc2ddf54dfee7ae9a6479c071092a228c68aef08252aa08dac2af002/numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9", size = 5322862, upload-time = "2025-12-20T16:17:44.145Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076, upload-time = "2025-09-09T15:58:20.343Z" }, { url = "https://files.pythonhosted.org/packages/87/74/0bb63a68394c0c1e52670cfff2e309afa41edbe11b3327d9af29e4383f34/numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9", size = 6644986, upload-time = "2025-12-20T16:17:46.203Z" },
{ url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491, upload-time = "2025-09-09T15:58:22.481Z" }, { url = "https://files.pythonhosted.org/packages/06/8f/9264d9bdbcf8236af2823623fe2f3981d740fc3461e2787e231d97c38c28/numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471", size = 14457958, upload-time = "2025-12-20T16:17:48.017Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913, upload-time = "2025-09-09T15:58:24.569Z" }, { url = "https://files.pythonhosted.org/packages/8c/d9/f9a69ae564bbc7236a35aa883319364ef5fd41f72aa320cc1cbe66148fe2/numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544", size = 16398394, upload-time = "2025-12-20T16:17:50.409Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811, upload-time = "2025-09-09T15:58:26.416Z" }, { url = "https://files.pythonhosted.org/packages/34/c7/39241501408dde7f885d241a98caba5421061a2c6d2b2197ac5e3aa842d8/numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c", size = 16241044, upload-time = "2025-12-20T16:17:52.661Z" },
{ url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689, upload-time = "2025-09-09T15:58:28.831Z" }, { url = "https://files.pythonhosted.org/packages/7c/95/cae7effd90e065a95e59fe710eeee05d7328ed169776dfdd9f789e032125/numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac", size = 18321772, upload-time = "2025-12-20T16:17:54.947Z" },
{ url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855, upload-time = "2025-09-09T15:58:31.349Z" }, { url = "https://files.pythonhosted.org/packages/96/df/3c6c279accd2bfb968a76298e5b276310bd55d243df4fa8ac5816d79347d/numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f", size = 6148320, upload-time = "2025-12-20T16:17:57.249Z" },
{ url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520, upload-time = "2025-09-09T15:58:33.762Z" }, { url = "https://files.pythonhosted.org/packages/92/8d/f23033cce252e7a75cae853d17f582e86534c46404dea1c8ee094a9d6d84/numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4", size = 12623460, upload-time = "2025-12-20T16:17:58.963Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371, upload-time = "2025-09-09T15:58:36.04Z" }, { url = "https://files.pythonhosted.org/packages/a4/4f/1f8475907d1a7c4ef9020edf7f39ea2422ec896849245f00688e4b268a71/numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", size = 10661799, upload-time = "2025-12-20T16:18:01.078Z" },
{ url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576, upload-time = "2025-09-09T15:58:37.927Z" }, { url = "https://files.pythonhosted.org/packages/4b/ef/088e7c7342f300aaf3ee5f2c821c4b9996a1bef2aaf6a49cc8ab4883758e/numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6", size = 16819003, upload-time = "2025-12-20T16:18:03.41Z" },
{ url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953, upload-time = "2025-09-09T15:58:40.576Z" }, { url = "https://files.pythonhosted.org/packages/ff/ce/a53017b5443b4b84517182d463fc7bcc2adb4faa8b20813f8e5f5aeb5faa/numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4", size = 12567105, upload-time = "2025-12-20T16:18:05.594Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f2/7e0a37cfced2644c9563c529f29fa28acbd0960dde32ece683aafa6f4949/numpy-2.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1e02c7159791cd481e1e6d5ddd766b62a4d5acf8df4d4d1afe35ee9c5c33a41e", size = 21131019, upload-time = "2025-09-09T15:58:42.838Z" }, { url = "https://files.pythonhosted.org/packages/77/58/5ff91b161f2ec650c88a626c3905d938c89aaadabd0431e6d9c1330c83e2/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba", size = 5395590, upload-time = "2025-12-20T16:18:08.031Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/3291f505297ed63831135a6cc0f474da0c868a1f31b0dd9a9f03a7a0d2ed/numpy-2.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:dca2d0fc80b3893ae72197b39f69d55a3cd8b17ea1b50aa4c62de82419936150", size = 14376288, upload-time = "2025-09-09T15:58:45.425Z" }, { url = "https://files.pythonhosted.org/packages/1d/4e/f1a084106df8c2df8132fc437e56987308e0524836aa7733721c8429d4fe/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d", size = 6709947, upload-time = "2025-12-20T16:18:09.836Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4b/ae02e985bdeee73d7b5abdefeb98aef1207e96d4c0621ee0cf228ddfac3c/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:99683cbe0658f8271b333a1b1b4bb3173750ad59c0c61f5bbdc5b318918fffe3", size = 5305425, upload-time = "2025-09-09T15:58:48.6Z" }, { url = "https://files.pythonhosted.org/packages/63/09/3d8aeb809c0332c3f642da812ac2e3d74fc9252b3021f8c30c82e99e3f3d/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf", size = 14535119, upload-time = "2025-12-20T16:18:12.105Z" },
{ url = "https://files.pythonhosted.org/packages/8b/eb/9df215d6d7250db32007941500dc51c48190be25f2401d5b2b564e467247/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d9d537a39cc9de668e5cd0e25affb17aec17b577c6b3ae8a3d866b479fbe88d0", size = 6819053, upload-time = "2025-09-09T15:58:50.401Z" }, { url = "https://files.pythonhosted.org/packages/fd/7f/68f0fc43a2cbdc6bb239160c754d87c922f60fbaa0fa3cd3d312b8a7f5ee/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98", size = 16475815, upload-time = "2025-12-20T16:18:14.433Z" },
{ url = "https://files.pythonhosted.org/packages/57/62/208293d7d6b2a8998a4a1f23ac758648c3c32182d4ce4346062018362e29/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8596ba2f8af5f93b01d97563832686d20206d303024777f6dfc2e7c7c3f1850e", size = 14420354, upload-time = "2025-09-09T15:58:52.704Z" }, { url = "https://files.pythonhosted.org/packages/11/73/edeacba3167b1ca66d51b1a5a14697c2c40098b5ffa01811c67b1785a5ab/numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", size = 12489376, upload-time = "2025-12-20T16:18:16.524Z" },
{ url = "https://files.pythonhosted.org/packages/ed/0c/8e86e0ff7072e14a71b4c6af63175e40d1e7e933ce9b9e9f765a95b4e0c3/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1ec5615b05369925bd1125f27df33f3b6c8bc10d788d5999ecd8769a1fa04db", size = 16760413, upload-time = "2025-09-09T15:58:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/af/11/0cc63f9f321ccf63886ac203336777140011fb669e739da36d8db3c53b98/numpy-2.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2e267c7da5bf7309670523896df97f93f6e469fb931161f483cd6882b3b1a5dc", size = 12971844, upload-time = "2025-09-09T15:58:57.359Z" },
] ]
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.11.3" version = "3.11.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/4d/8df5f83256a809c22c4d6792ce8d43bb503be0fb7a8e4da9025754b09658/orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a", size = 5482394, upload-time = "2025-08-26T17:46:43.171Z" } sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/64/4a3cef001c6cd9c64256348d4c13a7b09b857e3e1cbb5185917df67d8ced/orjson-3.11.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:29cb1f1b008d936803e2da3d7cba726fc47232c45df531b29edf0b232dd737e7", size = 238600, upload-time = "2025-08-26T17:44:36.875Z" }, { url = "https://files.pythonhosted.org/packages/79/19/b22cf9dad4db20c8737041046054cbd4f38bb5a2d0e4bb60487832ce3d76/orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1", size = 245719, upload-time = "2025-12-06T15:53:43.877Z" },
{ url = "https://files.pythonhosted.org/packages/10/ce/0c8c87f54f79d051485903dc46226c4d3220b691a151769156054df4562b/orjson-3.11.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97dceed87ed9139884a55db8722428e27bd8452817fbf1869c58b49fecab1120", size = 123526, upload-time = "2025-08-26T17:44:39.574Z" }, { url = "https://files.pythonhosted.org/packages/03/2e/b136dd6bf30ef5143fbe76a4c142828b55ccc618be490201e9073ad954a1/orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870", size = 132467, upload-time = "2025-12-06T15:53:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/249497e861f2d438f45b3ab7b7b361484237414945169aa285608f9f7019/orjson-3.11.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58533f9e8266cb0ac298e259ed7b4d42ed3fa0b78ce76860626164de49e0d467", size = 128075, upload-time = "2025-08-26T17:44:40.672Z" }, { url = "https://files.pythonhosted.org/packages/ae/fc/ae99bfc1e1887d20a0268f0e2686eb5b13d0ea7bbe01de2b566febcd2130/orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09", size = 130702, upload-time = "2025-12-06T15:53:46.659Z" },
{ url = "https://files.pythonhosted.org/packages/e5/64/00485702f640a0fd56144042a1ea196469f4a3ae93681871564bf74fa996/orjson-3.11.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c212cfdd90512fe722fa9bd620de4d46cda691415be86b2e02243242ae81873", size = 130483, upload-time = "2025-08-26T17:44:41.788Z" }, { url = "https://files.pythonhosted.org/packages/6e/43/ef7912144097765997170aca59249725c3ab8ef6079f93f9d708dd058df5/orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd", size = 135907, upload-time = "2025-12-06T15:53:48.487Z" },
{ url = "https://files.pythonhosted.org/packages/64/81/110d68dba3909171bf3f05619ad0cf187b430e64045ae4e0aa7ccfe25b15/orjson-3.11.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff835b5d3e67d9207343effb03760c00335f8b5285bfceefd4dc967b0e48f6a", size = 132539, upload-time = "2025-08-26T17:44:43.12Z" }, { url = "https://files.pythonhosted.org/packages/3f/da/24d50e2d7f4092ddd4d784e37a3fa41f22ce8ed97abc9edd222901a96e74/orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac", size = 139935, upload-time = "2025-12-06T15:53:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/79/92/dba25c22b0ddfafa1e6516a780a00abac28d49f49e7202eb433a53c3e94e/orjson-3.11.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5aa4682912a450c2db89cbd92d356fef47e115dffba07992555542f344d301b", size = 135390, upload-time = "2025-08-26T17:44:44.199Z" }, { url = "https://files.pythonhosted.org/packages/02/4a/b4cb6fcbfff5b95a3a019a8648255a0fac9b221fbf6b6e72be8df2361feb/orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e", size = 137541, upload-time = "2025-12-06T15:53:51.226Z" },
{ url = "https://files.pythonhosted.org/packages/44/1d/ca2230fd55edbd87b58a43a19032d63a4b180389a97520cc62c535b726f9/orjson-3.11.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d18dd34ea2e860553a579df02041845dee0af8985dff7f8661306f95504ddf", size = 132966, upload-time = "2025-08-26T17:44:45.719Z" }, { url = "https://files.pythonhosted.org/packages/a5/99/a11bd129f18c2377c27b2846a9d9be04acec981f770d711ba0aaea563984/orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f", size = 139031, upload-time = "2025-12-06T15:53:52.309Z" },
{ url = "https://files.pythonhosted.org/packages/6e/b9/96bbc8ed3e47e52b487d504bd6861798977445fbc410da6e87e302dc632d/orjson-3.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8b11701bc43be92ea42bd454910437b355dfb63696c06fe953ffb40b5f763b4", size = 131349, upload-time = "2025-08-26T17:44:46.862Z" }, { url = "https://files.pythonhosted.org/packages/64/29/d7b77d7911574733a036bb3e8ad7053ceb2b7d6ea42208b9dbc55b23b9ed/orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18", size = 141622, upload-time = "2025-12-06T15:53:53.606Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3c/418fbd93d94b0df71cddf96b7fe5894d64a5d890b453ac365120daec30f7/orjson-3.11.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:90368277087d4af32d38bd55f9da2ff466d25325bf6167c8f382d8ee40cb2bbc", size = 404087, upload-time = "2025-08-26T17:44:48.079Z" }, { url = "https://files.pythonhosted.org/packages/93/41/332db96c1de76b2feda4f453e91c27202cd092835936ce2b70828212f726/orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a", size = 413800, upload-time = "2025-12-06T15:53:54.866Z" },
{ url = "https://files.pythonhosted.org/packages/5b/a9/2bfd58817d736c2f63608dec0c34857339d423eeed30099b126562822191/orjson-3.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd7ff459fb393358d3a155d25b275c60b07a2c83dcd7ea962b1923f5a1134569", size = 146067, upload-time = "2025-08-26T17:44:49.302Z" }, { url = "https://files.pythonhosted.org/packages/76/e1/5a0d148dd1f89ad2f9651df67835b209ab7fcb1118658cf353425d7563e9/orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7", size = 151198, upload-time = "2025-12-06T15:53:56.383Z" },
{ url = "https://files.pythonhosted.org/packages/33/ba/29023771f334096f564e48d82ed855a0ed3320389d6748a9c949e25be734/orjson-3.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8d902867b699bcd09c176a280b1acdab57f924489033e53d0afe79817da37e6", size = 135506, upload-time = "2025-08-26T17:44:50.558Z" }, { url = "https://files.pythonhosted.org/packages/0d/96/8db67430d317a01ae5cf7971914f6775affdcfe99f5bff9ef3da32492ecc/orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401", size = 141984, upload-time = "2025-12-06T15:53:57.746Z" },
{ url = "https://files.pythonhosted.org/packages/39/62/b5a1eca83f54cb3aa11a9645b8a22f08d97dbd13f27f83aae7c6666a0a05/orjson-3.11.3-cp310-cp310-win32.whl", hash = "sha256:bb93562146120bb51e6b154962d3dadc678ed0fce96513fa6bc06599bb6f6edc", size = 136352, upload-time = "2025-08-26T17:44:51.698Z" }, { url = "https://files.pythonhosted.org/packages/71/49/40d21e1aa1ac569e521069228bb29c9b5a350344ccf922a0227d93c2ed44/orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8", size = 135272, upload-time = "2025-12-06T15:53:59.769Z" },
{ url = "https://files.pythonhosted.org/packages/e3/c0/7ebfaa327d9a9ed982adc0d9420dbce9a3fec45b60ab32c6308f731333fa/orjson-3.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:976c6f1975032cc327161c65d4194c549f2589d88b105a5e3499429a54479770", size = 131539, upload-time = "2025-08-26T17:44:52.974Z" }, { url = "https://files.pythonhosted.org/packages/c4/7e/d0e31e78be0c100e08be64f48d2850b23bcb4d4c70d114f4e43b39f6895a/orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167", size = 133360, upload-time = "2025-12-06T15:54:01.25Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/360674cd817faef32e49276187922a946468579fcaf37afdfb6c07046e92/orjson-3.11.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d2ae0cc6aeb669633e0124531f342a17d8e97ea999e42f12a5ad4adaa304c5f", size = 238238, upload-time = "2025-08-26T17:44:54.214Z" }, { url = "https://files.pythonhosted.org/packages/fd/68/6b3659daec3a81aed5ab47700adb1a577c76a5452d35b91c88efee89987f/orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8", size = 245318, upload-time = "2025-12-06T15:54:02.355Z" },
{ url = "https://files.pythonhosted.org/packages/05/3d/5fa9ea4b34c1a13be7d9046ba98d06e6feb1d8853718992954ab59d16625/orjson-3.11.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ba21dbb2493e9c653eaffdc38819b004b7b1b246fb77bfc93dc016fe664eac91", size = 127713, upload-time = "2025-08-26T17:44:55.596Z" }, { url = "https://files.pythonhosted.org/packages/e9/00/92db122261425f61803ccf0830699ea5567439d966cbc35856fe711bfe6b/orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc", size = 129491, upload-time = "2025-12-06T15:54:03.877Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5f/e18367823925e00b1feec867ff5f040055892fc474bf5f7875649ecfa586/orjson-3.11.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00f1a271e56d511d1569937c0447d7dce5a99a33ea0dec76673706360a051904", size = 123241, upload-time = "2025-08-26T17:44:57.185Z" }, { url = "https://files.pythonhosted.org/packages/94/4f/ffdcb18356518809d944e1e1f77589845c278a1ebbb5a8297dfefcc4b4cb/orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968", size = 132167, upload-time = "2025-12-06T15:54:04.944Z" },
{ url = "https://files.pythonhosted.org/packages/0f/bd/3c66b91c4564759cf9f473251ac1650e446c7ba92a7c0f9f56ed54f9f0e6/orjson-3.11.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b67e71e47caa6680d1b6f075a396d04fa6ca8ca09aafb428731da9b3ea32a5a6", size = 127895, upload-time = "2025-08-26T17:44:58.349Z" }, { url = "https://files.pythonhosted.org/packages/97/c6/0a8caff96f4503f4f7dd44e40e90f4d14acf80d3b7a97cb88747bb712d3e/orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7", size = 130516, upload-time = "2025-12-06T15:54:06.274Z" },
{ url = "https://files.pythonhosted.org/packages/82/b5/dc8dcd609db4766e2967a85f63296c59d4722b39503e5b0bf7fd340d387f/orjson-3.11.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d012ebddffcce8c85734a6d9e5f08180cd3857c5f5a3ac70185b43775d043d", size = 130303, upload-time = "2025-08-26T17:44:59.491Z" }, { url = "https://files.pythonhosted.org/packages/4d/63/43d4dc9bd9954bff7052f700fdb501067f6fb134a003ddcea2a0bb3854ed/orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd", size = 135695, upload-time = "2025-12-06T15:54:07.702Z" },
{ url = "https://files.pythonhosted.org/packages/48/c2/d58ec5fd1270b2aa44c862171891adc2e1241bd7dab26c8f46eb97c6c6f1/orjson-3.11.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd759f75d6b8d1b62012b7f5ef9461d03c804f94d539a5515b454ba3a6588038", size = 132366, upload-time = "2025-08-26T17:45:00.654Z" }, { url = "https://files.pythonhosted.org/packages/87/6f/27e2e76d110919cb7fcb72b26166ee676480a701bcf8fc53ac5d0edce32f/orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9", size = 139664, upload-time = "2025-12-06T15:54:08.828Z" },
{ url = "https://files.pythonhosted.org/packages/73/87/0ef7e22eb8dd1ef940bfe3b9e441db519e692d62ed1aae365406a16d23d0/orjson-3.11.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6890ace0809627b0dff19cfad92d69d0fa3f089d3e359a2a532507bb6ba34efb", size = 135180, upload-time = "2025-08-26T17:45:02.424Z" }, { url = "https://files.pythonhosted.org/packages/d4/f8/5966153a5f1be49b5fbb8ca619a529fde7bc71aa0a376f2bb83fed248bcd/orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef", size = 137289, upload-time = "2025-12-06T15:54:09.898Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6a/e5bf7b70883f374710ad74faf99bacfc4b5b5a7797c1d5e130350e0e28a3/orjson-3.11.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d4a5e041ae435b815e568537755773d05dac031fee6a57b4ba70897a44d9d2", size = 132741, upload-time = "2025-08-26T17:45:03.663Z" }, { url = "https://files.pythonhosted.org/packages/a7/34/8acb12ff0299385c8bbcbb19fbe40030f23f15a6de57a9c587ebf71483fb/orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9", size = 138784, upload-time = "2025-12-06T15:54:11.022Z" },
{ url = "https://files.pythonhosted.org/packages/bd/0c/4577fd860b6386ffaa56440e792af01c7882b56d2766f55384b5b0e9d39b/orjson-3.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d68bf97a771836687107abfca089743885fb664b90138d8761cce61d5625d55", size = 131104, upload-time = "2025-08-26T17:45:04.939Z" }, { url = "https://files.pythonhosted.org/packages/ee/27/910421ea6e34a527f73d8f4ee7bdffa48357ff79c7b8d6eb6f7b82dd1176/orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125", size = 141322, upload-time = "2025-12-06T15:54:12.427Z" },
{ url = "https://files.pythonhosted.org/packages/66/4b/83e92b2d67e86d1c33f2ea9411742a714a26de63641b082bdbf3d8e481af/orjson-3.11.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bfc27516ec46f4520b18ef645864cee168d2a027dbf32c5537cb1f3e3c22dac1", size = 403887, upload-time = "2025-08-26T17:45:06.228Z" }, { url = "https://files.pythonhosted.org/packages/87/a3/4b703edd1a05555d4bb1753d6ce44e1a05b7a6d7c164d5b332c795c63d70/orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814", size = 413612, upload-time = "2025-12-06T15:54:13.858Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e5/9eea6a14e9b5ceb4a271a1fd2e1dec5f2f686755c0fab6673dc6ff3433f4/orjson-3.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f66b001332a017d7945e177e282a40b6997056394e3ed7ddb41fb1813b83e824", size = 145855, upload-time = "2025-08-26T17:45:08.338Z" }, { url = "https://files.pythonhosted.org/packages/1b/36/034177f11d7eeea16d3d2c42a1883b0373978e08bc9dad387f5074c786d8/orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5", size = 150993, upload-time = "2025-12-06T15:54:15.189Z" },
{ url = "https://files.pythonhosted.org/packages/45/78/8d4f5ad0c80ba9bf8ac4d0fc71f93a7d0dc0844989e645e2074af376c307/orjson-3.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:212e67806525d2561efbfe9e799633b17eb668b8964abed6b5319b2f1cfbae1f", size = 135361, upload-time = "2025-08-26T17:45:09.625Z" }, { url = "https://files.pythonhosted.org/packages/44/2f/ea8b24ee046a50a7d141c0227c4496b1180b215e728e3b640684f0ea448d/orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880", size = 141774, upload-time = "2025-12-06T15:54:16.451Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5f/16386970370178d7a9b438517ea3d704efcf163d286422bae3b37b88dbb5/orjson-3.11.3-cp311-cp311-win32.whl", hash = "sha256:6e8e0c3b85575a32f2ffa59de455f85ce002b8bdc0662d6b9c2ed6d80ab5d204", size = 136190, upload-time = "2025-08-26T17:45:10.962Z" }, { url = "https://files.pythonhosted.org/packages/8a/12/cc440554bf8200eb23348a5744a575a342497b65261cd65ef3b28332510a/orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d", size = 135109, upload-time = "2025-12-06T15:54:17.73Z" },
{ url = "https://files.pythonhosted.org/packages/09/60/db16c6f7a41dd8ac9fb651f66701ff2aeb499ad9ebc15853a26c7c152448/orjson-3.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:6be2f1b5d3dc99a5ce5ce162fc741c22ba9f3443d3dd586e6a1211b7bc87bc7b", size = 131389, upload-time = "2025-08-26T17:45:12.285Z" }, { url = "https://files.pythonhosted.org/packages/a3/83/e0c5aa06ba73a6760134b169f11fb970caa1525fa4461f94d76e692299d9/orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1", size = 133193, upload-time = "2025-12-06T15:54:19.426Z" },
{ url = "https://files.pythonhosted.org/packages/3e/2a/bb811ad336667041dea9b8565c7c9faf2f59b47eb5ab680315eea612ef2e/orjson-3.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:fafb1a99d740523d964b15c8db4eabbfc86ff29f84898262bf6e3e4c9e97e43e", size = 126120, upload-time = "2025-08-26T17:45:13.515Z" }, { url = "https://files.pythonhosted.org/packages/cb/35/5b77eaebc60d735e832c5b1a20b155667645d123f09d471db0a78280fb49/orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c", size = 126830, upload-time = "2025-12-06T15:54:20.836Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b0/a7edab2a00cdcb2688e1c943401cb3236323e7bfd2839815c6131a3742f4/orjson-3.11.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8c752089db84333e36d754c4baf19c0e1437012242048439c7e80eb0e6426e3b", size = 238259, upload-time = "2025-08-26T17:45:15.093Z" }, { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c6/ff4865a9cc398a07a83342713b5932e4dc3cb4bf4bc04e8f83dedfc0d736/orjson-3.11.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:9b8761b6cf04a856eb544acdd82fc594b978f12ac3602d6374a7edb9d86fd2c2", size = 127633, upload-time = "2025-08-26T17:45:16.417Z" }, { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" },
{ url = "https://files.pythonhosted.org/packages/6e/e6/e00bea2d9472f44fe8794f523e548ce0ad51eb9693cf538a753a27b8bda4/orjson-3.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b13974dc8ac6ba22feaa867fc19135a3e01a134b4f7c9c28162fed4d615008a", size = 123061, upload-time = "2025-08-26T17:45:17.673Z" }, { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" },
{ url = "https://files.pythonhosted.org/packages/54/31/9fbb78b8e1eb3ac605467cb846e1c08d0588506028b37f4ee21f978a51d4/orjson-3.11.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f83abab5bacb76d9c821fd5c07728ff224ed0e52d7a71b7b3de822f3df04e15c", size = 127956, upload-time = "2025-08-26T17:45:19.172Z" }, { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/36/88/b0604c22af1eed9f98d709a96302006915cfd724a7ebd27d6dd11c22d80b/orjson-3.11.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6fbaf48a744b94091a56c62897b27c31ee2da93d826aa5b207131a1e13d4064", size = 130790, upload-time = "2025-08-26T17:45:20.586Z" }, { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" },
{ url = "https://files.pythonhosted.org/packages/0e/9d/1c1238ae9fffbfed51ba1e507731b3faaf6b846126a47e9649222b0fd06f/orjson-3.11.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc779b4f4bba2847d0d2940081a7b6f7b5877e05408ffbb74fa1faf4a136c424", size = 132385, upload-time = "2025-08-26T17:45:22.036Z" }, { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b5/c06f1b090a1c875f337e21dd71943bc9d84087f7cdf8c6e9086902c34e42/orjson-3.11.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd4b909ce4c50faa2192da6bb684d9848d4510b736b0611b6ab4020ea6fd2d23", size = 135305, upload-time = "2025-08-26T17:45:23.4Z" }, { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/a0/26/5f028c7d81ad2ebbf84414ba6d6c9cac03f22f5cd0d01eb40fb2d6a06b07/orjson-3.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:524b765ad888dc5518bbce12c77c2e83dee1ed6b0992c1790cc5fb49bb4b6667", size = 132875, upload-time = "2025-08-26T17:45:25.182Z" }, { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" },
{ url = "https://files.pythonhosted.org/packages/fe/d4/b8df70d9cfb56e385bf39b4e915298f9ae6c61454c8154a0f5fd7efcd42e/orjson-3.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84fd82870b97ae3cdcea9d8746e592b6d40e1e4d4527835fc520c588d2ded04f", size = 130940, upload-time = "2025-08-26T17:45:27.209Z" }, { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" },
{ url = "https://files.pythonhosted.org/packages/da/5e/afe6a052ebc1a4741c792dd96e9f65bf3939d2094e8b356503b68d48f9f5/orjson-3.11.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbecb9709111be913ae6879b07bafd4b0785b44c1eb5cac8ac76da048b3885a1", size = 403852, upload-time = "2025-08-26T17:45:28.478Z" }, { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" },
{ url = "https://files.pythonhosted.org/packages/f8/90/7bbabafeb2ce65915e9247f14a56b29c9334003536009ef5b122783fe67e/orjson-3.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9dba358d55aee552bd868de348f4736ca5a4086d9a62e2bfbbeeb5629fe8b0cc", size = 146293, upload-time = "2025-08-26T17:45:29.86Z" }, { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" },
{ url = "https://files.pythonhosted.org/packages/27/b3/2d703946447da8b093350570644a663df69448c9d9330e5f1d9cce997f20/orjson-3.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eabcf2e84f1d7105f84580e03012270c7e97ecb1fb1618bda395061b2a84a049", size = 135470, upload-time = "2025-08-26T17:45:31.243Z" }, { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" },
{ url = "https://files.pythonhosted.org/packages/38/70/b14dcfae7aff0e379b0119c8a812f8396678919c431efccc8e8a0263e4d9/orjson-3.11.3-cp312-cp312-win32.whl", hash = "sha256:3782d2c60b8116772aea8d9b7905221437fdf53e7277282e8d8b07c220f96cca", size = 136248, upload-time = "2025-08-26T17:45:32.567Z" }, { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" },
{ url = "https://files.pythonhosted.org/packages/35/b8/9e3127d65de7fff243f7f3e53f59a531bf6bb295ebe5db024c2503cc0726/orjson-3.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:79b44319268af2eaa3e315b92298de9a0067ade6e6003ddaef72f8e0bedb94f1", size = 131437, upload-time = "2025-08-26T17:45:34.949Z" }, { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" },
{ url = "https://files.pythonhosted.org/packages/51/92/a946e737d4d8a7fd84a606aba96220043dcc7d6988b9e7551f7f6d5ba5ad/orjson-3.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:0e92a4e83341ef79d835ca21b8bd13e27c859e4e9e4d7b63defc6e58462a3710", size = 125978, upload-time = "2025-08-26T17:45:36.422Z" }, { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" },
{ url = "https://files.pythonhosted.org/packages/fc/79/8932b27293ad35919571f77cb3693b5906cf14f206ef17546052a241fdf6/orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810", size = 238127, upload-time = "2025-08-26T17:45:38.146Z" }, { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" },
{ url = "https://files.pythonhosted.org/packages/1c/82/cb93cd8cf132cd7643b30b6c5a56a26c4e780c7a145db6f83de977b540ce/orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43", size = 127494, upload-time = "2025-08-26T17:45:39.57Z" }, { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" },
{ url = "https://files.pythonhosted.org/packages/a4/b8/2d9eb181a9b6bb71463a78882bcac1027fd29cf62c38a40cc02fc11d3495/orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27", size = 123017, upload-time = "2025-08-26T17:45:40.876Z" }, { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" },
{ url = "https://files.pythonhosted.org/packages/b4/14/a0e971e72d03b509190232356d54c0f34507a05050bd026b8db2bf2c192c/orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f", size = 127898, upload-time = "2025-08-26T17:45:42.188Z" }, { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" },
{ url = "https://files.pythonhosted.org/packages/8e/af/dc74536722b03d65e17042cc30ae586161093e5b1f29bccda24765a6ae47/orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c", size = 130742, upload-time = "2025-08-26T17:45:43.511Z" }, { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" },
{ url = "https://files.pythonhosted.org/packages/62/e6/7a3b63b6677bce089fe939353cda24a7679825c43a24e49f757805fc0d8a/orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be", size = 132377, upload-time = "2025-08-26T17:45:45.525Z" }, { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" },
{ url = "https://files.pythonhosted.org/packages/fc/cd/ce2ab93e2e7eaf518f0fd15e3068b8c43216c8a44ed82ac2b79ce5cef72d/orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d", size = 135313, upload-time = "2025-08-26T17:45:46.821Z" }, { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" },
{ url = "https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2", size = 132908, upload-time = "2025-08-26T17:45:48.126Z" }, { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" },
{ url = "https://files.pythonhosted.org/packages/eb/92/8f5182d7bc2a1bed46ed960b61a39af8389f0ad476120cd99e67182bfb6d/orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f", size = 130905, upload-time = "2025-08-26T17:45:49.414Z" }, { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" },
{ url = "https://files.pythonhosted.org/packages/1a/60/c41ca753ce9ffe3d0f67b9b4c093bdd6e5fdb1bc53064f992f66bb99954d/orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee", size = 403812, upload-time = "2025-08-26T17:45:51.085Z" }, { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" },
{ url = "https://files.pythonhosted.org/packages/dd/13/e4a4f16d71ce1868860db59092e78782c67082a8f1dc06a3788aef2b41bc/orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e", size = 146277, upload-time = "2025-08-26T17:45:52.851Z" }, { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" },
{ url = "https://files.pythonhosted.org/packages/8d/8b/bafb7f0afef9344754a3a0597a12442f1b85a048b82108ef2c956f53babd/orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633", size = 135418, upload-time = "2025-08-26T17:45:54.806Z" }, { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" },
{ url = "https://files.pythonhosted.org/packages/60/d4/bae8e4f26afb2c23bea69d2f6d566132584d1c3a5fe89ee8c17b718cab67/orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b", size = 136216, upload-time = "2025-08-26T17:45:57.182Z" }, { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" },
{ url = "https://files.pythonhosted.org/packages/88/76/224985d9f127e121c8cad882cea55f0ebe39f97925de040b75ccd4b33999/orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae", size = 131362, upload-time = "2025-08-26T17:45:58.56Z" }, { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" },
{ url = "https://files.pythonhosted.org/packages/e2/cf/0dce7a0be94bd36d1346be5067ed65ded6adb795fdbe3abd234c8d576d01/orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce", size = 125989, upload-time = "2025-08-26T17:45:59.95Z" }, { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" },
{ url = "https://files.pythonhosted.org/packages/ef/77/d3b1fef1fc6aaeed4cbf3be2b480114035f4df8fa1a99d2dac1d40d6e924/orjson-3.11.3-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cf4b81227ec86935568c7edd78352a92e97af8da7bd70bdfdaa0d2e0011a1ab4", size = 238115, upload-time = "2025-08-26T17:46:01.669Z" }, { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6d/468d21d49bb12f900052edcfbf52c292022d0a323d7828dc6376e6319703/orjson-3.11.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:bc8bc85b81b6ac9fc4dae393a8c159b817f4c2c9dee5d12b773bddb3b95fc07e", size = 127493, upload-time = "2025-08-26T17:46:03.466Z" }, { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" },
{ url = "https://files.pythonhosted.org/packages/67/46/1e2588700d354aacdf9e12cc2d98131fb8ac6f31ca65997bef3863edb8ff/orjson-3.11.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:88dcfc514cfd1b0de038443c7b3e6a9797ffb1b3674ef1fd14f701a13397f82d", size = 122998, upload-time = "2025-08-26T17:46:04.803Z" }, { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" },
{ url = "https://files.pythonhosted.org/packages/3b/94/11137c9b6adb3779f1b34fd98be51608a14b430dbc02c6d41134fbba484c/orjson-3.11.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d61cd543d69715d5fc0a690c7c6f8dcc307bc23abef9738957981885f5f38229", size = 132915, upload-time = "2025-08-26T17:46:06.237Z" }, { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/10/61/dccedcf9e9bcaac09fdabe9eaee0311ca92115699500efbd31950d878833/orjson-3.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2b7b153ed90ababadbef5c3eb39549f9476890d339cf47af563aea7e07db2451", size = 130907, upload-time = "2025-08-26T17:46:07.581Z" }, { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" },
{ url = "https://files.pythonhosted.org/packages/0e/fd/0e935539aa7b08b3ca0f817d73034f7eb506792aae5ecc3b7c6e679cdf5f/orjson-3.11.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7909ae2460f5f494fecbcd10613beafe40381fd0316e35d6acb5f3a05bfda167", size = 403852, upload-time = "2025-08-26T17:46:08.982Z" }, { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/4a/2b/50ae1a5505cd1043379132fdb2adb8a05f37b3e1ebffe94a5073321966fd/orjson-3.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2030c01cbf77bc67bee7eef1e7e31ecf28649353987775e3583062c752da0077", size = 146309, upload-time = "2025-08-26T17:46:10.576Z" }, { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" },
{ url = "https://files.pythonhosted.org/packages/cd/1d/a473c158e380ef6f32753b5f39a69028b25ec5be331c2049a2201bde2e19/orjson-3.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0169ebd1cbd94b26c7a7ad282cf5c2744fce054133f959e02eb5265deae1872", size = 135424, upload-time = "2025-08-26T17:46:12.386Z" }, { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" },
{ url = "https://files.pythonhosted.org/packages/da/09/17d9d2b60592890ff7382e591aa1d9afb202a266b180c3d4049b1ec70e4a/orjson-3.11.3-cp314-cp314-win32.whl", hash = "sha256:0c6d7328c200c349e3a4c6d8c83e0a5ad029bdc2d417f234152bf34842d0fc8d", size = 136266, upload-time = "2025-08-26T17:46:13.853Z" }, { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" },
{ url = "https://files.pythonhosted.org/packages/15/58/358f6846410a6b4958b74734727e582ed971e13d335d6c7ce3e47730493e/orjson-3.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:317bbe2c069bbc757b1a2e4105b64aacd3bc78279b66a6b9e51e846e4809f804", size = 131351, upload-time = "2025-08-26T17:46:15.27Z" }, { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" },
{ url = "https://files.pythonhosted.org/packages/28/01/d6b274a0635be0468d4dbd9cafe80c47105937a0d42434e805e67cd2ed8b/orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc", size = 125985, upload-time = "2025-08-26T17:46:16.67Z" }, { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" },
{ url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" },
{ url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" },
{ url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" },
{ url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" },
] ]
[[package]] [[package]]
@@ -514,29 +550,31 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.4.0" version = "4.5.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
] ]
[[package]] [[package]]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.8" version = "0.1.9"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "babel" }, { name = "babel" },
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
{ name = "evdev" }, { name = "evdev" },
{ name = "icoextract" }, { name = "icoextract" },
{ name = "libarchive-c" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "numpy", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "orjson" }, { name = "orjson" },
{ name = "pillow" }, { name = "pillow" },
{ name = "psutil" }, { name = "psutil" },
{ name = "pyside6" }, { name = "pyside6" },
{ name = "pyudev" }, { name = "pyudev" },
{ name = "rapidfuzz" },
{ name = "requests" }, { name = "requests" },
{ name = "tqdm" }, { name = "tqdm" },
{ name = "vdf" }, { name = "vdf" },
@@ -553,15 +591,17 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "babel", specifier = ">=2.17.0" }, { name = "babel", specifier = ">=2.17.0" },
{ name = "beautifulsoup4", specifier = ">=4.14.2" }, { name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "evdev", specifier = ">=1.9.2" }, { name = "evdev", specifier = ">=1.9.2" },
{ name = "icoextract", specifier = ">=0.2.0" }, { name = "icoextract", specifier = ">=0.2.0" },
{ name = "libarchive-c", specifier = ">=5.3" },
{ name = "numpy", specifier = ">=2.2.4" }, { name = "numpy", specifier = ">=2.2.4" },
{ name = "orjson", specifier = ">=3.11.3" }, { name = "orjson", specifier = ">=3.11.5" },
{ name = "pillow", specifier = ">=12.0.0" }, { name = "pillow", specifier = ">=12.0.0" },
{ name = "psutil", specifier = ">=7.1.0" }, { name = "psutil", specifier = ">=7.2.1" },
{ name = "pyside6", specifier = "==6.9.1" }, { name = "pyside6", specifier = ">=6.10.1" },
{ name = "pyudev", specifier = ">=0.24.3" }, { name = "pyudev", specifier = ">=0.24.4" },
{ name = "rapidfuzz", specifier = ">=3.14.3" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
{ name = "tqdm", specifier = ">=4.67.1" }, { name = "tqdm", specifier = ">=4.67.1" },
{ name = "vdf", specifier = ">=3.4" }, { name = "vdf", specifier = ">=3.4" },
@@ -570,14 +610,14 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "pre-commit", specifier = ">=4.3.0" }, { name = "pre-commit", specifier = ">=4.5.1" },
{ name = "pyaspeller", specifier = ">=2.0.2" }, { name = "pyaspeller", specifier = ">=2.0.2" },
{ name = "pyright", specifier = ">=1.1.406" }, { name = "pyright", specifier = ">=1.1.407" },
] ]
[[package]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "4.3.0" version = "4.5.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cfgv" }, { name = "cfgv" },
@@ -586,25 +626,37 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "virtualenv" }, { name = "virtualenv" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
] ]
[[package]] [[package]]
name = "psutil" name = "psutil"
version = "7.1.0" version = "7.2.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660, upload-time = "2025-09-17T20:14:52.902Z" } sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242, upload-time = "2025-09-17T20:14:56.126Z" }, { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" },
{ url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682, upload-time = "2025-09-17T20:14:58.25Z" }, { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" },
{ url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994, upload-time = "2025-09-17T20:14:59.901Z" }, { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" },
{ url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163, upload-time = "2025-09-17T20:15:01.481Z" }, { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" },
{ url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625, upload-time = "2025-09-17T20:15:04.492Z" }, { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" },
{ url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812, upload-time = "2025-09-17T20:15:07.462Z" }, { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965, upload-time = "2025-09-17T20:15:09.673Z" }, { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" },
{ url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" }, { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" },
{ url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" },
{ url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" },
{ url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" },
{ url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" },
{ url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" },
{ url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" },
{ url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" },
{ url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
] ]
[[package]] [[package]]
@@ -621,20 +673,20 @@ wheels = [
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.406" version = "1.1.407"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nodeenv" }, { name = "nodeenv" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" } sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" }, { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" },
] ]
[[package]] [[package]]
name = "pyside6" name = "pyside6"
version = "6.9.1" version = "6.10.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pyside6-addons" }, { name = "pyside6-addons" },
@@ -642,51 +694,51 @@ dependencies = [
{ name = "shiboken6" }, { name = "shiboken6" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/91/8e9c7f7e90431297de9856e90a156ade9420977e26d87996909c63f30bd2/PySide6-6.9.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:f843ef39970a2f79757810fffd7b8e93ac42a3de9ea62f2a03648cde57648aed", size = 558097, upload-time = "2025-06-03T13:20:03.739Z" }, { url = "https://files.pythonhosted.org/packages/56/22/f82cfcd1158be502c5741fe67c3fa853f3c1edbd3ac2c2250769dd9722d1/pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516", size = 558169, upload-time = "2025-11-20T10:09:08.79Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ff/04d1b6b30edd24d761cc30d964860f997bdf37d06620694bf9aab35eec3a/PySide6-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:db44ac08b8f7ac1b421bc1c6a44200d03f08d80dc7b3f68dfdb1684f30f41c17", size = 558239, upload-time = "2025-06-03T13:20:06.205Z" }, { url = "https://files.pythonhosted.org/packages/66/eb/54afe242a25d1c33b04ecd8321a549d9efb7b89eef7690eed92e98ba1dc9/pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa", size = 557818, upload-time = "2025-11-20T10:09:10.132Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b4/ca076c55c11a8e473363e05aa82c5c03dd7ba8f17b77cc9311ce17213193/PySide6-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:531a6e67c429b045674d57fe9864b711eb59e4cded753c2640982e368fd468d1", size = 558239, upload-time = "2025-06-03T13:20:08.257Z" }, { url = "https://files.pythonhosted.org/packages/4d/af/5706b1b33587dc2f3dfa3a5000424befba35e4f2d5889284eebbde37138b/pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c", size = 558358, upload-time = "2025-11-20T10:09:11.288Z" },
{ url = "https://files.pythonhosted.org/packages/83/ff/95c941f53b0faebc27dbe361d8e971b77f504b9cf36f8f5d750fd82cd6fc/PySide6-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:c82dbb7d32bbdd465e01059174f71bddc97de152ab71bded3f1907c40f9a5f16", size = 564571, upload-time = "2025-06-03T13:20:10.321Z" }, { url = "https://files.pythonhosted.org/packages/26/41/3f48d724ecc8e42cea8a8442aa9b5a86d394b85093275990038fd1020039/pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e", size = 564424, upload-time = "2025-11-20T10:09:12.677Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ef/0aa5e910fa4e9770db6b45c23e360a52313922e0ca71fc060a57db613de1/PySide6-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:1525d63dc6dc425b8c2dc5bc01a8cb1d67530401449f3a3490c09a14c095b9f9", size = 401793, upload-time = "2025-06-03T13:20:12.108Z" }, { url = "https://files.pythonhosted.org/packages/af/30/395411473b433875a82f6b5fdd0cb28f19a0e345bcaac9fbc039400d7072/pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437", size = 548866, upload-time = "2025-11-20T10:09:14.174Z" },
] ]
[[package]] [[package]]
name = "pyside6-addons" name = "pyside6-addons"
version = "6.9.1" version = "6.10.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pyside6-essentials" }, { name = "pyside6-essentials" },
{ name = "shiboken6" }, { name = "shiboken6" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/e2/39b9e04335d7ac782b6459bf7abec90c36b8efaac5a88ef818e972c59387/PySide6_Addons-6.9.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:7be0708fa89715c282541fca47e2ba97c0c8d2886e0236ef994b2dd8f52aacdd", size = 316212438, upload-time = "2025-06-03T13:06:15.027Z" }, { url = "https://files.pythonhosted.org/packages/2d/f9/b72a2578d7dbef7741bb90b5756b4ef9c99a5b40148ea53ce7f048573fe9/pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82", size = 322639446, upload-time = "2025-11-20T09:54:50.75Z" },
{ url = "https://files.pythonhosted.org/packages/cf/6f/691d7039a6f7943522a770b713ecd85fa169688dfdd65ddd4db1699d01b6/PySide6_Addons-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:da7869b02e3599d26546fad582db4656060786bc5ec8ece5ec9ee8aa8b42371c", size = 166690468, upload-time = "2025-06-03T13:06:34.962Z" }, { url = "https://files.pythonhosted.org/packages/94/3b/3ed951c570a15570706a89d39bfd4eaaffdf16d5c2dca17e82fc3ec8aaa6/pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4", size = 170678293, upload-time = "2025-11-20T09:56:40.991Z" },
{ url = "https://files.pythonhosted.org/packages/9d/08/a264db09ad35819643d910cd4c73a86f72f23b7092f8ebc7e51dcca53a86/PySide6_Addons-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:53fd08c8152b6ba8c435458afd189835ba905793a5077a2bb0b1b11222b375d4", size = 162466096, upload-time = "2025-06-03T13:08:58.065Z" }, { url = "https://files.pythonhosted.org/packages/22/77/4c780b204d0bf3323a75c184e349d063e208db44c993f1214aa4745d6f47/pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c", size = 166365011, upload-time = "2025-11-20T09:57:20.261Z" },
{ url = "https://files.pythonhosted.org/packages/84/be/a849402f7e73d137b5ae8b4370a49b0cf0e0c02f028b845782cb743e4995/PySide6_Addons-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:cd93a3a5e3886cd958f3a5acc7c061c24f10a394ce9f4ce657ac394544ca7ec2", size = 143150906, upload-time = "2025-06-03T13:09:12.762Z" }, { url = "https://files.pythonhosted.org/packages/04/14/58239776499e6b279fa6ca2e0d47209531454b99f6bd2ad7c96f11109416/pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341", size = 164864664, upload-time = "2025-11-20T09:57:54.815Z" },
{ url = "https://files.pythonhosted.org/packages/2a/f1/1bb6b5859aff4e2b3f5ef789b9cee200811a9f469f04d9aa7425e816622b/PySide6_Addons-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:4f589631bdceb518080ae9c9fa288e64f092cd5bebe25adc8ad89e8eadd4db29", size = 26938762, upload-time = "2025-06-03T13:09:20.009Z" }, { url = "https://files.pythonhosted.org/packages/e2/cd/1b74108671ba4b1ebb2661330665c4898b089e9c87f7ba69fe2438f3d1b6/pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db", size = 34191225, upload-time = "2025-11-20T09:58:04.184Z" },
] ]
[[package]] [[package]]
name = "pyside6-essentials" name = "pyside6-essentials"
version = "6.9.1" version = "6.10.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "shiboken6" }, { name = "shiboken6" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/59/714874db9ef3bbbbda654fd3223248969bea02ec1a5bfdd1c941c4e97749/PySide6_Essentials-6.9.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:ed43435a70e018e1c22efcaf34a9430b83cfcad716dba661b03de21c13322fab", size = 132957077, upload-time = "2025-06-03T13:11:52.629Z" }, { url = "https://files.pythonhosted.org/packages/04/b0/c43209fecef79912e9b1c70a1b5172b1edf76caebcc885c58c60a09613b0/pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe", size = 105461499, upload-time = "2025-11-20T09:59:23.733Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/ea0db68d40a1c487fd255634896f4e37b6560e3ef1f57ca5139bf6509b1f/PySide6_Essentials-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e5da48883f006c6206ef85874db74ddebcdf69b0281bd4f1642b1c5ac1d54aea", size = 96416183, upload-time = "2025-06-03T13:12:48.945Z" }, { url = "https://files.pythonhosted.org/packages/5f/8e/b69ba7fa0c701f3f4136b50460441697ec49ee6ea35c229eb2a5ee4b5952/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856", size = 76764617, upload-time = "2025-11-20T09:59:38.831Z" },
{ url = "https://files.pythonhosted.org/packages/5b/2f/4243630d1733522638c4967d36018c38719d8b84f5246bf3d4c010e0aa9d/PySide6_Essentials-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:e46a2801c9c6098025515fd0af6c594b9e9c951842f68b8f6f3da9858b9b26c2", size = 94171343, upload-time = "2025-06-03T13:12:59.426Z" }, { url = "https://files.pythonhosted.org/packages/bd/83/569d27f4b6c6b9377150fe1a3745d64d02614021bea233636bc936a23423/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2", size = 75850373, upload-time = "2025-11-20T09:59:56.082Z" },
{ url = "https://files.pythonhosted.org/packages/0d/a9/a8e0209ba9116f2c2db990cfb79f2edbd5a3a428013be2df1f1cddd660a9/PySide6_Essentials-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:ad1ac94011492dba33051bc33db1c76a7d6f815a81c01422cb6220273b369145", size = 72435676, upload-time = "2025-06-03T13:13:08.805Z" }, { url = "https://files.pythonhosted.org/packages/1e/64/a8df6333de8ccbf3a320e1346ca30d0f314840aff5e3db9b4b66bf38e26c/pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674", size = 74491180, upload-time = "2025-11-20T10:00:11.215Z" },
{ url = "https://files.pythonhosted.org/packages/d0/e4/23268c57e775a1a4d2843d288a9583a47f2e4b3977a9ae93cb9ded1a4ea5/PySide6_Essentials-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:35c2c2bb4a88db74d11e638cf917524ff35785883f10b439ead07960a5733aa4", size = 49483707, upload-time = "2025-06-03T13:13:16.399Z" }, { url = "https://files.pythonhosted.org/packages/67/da/65cc6c6a870d4ea908c59b2f0f9e2cf3bfc6c0710ebf278ed72f69865e4e/pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c", size = 55190458, upload-time = "2025-11-20T10:00:26.226Z" },
] ]
[[package]] [[package]]
name = "pyudev" name = "pyudev"
version = "0.24.3" version = "0.24.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/5c/6cc034da13830e3da123ccf9a30910bc868fa16670362f004e4b788d0df1/pyudev-0.24.3.tar.gz", hash = "sha256:2e945427a21674893bb97632401db62139d91cea1ee96137cc7b07ad22198fc7", size = 55970, upload-time = "2024-05-10T18:24:04.599Z" } sdist = { url = "https://files.pythonhosted.org/packages/5e/1d/8bdbf651de1002e8b58fbe817bee22b1e8bfcdd24341d42c3238ce9a75f4/pyudev-0.24.4.tar.gz", hash = "sha256:e788bb983700b1a84efc2e88862b0a51af2a995d5b86bc9997546505cf7b36bc", size = 56135, upload-time = "2025-10-08T17:26:58.661Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/3b/c37870f68ceb067707ca7b04db364a1478fcd40c6194007fb6e492ff9a92/pyudev-0.24.3-py3-none-any.whl", hash = "sha256:e8246f0a014fe370119ba2bc781bfbe62c0298d0d6b39c94e83102a8a3f56960", size = 62677, upload-time = "2024-05-10T18:24:02.743Z" }, { url = "https://files.pythonhosted.org/packages/2a/51/3dc0cd6498b24dea3cdeaed648568e3ca7454d41334d840b114156d7479f/pyudev-0.24.4-py3-none-any.whl", hash = "sha256:b3b6b01c68e6fc628428cc45ff3fe6c277afbb5d96507f14473ddb4a6b959e00", size = 62784, upload-time = "2025-10-08T17:26:57.664Z" },
] ]
[[package]] [[package]]
@@ -753,6 +805,96 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "rapidfuzz"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/d1/0efa42a602ed466d3ca1c462eed5d62015c3fd2a402199e2c4b87aa5aa25/rapidfuzz-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9fcd4d751a4fffa17aed1dde41647923c72c74af02459ad1222e3b0022da3a1", size = 1952376, upload-time = "2025-11-01T11:52:29.175Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/37a169bb28b23850a164e6624b1eb299e1ad73c9e7c218ee15744e68d628/rapidfuzz-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ad73afb688b36864a8d9b7344a9cf6da186c471e5790cbf541a635ee0f457f2", size = 1390903, upload-time = "2025-11-01T11:52:31.239Z" },
{ url = "https://files.pythonhosted.org/packages/3c/91/b37207cbbdb6eaafac3da3f55ea85287b27745cb416e75e15769b7d8abe8/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5fb2d978a601820d2cfd111e2c221a9a7bfdf84b41a3ccbb96ceef29f2f1ac7", size = 1385655, upload-time = "2025-11-01T11:52:32.852Z" },
{ url = "https://files.pythonhosted.org/packages/f2/bb/ca53e518acf43430be61f23b9c5987bd1e01e74fcb7a9ee63e00f597aefb/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d83b8b712fa37e06d59f29a4b49e2e9e8635e908fbc21552fe4d1163db9d2a1", size = 3164708, upload-time = "2025-11-01T11:52:34.618Z" },
{ url = "https://files.pythonhosted.org/packages/df/e1/7667bf2db3e52adb13cb933dd4a6a2efc66045d26fa150fc0feb64c26d61/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:dc8c07801df5206b81ed6bd6c35cb520cf9b6c64b9b0d19d699f8633dc942897", size = 1221106, upload-time = "2025-11-01T11:52:36.069Z" },
{ url = "https://files.pythonhosted.org/packages/05/8a/84d9f2d46a2c8eb2ccae81747c4901fa10fe4010aade2d57ce7b4b8e02ec/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c71ce6d4231e5ef2e33caa952bfe671cb9fd42e2afb11952df9fad41d5c821f9", size = 2406048, upload-time = "2025-11-01T11:52:37.936Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a9/a0b7b7a1b81a020c034eb67c8e23b7e49f920004e295378de3046b0d99e1/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0e38828d1381a0cceb8a4831212b2f673d46f5129a1897b0451c883eaf4a1747", size = 2527020, upload-time = "2025-11-01T11:52:39.657Z" },
{ url = "https://files.pythonhosted.org/packages/b4/bc/416df7d108b99b4942ba04dd4cf73c45c3aadb3ef003d95cad78b1d12eb9/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da2a007434323904719158e50f3076a4dadb176ce43df28ed14610c773cc9825", size = 4273958, upload-time = "2025-11-01T11:52:41.017Z" },
{ url = "https://files.pythonhosted.org/packages/81/d0/b81e041c17cd475002114e0ab8800e4305e60837882cb376a621e520d70f/rapidfuzz-3.14.3-cp310-cp310-win32.whl", hash = "sha256:fce3152f94afcfd12f3dd8cf51e48fa606e3cb56719bccebe3b401f43d0714f9", size = 1725043, upload-time = "2025-11-01T11:52:42.465Z" },
{ url = "https://files.pythonhosted.org/packages/09/6b/64ad573337d81d64bc78a6a1df53a72a71d54d43d276ce0662c2e95a1f35/rapidfuzz-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:37d3c653af15cd88592633e942f5407cb4c64184efab163c40fcebad05f25141", size = 1542273, upload-time = "2025-11-01T11:52:44.005Z" },
{ url = "https://files.pythonhosted.org/packages/f4/5e/faf76e259bc15808bc0b86028f510215c3d755b6c3a3911113079485e561/rapidfuzz-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:cc594bbcd3c62f647dfac66800f307beaee56b22aaba1c005e9c4c40ed733923", size = 814875, upload-time = "2025-11-01T11:52:45.405Z" },
{ url = "https://files.pythonhosted.org/packages/76/25/5b0a33ad3332ee1213068c66f7c14e9e221be90bab434f0cb4defa9d6660/rapidfuzz-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea2d113e260a5da0c4003e0a5e9fdf24a9dc2bb9eaa43abd030a1e46ce7837d", size = 1953885, upload-time = "2025-11-01T11:52:47.75Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ab/f1181f500c32c8fcf7c966f5920c7e56b9b1d03193386d19c956505c312d/rapidfuzz-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6c31a4aa68cfa75d7eede8b0ed24b9e458447db604c2db53f358be9843d81d3", size = 1390200, upload-time = "2025-11-01T11:52:49.491Z" },
{ url = "https://files.pythonhosted.org/packages/14/2a/0f2de974ececad873865c6bb3ea3ad07c976ac293d5025b2d73325aac1d4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02821366d928e68ddcb567fed8723dad7ea3a979fada6283e6914d5858674850", size = 1389319, upload-time = "2025-11-01T11:52:51.224Z" },
{ url = "https://files.pythonhosted.org/packages/ed/69/309d8f3a0bb3031fd9b667174cc4af56000645298af7c2931be5c3d14bb4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe8df315ab4e6db4e1be72c5170f8e66021acde22cd2f9d04d2058a9fd8162e", size = 3178495, upload-time = "2025-11-01T11:52:53.005Z" },
{ url = "https://files.pythonhosted.org/packages/10/b7/f9c44a99269ea5bf6fd6a40b84e858414b6e241288b9f2b74af470d222b1/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:769f31c60cd79420188fcdb3c823227fc4a6deb35cafec9d14045c7f6743acae", size = 1228443, upload-time = "2025-11-01T11:52:54.991Z" },
{ url = "https://files.pythonhosted.org/packages/f2/0a/3b3137abac7f19c9220e14cd7ce993e35071a7655e7ef697785a3edfea1a/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54fa03062124e73086dae66a3451c553c1e20a39c077fd704dc7154092c34c63", size = 2411998, upload-time = "2025-11-01T11:52:56.629Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b6/983805a844d44670eaae63831024cdc97ada4e9c62abc6b20703e81e7f9b/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:834d1e818005ed0d4ae38f6b87b86fad9b0a74085467ece0727d20e15077c094", size = 2530120, upload-time = "2025-11-01T11:52:58.298Z" },
{ url = "https://files.pythonhosted.org/packages/b4/cc/2c97beb2b1be2d7595d805682472f1b1b844111027d5ad89b65e16bdbaaa/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:948b00e8476a91f510dd1ec07272efc7d78c275d83b630455559671d4e33b678", size = 4283129, upload-time = "2025-11-01T11:53:00.188Z" },
{ url = "https://files.pythonhosted.org/packages/4d/03/2f0e5e94941045aefe7eafab72320e61285c07b752df9884ce88d6b8b835/rapidfuzz-3.14.3-cp311-cp311-win32.whl", hash = "sha256:43d0305c36f504232f18ea04e55f2059bb89f169d3119c4ea96a0e15b59e2a91", size = 1724224, upload-time = "2025-11-01T11:53:02.149Z" },
{ url = "https://files.pythonhosted.org/packages/cf/99/5fa23e204435803875daefda73fd61baeabc3c36b8fc0e34c1705aab8c7b/rapidfuzz-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:ef6bf930b947bd0735c550683939a032090f1d688dfd8861d6b45307b96fd5c5", size = 1544259, upload-time = "2025-11-01T11:53:03.66Z" },
{ url = "https://files.pythonhosted.org/packages/48/35/d657b85fcc615a42661b98ac90ce8e95bd32af474603a105643963749886/rapidfuzz-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:f3eb0ff3b75d6fdccd40b55e7414bb859a1cda77c52762c9c82b85569f5088e7", size = 814734, upload-time = "2025-11-01T11:53:05.008Z" },
{ url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306, upload-time = "2025-11-01T11:53:06.452Z" },
{ url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788, upload-time = "2025-11-01T11:53:08.721Z" },
{ url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580, upload-time = "2025-11-01T11:53:10.164Z" },
{ url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947, upload-time = "2025-11-01T11:53:12.093Z" },
{ url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872, upload-time = "2025-11-01T11:53:13.664Z" },
{ url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512, upload-time = "2025-11-01T11:53:15.109Z" },
{ url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398, upload-time = "2025-11-01T11:53:17.146Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416, upload-time = "2025-11-01T11:53:19.34Z" },
{ url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527, upload-time = "2025-11-01T11:53:20.949Z" },
{ url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989, upload-time = "2025-11-01T11:53:22.428Z" },
{ url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161, upload-time = "2025-11-01T11:53:23.811Z" },
{ url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" },
{ url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" },
{ url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" },
{ url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" },
{ url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" },
{ url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" },
{ url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" },
{ url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" },
{ url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" },
{ url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" },
{ url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" },
{ url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" },
{ url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" },
{ url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" },
{ url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" },
{ url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" },
{ url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" },
{ url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" },
{ url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" },
{ url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" },
{ url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" },
{ url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" },
{ url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" },
{ url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" },
{ url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" },
{ url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" },
{ url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" },
{ url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" },
{ url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" },
{ url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" },
{ url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" },
{ url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" },
{ url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" },
{ url = "https://files.pythonhosted.org/packages/c9/33/b5bd6475c7c27164b5becc9b0e3eb978f1e3640fea590dd3dced6006ee83/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7cf174b52cb3ef5d49e45d0a1133b7e7d0ecf770ed01f97ae9962c5c91d97d23", size = 1888499, upload-time = "2025-11-01T11:54:42.094Z" },
{ url = "https://files.pythonhosted.org/packages/30/d2/89d65d4db4bb931beade9121bc71ad916b5fa9396e807d11b33731494e8e/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:442cba39957a008dfc5bdef21a9c3f4379e30ffb4e41b8555dbaf4887eca9300", size = 1336747, upload-time = "2025-11-01T11:54:43.957Z" },
{ url = "https://files.pythonhosted.org/packages/85/33/cd87d92b23f0b06e8914a61cea6850c6d495ca027f669fab7a379041827a/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1faa0f8f76ba75fd7b142c984947c280ef6558b5067af2ae9b8729b0a0f99ede", size = 1352187, upload-time = "2025-11-01T11:54:45.518Z" },
{ url = "https://files.pythonhosted.org/packages/22/20/9d30b4a1ab26aac22fff17d21dec7e9089ccddfe25151d0a8bb57001dc3d/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e6eefec45625c634926a9fd46c9e4f31118ac8f3156fff9494422cee45207e6", size = 3101472, upload-time = "2025-11-01T11:54:47.255Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ad/fa2d3e5c29a04ead7eaa731c7cd1f30f9ec3c77b3a578fdf90280797cbcb/rapidfuzz-3.14.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56fefb4382bb12250f164250240b9dd7772e41c5c8ae976fd598a32292449cc5", size = 1511361, upload-time = "2025-11-01T11:54:49.057Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@@ -770,23 +912,23 @@ wheels = [
[[package]] [[package]]
name = "shiboken6" name = "shiboken6"
version = "6.9.1" version = "6.10.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/98/34d4d25b79055959b171420d47fcc10121aefcbb261c91d5491252830e31/shiboken6-6.9.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:40e92afc88da06b5100c56b761e59837ff282166e9531268f3d910b6128e621e", size = 406159, upload-time = "2025-06-03T13:16:45.104Z" }, { url = "https://files.pythonhosted.org/packages/6f/8b/e5db743d505ceea3efc4cd9634a3bee22a3e2bf6e07cefd28c9b9edabcc6/shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71", size = 478483, upload-time = "2025-11-20T10:08:52.411Z" },
{ url = "https://files.pythonhosted.org/packages/5a/07/53b2532ecd42ff925feb06b7bb16917f5f99f9c3470f0815c256789d818b/shiboken6-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efcdfa8655d34aaf8d7a0c7724def3440bd46db02f5ad3b1785db5f6ccb0a8ff", size = 206756, upload-time = "2025-06-03T13:16:46.528Z" }, { url = "https://files.pythonhosted.org/packages/56/ba/b50c1a44b3c4643f482afbf1a0ea58f393827307100389ce29404f9ad3b0/shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4", size = 271993, upload-time = "2025-11-20T10:08:54.093Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b0/75b86ee3f7b044e6a87fbe7abefd1948ca4ae5fcde8321f4986a1d9eaa5e/shiboken6-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:efcf75d48a29ae072d0bf54b3cd5a59ae91bb6b3ab7459e17c769355486c2e0b", size = 203233, upload-time = "2025-06-03T13:16:48.264Z" }, { url = "https://files.pythonhosted.org/packages/16/b8/939c24ebd662b0aa5c945443d0973145b3fb7079f0196274ef7bb4b98f73/shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0", size = 268691, upload-time = "2025-11-20T10:08:55.639Z" },
{ url = "https://files.pythonhosted.org/packages/30/56/00af281275aab4c79e22e0ea65feede0a5c6da3b84e86b21a4a0071e0744/shiboken6-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:209ccf02c135bd70321143dcbc5023ae0c056aa4850a845955dd2f9b2ff280a9", size = 1153587, upload-time = "2025-06-03T13:16:50.454Z" }, { url = "https://files.pythonhosted.org/packages/cf/a6/8c65ee0fa5e172ebcca03246b1bc3bd96cdaf1d60537316648536b7072a5/shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e", size = 1234704, upload-time = "2025-11-20T10:08:57.417Z" },
{ url = "https://files.pythonhosted.org/packages/de/ce/6ccd382fbe1a96926c5514afa6f2c42da3a9a8482e61f8dfc6068a9ca64f/shiboken6-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:2a39997ce275ced7853defc89d3a1f19a11c90991ac6eef3435a69bb0b7ff1de", size = 1831623, upload-time = "2025-06-03T13:16:52.468Z" }, { url = "https://files.pythonhosted.org/packages/7b/6a/c0fea2f2ac7d9d96618c98156500683a4d1f93fea0e8c5a2bc39913d7ef1/shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9", size = 1795567, upload-time = "2025-11-20T10:08:59.184Z" },
] ]
[[package]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.8" version = "2.8.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
] ]
[[package]] [[package]]
@@ -812,11 +954,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.5.0" version = "2.6.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
] ]
[[package]] [[package]]
@@ -830,7 +972,7 @@ wheels = [
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.34.0" version = "20.35.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "distlib" }, { name = "distlib" },
@@ -838,9 +980,9 @@ dependencies = [
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
] ]
[[package]] [[package]]