40 Commits

Author SHA1 Message Date
ecfe252ae3 v0.1.6
Some checks failed
Code check / Check code (push) Successful in 1m16s
Build AppImage, Arch and Fedora Packages / Build AppImage (push) Successful in 2m28s
Build AppImage, Arch and Fedora Packages / Build Arch Package (push) Successful in 1m23s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (41) (push) Successful in 2m4s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (42) (push) Successful in 47s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (43) (push) Successful in 2m56s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (rawhide) (push) Successful in 58s
Build AppImage, Arch and Fedora Packages / Create and Publish Release (push) Failing after 35s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 11:45:36 +05:00
1ad19bff6a chore: hide legendary login
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 10:51:33 +05:00
98f07a9792 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:20:58 +05:00
d5c53ed1aa feat(completion): added --debug-level
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:19:40 +05:00
5a2ab36b60 feat(cli): added --debug-level= argument
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:14:51 +05:00
8e25c04f56 chore(logs): start rework
All checks were successful
Code check / Check code (push) Successful in 1m22s
renovate / renovate (push) Successful in 24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-18 17:29:38 +05:00
f249b01dc6 chore(readme): fix logo path
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-17 12:53:38 +05:00
9f32afe6a3 fix: dialog navigation on gamepad
All checks were successful
Code check / Check code (push) Successful in 2m11s
renovate / renovate (push) Successful in 25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 21:30:57 +05:00
f475e6e0b2 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 2m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 17:03:21 +05:00
43a7c37e91 feat: use mouse extra button to back
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 17:01:43 +05:00
f1cf0ffd68 fix ecodes again meh
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 16:40:10 +05:00
70ed3abcb5 fix add game dialog navigation on keyboard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 16:28:19 +05:00
f061b1597e chore(changelog): update
All checks were successful
Check Translations / check-translations (push) Successful in 43s
Code check / Check code (push) Successful in 1m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 12:17:57 +05:00
0f37a8fc6f fix: disable input manager if window is not focused
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 12:15:12 +05:00
850bc57a16 chore: added prompts license to readme
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:55:52 +05:00
0dcc3ea13f chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:51:38 +05:00
1c82b34e36 feat: added ps controllers hint
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:46:48 +05:00
a8c4ae6f7b chore: clean icons
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 10:24:34 +05:00
dd4f658b66 feat: rework createControlHintsWidget
All checks were successful
Code check / Check code (push) Successful in 1m37s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-12 15:07:25 +05:00
bff6b7fd34 chore(build): update setuptools
All checks were successful
Code check / Check code (push) Successful in 1m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-09 10:59:41 +05:00
1e191bbba3 chore(build): added fedora 43 build
All checks were successful
Code check / Check code (push) Successful in 1m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-09 10:07:52 +05:00
4356e653b8 feat: added control hint
All checks were successful
Code check / Check code (push) Successful in 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 20:48:03 +05:00
4fc95511f1 docs(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 18:47:23 +05:00
4d4e14ea52 fix: Prevent fullscreen toggle on 'Select' button press during game launch
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 18:45:30 +05:00
c39f5ad83b chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:58:15 +05:00
f3325ca35f feat(theme-manager): implement singleton and caching for improved theme handling
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:54:25 +05:00
50645066dd chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:30:16 +05:00
7945dd8980 fix(input_manager): exclude ASRock LED controller from gamepad detection
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:28:34 +05:00
59c38f9c57 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m15s
renovate / renovate (push) Successful in 28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-06 12:44:43 +05:00
a2d5d28884 fix(cache): add cleanup of related cache files on JSON updates
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-06 12:43:22 +05:00
16af4b410a chore(renovate): disable almost python-version update
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-03 19:17:13 +05:00
e8e42b5a86 chore(renovate): disable python-version update
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-03 19:08:58 +05:00
d16e2cdf43 chore(renovate): dont update github-runners
All checks were successful
Code check / Check code (push) Successful in 1m44s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 22:56:03 +05:00
Renovate Bot
b60fd0d593 chore(deps): pin dependencies
All checks were successful
Code check / Check code (pull_request) Successful in 2m16s
Code check / Check code (push) Successful in 1m36s
2025-09-02 17:31:21 +00:00
d93f23fe8c chore(renovate): added GITHUB_TOKEN
All checks were successful
Code check / Check code (push) Successful in 1m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 22:28:10 +05:00
5423ada8f1 fix(theme-security): check standart theme too
All checks were successful
Code check / Check code (push) Successful in 1m12s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 17:05:35 +05:00
2547c7c78d chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 00:11:35 +05:00
2e93073446 feat(theme-security): add theme safety checks and unify loading via ThemeManager
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-01 23:58:38 +05:00
Gitea Actions
9657ff20d3 chore: update steam apps list 2025-09-01T15:10:40Z 2025-09-01 15:10:40 +00:00
849333c283 feat(dev-scripts): add import and function safety checks to theme pre-commit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-01 11:42:06 +05:00
77 changed files with 13520 additions and 1615 deletions

View File

@@ -12,7 +12,7 @@ jobs:
name: Build AppImage
runs-on: ubuntu-22.04
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Install required dependencies
run: |
@@ -42,7 +42,7 @@ jobs:
strategy:
matrix:
fedora_version: [41, 42, rawhide]
fedora_version: [41, 42, 43, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
@@ -63,7 +63,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@v4
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Copy fedora.spec
run: |
@@ -84,7 +84,7 @@ jobs:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
volumes:
- /usr:/usr-host
- /opt:/opt-host
@@ -124,7 +124,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -8,7 +8,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 0.1.5
VERSION: 0.1.6
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -99,7 +99,7 @@ jobs:
strategy:
matrix:
fedora_version: [41, 42, rawhide]
fedora_version: [41, 42, 43, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}

View File

@@ -15,10 +15,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set up Python
uses: https://gitea.com/actions/setup-python@v5
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version-file: "pyproject.toml"

View File

@@ -18,7 +18,7 @@ jobs:
fedora: ${{ steps.check.outputs.fedora }}
arch: ${{ steps.check.outputs.arch }}
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
fetch-depth: 0
@@ -63,7 +63,7 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Install required dependencies
run: |
@@ -115,7 +115,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@v4
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Copy fedora-git.spec
run: |
@@ -138,7 +138,7 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
volumes:
- /usr:/usr-host
- /opt:/opt-host
@@ -178,7 +178,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -20,10 +20,10 @@ jobs:
name: Check code
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@v4
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set up Python
uses: https://gitea.com/actions/setup-python@v5
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version-file: "pyproject.toml"

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ AppDir:
id: ru.linux_gaming.PortProtonQt
name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt
version: 0.1.5
version: 0.1.6
exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@"
apt:

View File

@@ -1,5 +1,5 @@
pkgname=portprotonqt
pkgver=0.1.5
pkgver=0.1.6
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any')

View File

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

View File

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

View File

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,48 @@
[
{
"normalized_title": "astroneer",
"slug": "astroneer"
},
{
"normalized_title": "anno 2205",
"slug": "anno-2205"
},
{
"normalized_title": "anno 2070",
"slug": "anno-2070"
},
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{
"normalized_title": "ultrakill (early access)",
"slug": "ultrakill-early-access"
},
{
"normalized_title": "vintage story",
"slug": "vintage-story"
},
{
"normalized_title": "disco elysium the finul cut",
"slug": "disco-elysium-the-finul-cut"
},
{
"normalized_title": "warcraft iii reign of chaos",
"slug": "warcraft-iii-reign-of-chaos"
},
{
"normalized_title": "dying light",
"slug": "dying-light"
},
{
"normalized_title": "лихо одноглазое",
"slug": "liho-odnoglazoe"
},
{
"normalized_title": "indika",
"slug": "indika"
},
{
"normalized_title": "no sleep for kaname date from ai the somnium files",
"slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
@@ -235,10 +279,6 @@
"normalized_title": "cardlife creative survival",
"slug": "cardlife-creative-survival"
},
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{
"normalized_title": "kompas 3d v24 / компас 3d v24 beta",
"slug": "kompas-3d-v24-kompas-3d-v24-beta"

Binary file not shown.

View File

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

View File

@@ -3,8 +3,9 @@
import sys
from pathlib import Path
import re
import ast
# Запрещенные свойства
# Запрещенные QSS-свойства
FORBIDDEN_PROPERTIES = {
"box-shadow",
"backdrop-filter",
@@ -12,15 +13,55 @@ FORBIDDEN_PROPERTIES = {
"text-shadow",
}
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_qss_files():
has_errors = False
for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
with open(qss_file, "r") as f:
content = f.read()
# Проверка на запрещённые QSS-свойства
for prop in FORBIDDEN_PROPERTIES:
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}")
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
has_errors = True
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, (ast.Import, ast.ImportFrom)):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
has_errors = True
except SyntaxError as e:
print(f"ERROR: Syntax error in file {qss_file}: {e}")
has_errors = True
return has_errors
if __name__ == "__main__":

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,12 @@ from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow
from portprotonqt.config_utils import save_fullscreen_config
from portprotonqt.logger import get_logger
from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.cli import parse_args
logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
__app_version__ = "0.1.5"
__app_version__ = "0.1.6"
def main():
app = QApplication(sys.argv)
@@ -20,15 +18,21 @@ def main():
app.setApplicationName(__app_name__)
app.setApplicationVersion(__app_version__)
args = parse_args()
# Setup logger with specified debug level
setup_logger(args.debug_level)
# Reinitialize logger after setup to ensure it uses the new configuration
logger = get_logger(__name__)
system_locale = QLocale.system()
qt_translator = QTranslator()
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
app.installTranslator(qt_translator)
else:
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
args = parse_args()
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
window = MainWindow(app_name=__app_name__)

View File

@@ -13,4 +13,10 @@ def parse_args():
action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
)
parser.add_argument(
"--debug-level",
choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
default='NOTSET',
help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)"
)
return parser.parse_args()

View File

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

View File

@@ -4,7 +4,6 @@ import glob
import shutil
import subprocess
import threading
import logging
import orjson
import psutil
import signal
@@ -17,8 +16,9 @@ from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_s
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.logger import get_logger
logger = logging.getLogger(__name__)
logger = get_logger(__name__)
class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads."""

View File

@@ -9,10 +9,9 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
from icoextract import IconExtractor, IconExtractorError
from PIL import Image
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader
@@ -22,6 +21,7 @@ if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow
logger = get_logger(__name__)
theme_manager = ThemeManager()
def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
"""
@@ -94,8 +94,7 @@ class GameLaunchDialog(QDialog):
"""Modal dialog to indicate game launch progress, similar to Steam's launch dialog."""
def __init__(self, parent=None, game_name=None, theme=None, target_exe=None):
super().__init__(parent)
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager()
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.game_name = game_name
self.target_exe = target_exe # Store the target executable name
self.setWindowTitle(_("Launching {0}").format(self.game_name))
@@ -123,7 +122,7 @@ class GameLaunchDialog(QDialog):
layout.addWidget(self.progress_bar)
# Cancel button
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.clicked.connect(self.reject)
layout.addWidget(self.cancel_button, alignment=Qt.AlignmentFlag.AlignCenter)
@@ -173,8 +172,7 @@ class GameLaunchDialog(QDialog):
class FileExplorer(QDialog):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent)
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager()
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.file_signal = FileSelectedSignal()
self.file_filter = file_filter # Store the file filter
self.directory_only = directory_only # Store the directory_only flag
@@ -272,8 +270,8 @@ class FileExplorer(QDialog):
# Кнопки
self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Select"), icon=self.theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
self.select_button = AutoSizeButton(_("Select"), icon=theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button)
@@ -406,7 +404,7 @@ class FileExplorer(QDialog):
# Добавляем смонтированные диски
for drive in drives:
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
button = AutoSizeButton(drive_name, icon=theme_manager.get_icon("mount_point"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
@@ -416,7 +414,7 @@ class FileExplorer(QDialog):
# Добавляем избранные папки
for folder in favorite_folders:
folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
button = AutoSizeButton(folder_name, icon=self.theme_manager.get_icon("folder"))
button = AutoSizeButton(folder_name, icon=theme_manager.get_icon("folder"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.clicked.connect(lambda checked, path=folder: self.change_drive(path))
@@ -484,7 +482,7 @@ class FileExplorer(QDialog):
try:
if self.current_path != "/":
item = QListWidgetItem("../")
folder_icon = self.theme_manager.get_icon("folder")
folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
@@ -499,7 +497,7 @@ class FileExplorer(QDialog):
# Добавляем директории
for d in sorted(dirs):
item = QListWidgetItem(f"{d}/")
folder_icon = self.theme_manager.get_icon("folder")
folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
@@ -590,8 +588,7 @@ class AddGameDialog(QDialog):
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
super().__init__(parent)
from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager()
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.edit_mode = edit_mode
self.original_name = game_name
self.last_exe_path = exe_path # Store last selected exe path
@@ -627,7 +624,7 @@ class AddGameDialog(QDialog):
if exe_path:
self.exeEdit.setText(exe_path)
exeBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search"))
exeBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
exeBrowseButton.clicked.connect(self.browseExe)
exeBrowseButton.setObjectName("exeBrowseButton") # Для поиска кнопки
@@ -649,7 +646,7 @@ class AddGameDialog(QDialog):
if cover_path:
self.coverEdit.setText(cover_path)
coverBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search"))
coverBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
coverBrowseButton.clicked.connect(self.browseCover)
coverBrowseButton.setObjectName("coverBrowseButton") # Для поиска кнопки
@@ -678,8 +675,8 @@ class AddGameDialog(QDialog):
# Dialog buttons
self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Apply"), icon=self.theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
self.select_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button)

View File

@@ -2,12 +2,10 @@ from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import Signal, Property, Qt, QUrl
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader
@@ -56,7 +54,7 @@ class GameCard(QFrame):
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu)
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else default_styles
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.display_filter = read_display_filter()
self.current_theme_name = read_theme_from_config()

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -360,6 +360,12 @@ msgstr ""
msgid "Themes"
msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -549,9 +555,6 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -360,6 +360,12 @@ msgstr ""
msgid "Themes"
msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -549,9 +555,6 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -358,6 +358,12 @@ msgstr ""
msgid "Themes"
msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -547,9 +553,6 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: 2025-08-31 12:28+0500\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"PO-Revision-Date: 2025-09-13 11:47+0500\n"
"Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
@@ -367,6 +367,13 @@ msgstr "Настройки PortProton"
msgid "Themes"
msgstr "Темы"
msgid "Back"
msgstr "Назад"
#, fuzzy
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..."
@@ -558,9 +565,6 @@ msgstr "Тема '{0}' применена успешно"
msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'"
msgid "Back"
msgstr "Назад"
msgid "LAST LAUNCH"
msgstr "Последний запуск"

View File

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

View File

@@ -4,10 +4,9 @@ import shutil
import signal
import subprocess
import sys
import portprotonqt.themes.standart.styles as default_styles
import psutil
from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer
from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
@@ -16,11 +15,12 @@ from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.input_manager import InputManager
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from portprotonqt.system_overlay import SystemOverlay
from portprotonqt.input_manager import GamepadType
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
from portprotonqt.egs_api import load_egs_games_async, get_egs_executable
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
from portprotonqt.config_utils import (
get_portproton_location, read_theme_from_config, save_theme_to_config, parse_desktop_entry,
@@ -31,7 +31,6 @@ from portprotonqt.config_utils import (
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
)
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
@@ -60,15 +59,7 @@ class MainWindow(QMainWindow):
self.is_exiting = False
selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme
try:
self.theme = self.theme_manager.apply_theme(selected_theme)
except FileNotFoundError:
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
self.theme = self.theme_manager.apply_theme("standart")
self.current_theme_name = "standart"
save_theme_to_config("standart")
if not self.theme:
self.theme = default_styles
self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size()
self.setWindowTitle(app_name)
@@ -151,32 +142,26 @@ class MainWindow(QMainWindow):
self.header.setStyleSheet(self.theme.MAIN_WINDOW_HEADER_STYLE)
headerLayout = QVBoxLayout(self.header)
headerLayout.setContentsMargins(0, 0, 0, 0)
# Текст "PortProton" слева
self.titleLabel = QLabel()
pixmap = load_logo()
if pixmap is None:
width, height = self.theme.pixmapsScaledSize
pixmap = QPixmap(width, height)
pixmap.fill(QColor(0, 0, 0, 0))
width, height = self.theme.pixmapsScaledSize
scaled_pixmap = pixmap.scaled(width, height,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
self.titleLabel.setPixmap(scaled_pixmap)
self.titleLabel.setFixedSize(scaled_pixmap.size())
self.titleLabel.setStyleSheet(self.theme.TITLE_LABEL_STYLE)
headerLayout.addStretch()
self.input_manager = InputManager(self)
self.input_manager.button_pressed.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints)
# 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
self.navWidget = QWidget()
self.navWidget.setStyleSheet(self.theme.NAV_WIDGET_STYLE)
navLayout = QHBoxLayout(self.navWidget)
navLayout.setContentsMargins(10, 0, 10, 0)
navLayout.setSpacing(0)
navLayout.setSpacing(10)
navLayout.addWidget(self.titleLabel)
# Left navigation button (key_left or button_lb)
self.leftNavButton = QLabel()
self.leftNavButton.setFixedSize(32, 32)
self.leftNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
navLayout.addWidget(self.leftNavButton)
# Вкладки
self.tabButtons = {}
tabs = [
_("Library"),
@@ -195,6 +180,16 @@ class MainWindow(QMainWindow):
self.tabButtons[i] = btn
self.tabButtons[0].setChecked(True)
# Right navigation button (key_right or button_rb)
self.rightNavButton = QLabel()
self.rightNavButton.setFixedSize(32, 32)
self.rightNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
navLayout.addWidget(self.rightNavButton)
# Initial update of navigation buttons based on input device
self.updateNavButtons()
mainLayout.addWidget(self.navWidget)
# 3. QStackedWidget (ВКЛАДКИ)
@@ -209,9 +204,12 @@ class MainWindow(QMainWindow):
self.createPortProtonTab() # вкладка 4
self.createThemeTab() # вкладка 5
# Подсказки управления
self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget)
self.restore_state()
self.input_manager = InputManager(self)
self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames)
@@ -223,6 +221,212 @@ class MainWindow(QMainWindow):
self.resize(width, height)
else:
self.showNormal()
def get_button_icon(self, action: str, gtype: GamepadType) -> str:
"""Get the icon name for a specific action and gamepad type."""
mappings = {
'confirm': {
GamepadType.XBOX: "xbox_a",
GamepadType.PLAYSTATION: "ps_cross",
},
'back': {
GamepadType.XBOX: "xbox_b",
GamepadType.PLAYSTATION: "ps_circle",
},
'add_game': {
GamepadType.XBOX: "xbox_x",
GamepadType.PLAYSTATION: "ps_triangle",
},
'context_menu': {
GamepadType.XBOX: "xbox_start",
GamepadType.PLAYSTATION: "ps_options",
},
'menu': {
GamepadType.XBOX: "xbox_view",
GamepadType.PLAYSTATION: "ps_share",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
def get_nav_icon(self, direction: str, gtype: GamepadType) -> str:
"""Get the icon name for navigation direction and gamepad type."""
if direction == 'left':
action = 'prev_tab'
else:
action = 'next_tab'
mappings = {
'prev_tab': {
GamepadType.XBOX: "xbox_lb",
GamepadType.PLAYSTATION: "ps_l1",
},
'next_tab': {
GamepadType.XBOX: "xbox_rb",
GamepadType.PLAYSTATION: "ps_r1",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
def createControlHintsWidget(self) -> QWidget:
from portprotonqt.localization import _
"""Creates a widget displaying control hints for gamepad and keyboard."""
logger.debug("Creating control hints widget")
hintsWidget = QWidget()
hintsWidget.setStyleSheet(self.theme.STATUS_BAR_STYLE)
hintsLayout = QHBoxLayout(hintsWidget)
hintsLayout.setContentsMargins(10, 0, 10, 0)
hintsLayout.setSpacing(20)
gamepad_actions = [
("confirm", _("Select")),
("back", _("Back")),
("add_game", _("Add Game")),
("context_menu", _("Menu")),
("menu", _("Fullscreen")),
]
keyboard_hints = [
("key_enter", _("Select")),
("key_backspace", _("Back")),
("key_e", _("Add Game")),
("key_context", _("Menu")),
("key_f11", _("Fullscreen")),
]
self.hintsLabels = []
def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
# иконка кнопки
icon_label = QLabel()
icon_label.setFixedSize(32, 32)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
for candidate in (
self.theme_manager.get_theme_image(icon_name, self.current_theme_name),
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and pixmap.load(str(candidate)):
break
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
layout.addWidget(icon_label)
# текст действия
text_label = QLabel(action_text)
text_label.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
layout.addWidget(text_label)
if is_gamepad:
container.setVisible(False)
self.hintsLabels.append((container, icon_label, action)) # Store action for dynamic update
else:
container.setVisible(True)
self.hintsLabels.append((container, icon_label, None)) # Keyboard, no action
hintsLayout.addWidget(container)
# Create gamepad hints
for action, text in gamepad_actions:
makeHint("placeholder", text, True, action) # Initial placeholder
# Create keyboard hints
for icon, text in keyboard_hints:
makeHint(icon, text, False)
hintsLayout.addStretch()
return hintsWidget
def updateNavButtons(self, *args) -> None:
"""Updates navigation buttons based on gamepad connection status and type."""
is_gamepad_connected = self.input_manager.gamepad is not None
gtype = self.input_manager.gamepad_type
logger.debug("Updating nav buttons, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
# Left navigation button
left_pix = QPixmap()
if is_gamepad_connected:
left_icon_name = self.get_nav_icon('left', gtype)
else:
left_icon_name = "key_left"
left_icon = self.theme_manager.get_theme_image(left_icon_name, self.current_theme_name)
if left_icon:
left_pix.load(str(left_icon))
if not left_pix.isNull():
self.leftNavButton.setPixmap(left_pix.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
self.leftNavButton.setVisible(True) # Always visible, icon changes
# Right navigation button
right_pix = QPixmap()
if is_gamepad_connected:
right_icon_name = self.get_nav_icon('right', gtype)
else:
right_icon_name = "key_right"
right_icon = self.theme_manager.get_theme_image(right_icon_name, self.current_theme_name)
if right_icon:
right_pix.load(str(right_icon))
if not right_pix.isNull():
self.rightNavButton.setPixmap(right_pix.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
self.rightNavButton.setVisible(True) # Always visible, icon changes
def updateControlHints(self, *args) -> None:
"""Updates control hints based on gamepad connection status and type."""
is_gamepad_connected = self.input_manager.gamepad is not None
gtype = self.input_manager.gamepad_type
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu']
for container, icon_label, action in self.hintsLabels:
if action in gamepad_actions: # Gamepad hint
if is_gamepad_connected:
container.setVisible(True)
# Update icon based on type
icon_name = self.get_button_icon(action, gtype)
icon_path = self.theme_manager.get_theme_image(icon_name, self.current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# Fallback to placeholder
placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
else: # Keyboard hint
container.setVisible(not is_gamepad_connected)
# Update navigation buttons
self.updateNavButtons()
@Slot(list)
def on_games_loaded(self, games: list[tuple]):
self.games = games
@@ -672,6 +876,8 @@ class MainWindow(QMainWindow):
sliderLayout = QHBoxLayout()
sliderLayout.addStretch()
# Слайдер
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250)
@@ -682,6 +888,7 @@ class MainWindow(QMainWindow):
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout)
def calculate_card_width():
@@ -1134,36 +1341,36 @@ class MainWindow(QMainWindow):
self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
# 8. Legendary Authentication
self.legendaryAuthButton = AutoSizeButton(
_("Open Legendary Login"),
icon=self.theme_manager.get_icon("login")
)
self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin)
self.legendaryAuthTitle = QLabel(_("Legendary Authentication:"))
self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton)
self.legendaryCodeEdit = CustomLineEdit(self, theme=self.theme)
self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code"))
self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.legendaryCodeTitle = QLabel(_("Authorization Code:"))
self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit)
self.submitCodeButton = AutoSizeButton(
_("Submit Code"),
icon=self.theme_manager.get_icon("save")
)
self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.submitCodeButton.clicked.connect(self.submitLegendaryCode)
formLayout.addRow(QLabel(""), self.submitCodeButton)
# # 8. Legendary Authentication
# self.legendaryAuthButton = AutoSizeButton(
# _("Open Legendary Login"),
# icon=self.theme_manager.get_icon("login")
# )
# self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
# self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin)
# self.legendaryAuthTitle = QLabel(_("Legendary Authentication:"))
# self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
# self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton)
#
# self.legendaryCodeEdit = CustomLineEdit(self, theme=self.theme)
# self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code"))
# self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
# self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.legendaryCodeTitle = QLabel(_("Authorization Code:"))
# self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
# self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit)
#
# self.submitCodeButton = AutoSizeButton(
# _("Submit Code"),
# icon=self.theme_manager.get_icon("save")
# )
# self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
# self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.submitCodeButton.clicked.connect(self.submitLegendaryCode)
# formLayout.addRow(QLabel(""), self.submitCodeButton)
layout.addLayout(formLayout)
@@ -1205,46 +1412,46 @@ class MainWindow(QMainWindow):
layout.addStretch(1)
self.stackedWidget.addWidget(self.portProtonWidget)
def openLegendaryLogin(self):
"""Opens the Legendary login page in the default web browser."""
login_url = "https://legendary.gl/epiclogin"
try:
QDesktopServices.openUrl(QUrl(login_url))
self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000)
except Exception as e:
logger.error(f"Failed to open Legendary login page: {e}")
self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
def submitLegendaryCode(self):
"""Submits the Legendary authorization code using the legendary CLI."""
auth_code = self.legendaryCodeEdit.text().strip()
if not auth_code:
QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
return
try:
# Execute legendary auth command
result = subprocess.run(
[self.legendary_path, "auth", "--code", auth_code],
capture_output=True,
text=True,
check=True
)
logger.info("Legendary authentication successful: %s", result.stdout)
self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
self.legendaryCodeEdit.clear()
# Reload Epic Games Store games after successful authentication
self.games = self.loadGames()
self.updateGameGrid()
except subprocess.CalledProcessError as e:
logger.error("Legendary authentication failed: %s", e.stderr)
self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
except FileNotFoundError:
logger.error("Legendary executable not found at %s", self.legendary_path)
self.statusBar().showMessage(_("Legendary executable not found"), 5000)
except Exception as e:
logger.error("Unexpected error during Legendary authentication: %s", str(e))
self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
# def openLegendaryLogin(self):
# """Opens the Legendary login page in the default web browser."""
# login_url = "https://legendary.gl/epiclogin"
# try:
# QDesktopServices.openUrl(QUrl(login_url))
# self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000)
# except Exception as e:
# logger.error(f"Failed to open Legendary login page: {e}")
# self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
#
# def submitLegendaryCode(self):
# """Submits the Legendary authorization code using the legendary CLI."""
# auth_code = self.legendaryCodeEdit.text().strip()
# if not auth_code:
# QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
# return
#
# try:
# # Execute legendary auth command
# result = subprocess.run(
# [self.legendary_path, "auth", "--code", auth_code],
# capture_output=True,
# text=True,
# check=True
# )
# logger.info("Legendary authentication successful: %s", result.stdout)
# self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
# self.legendaryCodeEdit.clear()
# # Reload Epic Games Store games after successful authentication
# self.games = self.loadGames()
# self.updateGameGrid()
# except subprocess.CalledProcessError as e:
# logger.error("Legendary authentication failed: %s", e.stderr)
# self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
# except FileNotFoundError:
# logger.error("Legendary executable not found at %s", self.legendary_path)
# self.statusBar().showMessage(_("Legendary executable not found"), 5000)
# except Exception as e:
# logger.error("Unexpected error during Legendary authentication: %s", str(e))
# self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
def resetSettings(self):
"""Сбрасывает настройки и перезапускает приложение."""
@@ -1333,7 +1540,6 @@ class MainWindow(QMainWindow):
self.settingsDebounceTimer.start()
# Управление полноэкранным режимом
gamepad_connected = self.input_manager.find_gamepad() is not None
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
@@ -2032,8 +2238,6 @@ class MainWindow(QMainWindow):
elif not child_running:
# Игра завершилась сбрасываем флаг, сбрасываем кнопку и останавливаем таймер
self._gameLaunched = False
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
self.resetPlayButton()
#self._uninhibit_screensaver()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
@@ -2089,9 +2293,6 @@ class MainWindow(QMainWindow):
# Проверяем, запущена ли игра
if self.game_processes and self.target_exe == current_exe:
# Останавливаем игру
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
@@ -2151,10 +2352,6 @@ class MainWindow(QMainWindow):
icon = QIcon()
update_button.setIcon(icon)
# Delay disabling gamepad handling
if hasattr(self, 'input_manager'):
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(500)
@@ -2192,9 +2389,6 @@ class MainWindow(QMainWindow):
# Если игра уже запущена для этого exe останавливаем её
if self.game_processes and self.target_exe == current_exe:
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
@@ -2242,10 +2436,6 @@ class MainWindow(QMainWindow):
env_vars['START_FROM_STEAM'] = '1'
env_vars['PROCESS_LOG'] = '1'
# Delay disabling gamepad handling to allow rumble to complete
if hasattr(self, 'input_manager'):
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
# Запускаем игру
try:
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)

View File

@@ -22,6 +22,7 @@ import websocket
import requests
import random
import base64
import glob
downloader = Downloader()
logger = get_logger(__name__)
@@ -265,10 +266,20 @@ def get_exiftool_data(game_exe):
logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
return {}
def delete_cached_app_files(cache_dir: str, pattern: str):
"""Deletes cached files matching the given pattern in the cache directory."""
try:
for file_path in glob.glob(os.path.join(cache_dir, pattern)):
os.remove(file_path)
logger.info(f"Deleted cached file: {file_path}")
except Exception as e:
logger.error(f"Failed to delete cached files matching {pattern}: {e}")
def load_steam_apps_async(callback: Callable[[list], None]):
"""
Asynchronously loads the list of Steam applications, using cache if available.
Calls the callback with the list of apps.
Deletes cached app detail files when downloading a new steam_apps.json.
"""
cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "games_appid.tar.xz")
@@ -295,6 +306,8 @@ def load_steam_apps_async(callback: Callable[[list], None]):
if os.path.exists(cache_tar):
os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar)
# Delete all cached app detail files (steam_app_*.json)
delete_cached_app_files(cache_dir, "steam_app_*.json")
steam_apps = data if isinstance(data, list) else []
logger.info("Loaded %d apps from archive", len(steam_apps))
callback(steam_apps)
@@ -325,11 +338,15 @@ def load_steam_apps_async(callback: Callable[[list], None]):
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
)
# Delete cached app detail files before re-downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else:
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
)
# Delete cached app detail files before downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
def build_index(steam_apps):
@@ -427,6 +444,7 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
"""
Asynchronously loads the list of WeAntiCheatYet data, using cache if available.
Calls the callback with the list of anti-cheat data.
Deletes cached anti-cheat files when downloading a new anticheat_games.json.
"""
cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz")
@@ -483,11 +501,15 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
)
# Delete cached anti-cheat files before re-downloading
delete_cached_app_files(cache_dir, "anticheat_*.json") # Adjust pattern if app-specific files are added
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else:
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
)
# Delete cached anti-cheat files before downloading
delete_cached_app_files(cache_dir, "anticheat_*.json") # Adjust pattern if app-specific files are added
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
def build_weanticheatyet_index(anti_cheat_data):

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

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

View File

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

View File

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

View File

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

2
uv.lock generated
View File

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