19 Commits
v0.1.5 ... main

Author SHA1 Message Date
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
41 changed files with 12950 additions and 1047 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,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", "normalized_title": "no sleep for kaname date from ai the somnium files",
"slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files" "slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
@@ -235,10 +279,6 @@
"normalized_title": "cardlife creative survival", "normalized_title": "cardlife creative survival",
"slug": "cardlife-creative-survival" "slug": "cardlife-creative-survival"
}, },
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{ {
"normalized_title": "kompas 3d v24 / компас 3d v24 beta", "normalized_title": "kompas 3d v24 / компас 3d v24 beta",
"slug": "kompas-3d-v24-kompas-3d-v24-beta" "slug": "kompas-3d-v24-kompas-3d-v24-beta"

Binary file not shown.

View File

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

View File

@@ -2,8 +2,10 @@ from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstra
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
from collections.abc import Callable from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -23,7 +25,8 @@ class SafeOpacityEffect(QGraphicsOpacityEffect):
class GameCardAnimations: class GameCardAnimations:
def __init__(self, game_card, theme=None): def __init__(self, game_card, theme=None):
self.game_card = game_card self.game_card = game_card
self.theme = theme if theme is not None else default_styles self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.thickness_anim: QPropertyAnimation | None = None self.thickness_anim: QPropertyAnimation | None = None
self.gradient_anim: QPropertyAnimation | None = None self.gradient_anim: QPropertyAnimation | None = None
self.scale_anim: QPropertyAnimation | None = None self.scale_anim: QPropertyAnimation | None = None
@@ -232,7 +235,8 @@ class GameCardAnimations:
class DetailPageAnimations: class DetailPageAnimations:
def __init__(self, main_window, theme=None): def __init__(self, main_window, theme=None):
self.main_window = main_window self.main_window = main_window
self.theme = theme if theme is not None else default_styles self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.animations = main_window._animations if hasattr(main_window, '_animations') else {} self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable): def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):

View File

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

View File

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

View File

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

View File

@@ -1061,6 +1061,10 @@ class InputManager(QObject):
try: try:
devices = [InputDevice(path) for path in list_devices()] devices = [InputDevice(path) for path in list_devices()]
for device in devices: for device in devices:
# Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2)
if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
logger.debug(f"Skipping ASRock LED controller: {device.name}")
continue
caps = device.capabilities() caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps: if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
return device return device
@@ -1081,7 +1085,9 @@ class InputManager(QObject):
now = time.time() now = time.time()
if event.type == ecodes.EV_KEY and event.value == 1: if event.type == ecodes.EV_KEY and event.value == 1:
if event.code in BUTTONS['menu'] and not self._is_gamescope_session: if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen) # Проверяем, не запущена ли игра
if not getattr(self._parent, '_gameLaunched', False):
self.toggle_fullscreen.emit(not self._is_fullscreen)
else: else:
self.button_pressed.emit(event.code) self.button_pressed.emit(event.code)
elif event.type == ecodes.EV_ABS: elif event.type == ecodes.EV_ABS:

View File

@@ -4,10 +4,9 @@ import shutil
import signal import signal
import subprocess import subprocess
import sys import sys
import portprotonqt.themes.standart.styles as default_styles
import psutil import psutil
from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer from portprotonqt.dialogs import AddGameDialog, FileExplorer
from portprotonqt.game_card import GameCard from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations from portprotonqt.animations import DetailPageAnimations
@@ -31,7 +30,6 @@ from portprotonqt.config_utils import (
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config 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.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager from portprotonqt.tray_manager import TrayManager
@@ -60,15 +58,7 @@ class MainWindow(QMainWindow):
self.is_exiting = False self.is_exiting = False
selected_theme = read_theme_from_config() selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme self.current_theme_name = selected_theme
try: self.theme = self.theme_manager.apply_theme(selected_theme)
self.theme = self.theme_manager.apply_theme(selected_theme)
except FileNotFoundError:
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
self.theme = self.theme_manager.apply_theme("standart")
self.current_theme_name = "standart"
save_theme_to_config("standart")
if not self.theme:
self.theme = default_styles
self.tray_manager = TrayManager(self, app_name, self.current_theme_name) self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size() self.card_width = read_card_size()
self.setWindowTitle(app_name) self.setWindowTitle(app_name)
@@ -209,9 +199,15 @@ class MainWindow(QMainWindow):
self.createPortProtonTab() # вкладка 4 self.createPortProtonTab() # вкладка 4
self.createThemeTab() # вкладка 5 self.createThemeTab() # вкладка 5
self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget)
self.restore_state() self.restore_state()
self.input_manager = InputManager(self) self.input_manager = InputManager(self)
# Connect InputManager gamepad connection/disconnection signals
self.input_manager.button_pressed.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints)
self.detail_animations = DetailPageAnimations(self, self.theme) self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames) QTimer.singleShot(0, self.loadGames)
@@ -223,6 +219,98 @@ class MainWindow(QMainWindow):
self.resize(width, height) self.resize(width, height)
else: else:
self.showNormal() self.showNormal()
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)
gamepad_hints = [
("button_a", _("Select")),
("button_b", _("Back")),
("button_x", _("Add Game")),
("button_y", _("Previous Directory")),
("button_lb", _("Previous Tab")),
("button_rb", _("Next Tab")),
("button_start", _("Context Menu")),
("button_select", _("Toggle Fullscreen")),
("button_guide", _("System Overlay")),
("button_rt", _("Increase Size")),
("button_lt", _("Decrease Size")),
]
keyboard_hints = [
("key_enter", _("Select")),
("key_esc", _("Back")),
("key_e", _("Add Game")),
("key_left", _("Previous Tab")),
("key_right", _("Next Tab")),
("key_context", _("Context Menu")),
("key_insert", _("System Overlay")),
("key_f11", _("Toggle Fullscreen")),
]
self.hintsLabels = []
def makeHint(icon_name: str, action: str, visible: bool):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
# иконка
icon_label = QLabel()
icon_label.setFixedSize(48, 48)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
icon_path = self.theme_manager.get_theme_image(icon_name, self.current_theme_name)
if not icon_path:
icon_path = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(48, 48, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
layout.addWidget(icon_label)
# текст
text_label = QLabel(action)
text_label.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
layout.addWidget(text_label)
container.setVisible(visible)
self.hintsLabels.append((container, icon_name))
hintsLayout.addWidget(container)
for icon, action in gamepad_hints:
makeHint(icon, action, visible=False)
for icon, action in keyboard_hints:
makeHint(icon, action, visible=True)
hintsLayout.addStretch() # чтобы всё разъехалось на всю ширину
return hintsWidget
def updateControlHints(self, *args) -> None:
"""Updates control hints based on gamepad connection status."""
is_gamepad_connected = self.input_manager.gamepad is not None
logger.debug("Updating control hints, gamepad connected: %s", is_gamepad_connected)
for container, icon_name in self.hintsLabels:
if icon_name.startswith("button_"): # это геймпад
container.setVisible(is_gamepad_connected)
else: # это клавиатура
container.setVisible(not is_gamepad_connected)
@Slot(list) @Slot(list)
def on_games_loaded(self, games: list[tuple]): def on_games_loaded(self, games: list[tuple]):
self.games = games self.games = games

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 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.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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