Compare commits
9 Commits
anylinux-a
...
renovate/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47276044e4 | ||
|
aa0c0a5675
|
|||
|
613b28a751
|
|||
|
a9e9f4e4e3
|
|||
|
61c59814a5
|
|||
|
80d3b69311
|
|||
|
ac09ac1e36
|
|||
|
7cdc7264cd
|
|||
|
94f61b1124
|
@@ -11,34 +11,40 @@ jobs:
|
||||
build-appimage:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman-key --init
|
||||
pacman -Sy --noconfirm archlinux-keyring
|
||||
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
|
||||
|
||||
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Install appimage dependencies
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
cd build-aux/AppImage
|
||||
chmod +x get-dependencies.sh portprotonqt-appimage.sh
|
||||
./get-dependencies.sh --git
|
||||
sudo apt update
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
|
||||
|
||||
- name: Upgrade pip toolchain
|
||||
run: |
|
||||
python3 -m pip install --upgrade \
|
||||
pip setuptools setuptools-scm wheel packaging build
|
||||
|
||||
- name: Install appimage-builder
|
||||
run: |
|
||||
git clone https://github.com/Boria138/appimage-builder
|
||||
cd appimage-builder
|
||||
pip install .
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
cd build-aux/AppImage
|
||||
./portprotonqt-appimage.sh
|
||||
cd build-aux
|
||||
sed -i '/app_info:/,/- exec:/ s/^\(\s*version:\s*\).*/\1"0"/' AppImageBuilder.yml
|
||||
appimage-builder
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-AppImage
|
||||
path: build-aux/AppImage/dist/*.AppImage
|
||||
path: build-aux/PortProtonQt*.AppImage
|
||||
|
||||
build-fedora:
|
||||
name: Build Fedora RPM
|
||||
@@ -88,7 +94,11 @@ jobs:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:9414f5b766a34ebd769608b9ec80e1b4bfd7ea5e86dd49cae06ae308ad6c4221
|
||||
image: archlinux:base-devel@sha256:0a03ad573989e8df9d62ac9d52600a6fb0778016bf5990716a19063e05ebe3c3
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
|
||||
@@ -17,40 +17,49 @@ jobs:
|
||||
build-appimage:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman-key --init
|
||||
pacman -Sy --noconfirm archlinux-keyring
|
||||
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
|
||||
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Install appimage dependencies
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
cd build-aux/AppImage
|
||||
chmod +x get-dependencies.sh portprotonqt-appimage.sh
|
||||
./get-dependencies.sh
|
||||
sudo apt update
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
|
||||
|
||||
- name: Upgrade pip toolchain
|
||||
run: |
|
||||
python3 -m pip install --upgrade \
|
||||
pip setuptools setuptools-scm wheel packaging build
|
||||
|
||||
- name: Install appimage-builder
|
||||
run: |
|
||||
git clone https://github.com/Boria138/appimage-builder
|
||||
cd appimage-builder
|
||||
pip install .
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
cd build-aux/AppImage
|
||||
./portprotonqt-appimage.sh
|
||||
cd build-aux
|
||||
appimage-builder
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-AppImage
|
||||
path: build-aux/AppImage/dist/*.AppImage
|
||||
path: build-aux/PortProtonQt*.AppImage*
|
||||
|
||||
build-arch:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check-translations:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
cat changed_files.txt
|
||||
|
||||
# Check AppImage files
|
||||
if grep -q "build-aux/AppImage/" changed_files.txt; then
|
||||
if grep -q "build-aux/AppImageBuilder.yml" changed_files.txt; then
|
||||
echo "appimage=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "appimage=false" >> $GITHUB_OUTPUT
|
||||
@@ -62,34 +62,29 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman-key --init
|
||||
pacman -S --noconfirm archlinux-keyring
|
||||
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync
|
||||
|
||||
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Install appimage dependencies
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
cd build-aux/AppImage
|
||||
chmod +x get-dependencies.sh portprotonqt-appimage.sh
|
||||
./get-dependencies.sh
|
||||
sudo apt update
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync zstd git
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
cd build-aux/AppImage
|
||||
./portprotonqt-appimage.sh
|
||||
cd build-aux
|
||||
appimage-builder
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-AppImage
|
||||
path: build-aux/AppImage/dist/*.AppImage
|
||||
path: build-aux/PortProtonQt*.AppImage
|
||||
|
||||
build-fedora:
|
||||
name: Build Fedora RPM
|
||||
@@ -143,7 +138,11 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:9414f5b766a34ebd769608b9ec80e1b4bfd7ea5e86dd49cae06ae308ad6c4221
|
||||
image: archlinux:base-devel@sha256:0a03ad573989e8df9d62ac9d52600a6fb0778016bf5990716a19063e05ebe3c3
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
# Determine if git mode is enabled based on the first argument
|
||||
if [ "${1:-}" = "--git" ] || [ "${1:-}" = "-g" ]; then
|
||||
GIT_MODE=true
|
||||
else
|
||||
GIT_MODE=false
|
||||
fi
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
PACKAGE_BUILDER="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/make-aur-package.sh"
|
||||
EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/get-debloated-pkgs.sh"
|
||||
|
||||
if [ "$GIT_MODE" = true ]; then
|
||||
echo "Using git version of PortProtonQt..."
|
||||
PPQT_PKGBUILD="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/build-aux/PKGBUILD-git"
|
||||
else
|
||||
echo "Using stable version of PortProtonQt..."
|
||||
PPQT_PKGBUILD="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/build-aux/PKGBUILD"
|
||||
fi
|
||||
|
||||
|
||||
echo "Installing dependencies..."
|
||||
echo "---------------------------------------------------------------"
|
||||
pacman-key --init
|
||||
pacman -Syy --needed --noconfirm archlinux-keyring
|
||||
pacman -Syu --needed --noconfirm \
|
||||
cabextract \
|
||||
curl \
|
||||
perl-image-exiftool \
|
||||
pyside6 \
|
||||
python-babel \
|
||||
python-beautifulsoup4 \
|
||||
python-evdev \
|
||||
python-numpy \
|
||||
python-orjson \
|
||||
python-pillow \
|
||||
python-psutil \
|
||||
python-pyudev \
|
||||
python-rapidfuzz \
|
||||
python-requests \
|
||||
python-tqdm \
|
||||
python-websocket-client \
|
||||
unrar \
|
||||
unzip \
|
||||
qt6-svg \
|
||||
qt6-wayland \
|
||||
xdg-utils
|
||||
|
||||
echo "Installing debloated packages..."
|
||||
echo "---------------------------------------------------------------"
|
||||
wget --retry-connrefused --tries=30 "$EXTRA_PACKAGES" -O ./get-debloated-pkgs.sh
|
||||
chmod +x ./get-debloated-pkgs.sh
|
||||
./get-debloated-pkgs.sh --add-common --prefer-nano
|
||||
|
||||
echo "Installing AUR packages..."
|
||||
echo "---------------------------------------------------------------"
|
||||
wget --retry-connrefused --tries=30 "$PACKAGE_BUILDER" -O ./make-aur-package.sh
|
||||
chmod +x ./make-aur-package.sh
|
||||
|
||||
./make-aur-package.sh --chaotic-aur icoextract
|
||||
./make-aur-package.sh --chaotic-aur python-vdf
|
||||
|
||||
echo "Building PortProtonQt from PKGBUILD..."
|
||||
echo "---------------------------------------------------------------"
|
||||
wget --retry-connrefused --tries=30 "$PPQT_PKGBUILD" -O ./PKGBUILD
|
||||
makepkg -si --noconfirm
|
||||
|
||||
if [ "$GIT_MODE" = true ]; then
|
||||
# For git version, we use portprotonqt-git
|
||||
pacman -Q portprotonqt-git | awk '{print $2; exit}' > ~/version
|
||||
else
|
||||
# For stable version, we use portprotonqt
|
||||
pacman -Q portprotonqt | awk '{print $2; exit}' > ~/version
|
||||
fi
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SHARUN="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/quick-sharun.sh"
|
||||
ARCH="$(uname -m)"
|
||||
VERSION="$(cat ~/version)"
|
||||
export ARCH VERSION
|
||||
export OUTPATH=./dist
|
||||
export DESKTOP=/usr/share/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
export ICON=/usr/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||
export OUTNAME=PortProtonQt-"$VERSION"-anylinux-"$ARCH".AppImage
|
||||
export DEPLOY_OPENGL=1
|
||||
export DEPLOY_SYS_PYTHON=1
|
||||
export OPTIMIZE_LAUNCH=1
|
||||
|
||||
# Adjust comp settings to bypass oom-killer
|
||||
export DWARFS_COMP="zstd:level=15 -S22 -B5"
|
||||
|
||||
# DEPLOY ALL LIBS
|
||||
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
|
||||
chmod +x ./quick-sharun
|
||||
|
||||
# Add udev rules
|
||||
mkdir -p ./AppDir/etc/udev/rules.d
|
||||
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
|
||||
|
||||
# Deploy Qt translations
|
||||
mkdir -p ./AppDir/usr/share/qt6/translations
|
||||
cp -r /usr/share/qt6/translations/* ./AppDir/usr/share/qt6/translations/
|
||||
|
||||
# Deploy dependencies
|
||||
# Qt libs have to be passed manually due to the app being a python script
|
||||
./quick-sharun \
|
||||
/usr/bin/portprotonqt* \
|
||||
/usr/lib/libQt6Core.so* \
|
||||
/usr/lib/libQt6Gui.so* \
|
||||
/usr/lib/libQt6Network.so* \
|
||||
/usr/lib/libudev.so*
|
||||
|
||||
# Turn AppDir into AppImage
|
||||
./quick-sharun --make-appimage
|
||||
80
build-aux/AppImageBuilder.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
version: 1
|
||||
script:
|
||||
- rm -rf AppDir || true
|
||||
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
|
||||
- uv venv
|
||||
- uv pip install --no-cache-dir ../
|
||||
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
|
||||
- cp -r share AppDir/usr
|
||||
- cp -r lib AppDir/usr
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
|
||||
- shopt -s extglob
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Network*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
|
||||
AppDir:
|
||||
path: ./AppDir
|
||||
after_bundle:
|
||||
- rm -rf $TARGET_APPDIR/usr/share/man || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/doc || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/info || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/help || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/gtk-doc || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/devhelp || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/examples || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/pkgconfig || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/bash-completion || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/pixmaps || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/mime || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true
|
||||
- rm -rf $TARGET_APPDIR/usr/include || true
|
||||
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
|
||||
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
|
||||
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
|
||||
- find $TARGET_APPDIR -type d -empty -delete || true
|
||||
app_info:
|
||||
id: ru.linux_gaming.PortProtonQt
|
||||
name: PortProtonQt
|
||||
icon: ru.linux_gaming.PortProtonQt
|
||||
version: 0.1.9
|
||||
exec: usr/bin/python3
|
||||
exec_args: "-m portprotonqt.app $@"
|
||||
apt:
|
||||
arch: amd64
|
||||
sources:
|
||||
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
|
||||
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
|
||||
include:
|
||||
- python3-minimal
|
||||
- python3-pkg-resources
|
||||
- libopengl0
|
||||
- libk5crypto3
|
||||
- libkrb5-3
|
||||
- libgssapi-krb5-2
|
||||
- libxcb-cursor0
|
||||
- libimage-exiftool-perl
|
||||
- xdg-utils
|
||||
- cabextract
|
||||
- curl
|
||||
- 7zip
|
||||
- unzip
|
||||
- unrar
|
||||
exclude:
|
||||
- "*-doc"
|
||||
- "*-man"
|
||||
- manpages
|
||||
- mandb
|
||||
- "*-dev"
|
||||
- "*-static"
|
||||
- "*-dbg"
|
||||
- "*-dbgsym"
|
||||
runtime:
|
||||
env:
|
||||
PYTHONHOME: '${APPDIR}/usr'
|
||||
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
|
||||
PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
|
||||
AppImage:
|
||||
sign-key: None
|
||||
arch: x86_64
|
||||
comp: zstd
|
||||
474
dev-scripts/appimage_clean.py
Executable file
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PySide6 Dependencies Analyzer with ldd support
|
||||
Анализирует зависимости PySide6 модулей используя ldd для определения
|
||||
реальных зависимостей скомпилированных библиотек.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict, List
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
class PySide6DependencyAnalyzer:
|
||||
def __init__(self, project_root: Path = None):
|
||||
# Системные библиотеки, которые нужно всегда оставлять
|
||||
self.system_libs = {
|
||||
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
|
||||
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus',
|
||||
'libQt6Svg'
|
||||
}
|
||||
|
||||
self.critical_modules = {
|
||||
'QtSvg',
|
||||
}
|
||||
|
||||
self.real_dependencies = {}
|
||||
self.used_modules_code = set()
|
||||
self.used_modules_ldd = set()
|
||||
self.all_required_modules = set()
|
||||
# Определяем корень проекта
|
||||
if project_root is None:
|
||||
# Корень проекта - две директории выше от скрипта
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
else:
|
||||
self.project_root = project_root
|
||||
|
||||
self.venv_path = self.project_root / ".venv"
|
||||
self.build_path = self.project_root / "build-aux"
|
||||
|
||||
def find_python_files(self, directory: Path) -> List[Path]:
|
||||
"""Находит все Python файлы в директории"""
|
||||
python_files = []
|
||||
for root, dirs, files in os.walk(directory):
|
||||
dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git'}]
|
||||
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
python_files.append(Path(root) / file)
|
||||
return python_files
|
||||
|
||||
def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]:
|
||||
"""Находит все PySide6 библиотеки (.so файлы)"""
|
||||
libs = {}
|
||||
|
||||
# Ищем venv в корне проекта
|
||||
venv_candidates = [
|
||||
self.venv_path, # .venv
|
||||
self.project_root / "venv",
|
||||
self.project_root / ".virtualenv",
|
||||
]
|
||||
|
||||
pyside6_path = None
|
||||
|
||||
# Пробуем найти PySide6 в venv
|
||||
for venv in venv_candidates:
|
||||
if venv.exists():
|
||||
# Ищем Python версию
|
||||
lib_path = venv / "lib"
|
||||
if lib_path.exists():
|
||||
for python_dir in lib_path.iterdir():
|
||||
if python_dir.name.startswith('python'):
|
||||
candidate = python_dir / "site-packages" / "PySide6"
|
||||
if candidate.exists():
|
||||
pyside6_path = candidate
|
||||
print(f"Найден PySide6 в: {candidate}")
|
||||
break
|
||||
if pyside6_path:
|
||||
break
|
||||
|
||||
if not pyside6_path:
|
||||
print(f"Предупреждение: PySide6 не найден в venv, проверяем AppDir...")
|
||||
# Если не нашли в venv, пробуем в AppDir
|
||||
if base_path:
|
||||
appdir_candidate = base_path / "AppDir/usr/local/lib"
|
||||
if appdir_candidate.exists():
|
||||
for python_dir in appdir_candidate.iterdir():
|
||||
if python_dir.name.startswith('python'):
|
||||
candidate = python_dir / "dist-packages" / "PySide6"
|
||||
if candidate.exists():
|
||||
pyside6_path = candidate
|
||||
print(f"Найден PySide6 в AppDir: {candidate}")
|
||||
break
|
||||
|
||||
if not pyside6_path:
|
||||
return libs
|
||||
|
||||
# Ищем .so файлы модулей
|
||||
for so_file in pyside6_path.glob("Qt*.*.so"):
|
||||
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
|
||||
if module_name.startswith('Qt'):
|
||||
libs[module_name] = so_file
|
||||
|
||||
# Также ищем в подпапках
|
||||
for subdir in pyside6_path.iterdir():
|
||||
if subdir.is_dir() and subdir.name.startswith('Qt'):
|
||||
for so_file in subdir.glob("*.so*"):
|
||||
if 'Qt' in so_file.name:
|
||||
libs[subdir.name] = so_file
|
||||
break
|
||||
|
||||
return libs
|
||||
|
||||
def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]:
|
||||
"""Анализирует зависимости библиотеки с помощью ldd"""
|
||||
qt_deps = set()
|
||||
|
||||
try:
|
||||
result = subprocess.run(['ldd', str(lib_path)],
|
||||
capture_output=True, text=True, check=True)
|
||||
|
||||
# Парсим вывод ldd и ищем Qt библиотеки
|
||||
for line in result.stdout.split('\n'):
|
||||
# Ищем строки вида: libQt6Core.so.6 => /path/to/lib
|
||||
match = re.search(r'libQt6(\w+)\.so', line)
|
||||
if match:
|
||||
qt_module = f"Qt{match.group(1)}"
|
||||
qt_deps.add(qt_module)
|
||||
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}")
|
||||
|
||||
return qt_deps
|
||||
|
||||
def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]:
|
||||
"""Строит граф зависимостей на основе ldd анализа"""
|
||||
dependencies = {}
|
||||
|
||||
print("Анализ реальных зависимостей с помощью ldd...")
|
||||
for module, lib_path in pyside_libs.items():
|
||||
print(f" Анализируется {module}...")
|
||||
deps = self.analyze_ldd_dependencies(lib_path)
|
||||
dependencies[module] = deps
|
||||
|
||||
if deps:
|
||||
print(f" Зависимости: {', '.join(sorted(deps))}")
|
||||
|
||||
return dependencies
|
||||
|
||||
def analyze_file_imports(self, file_path: Path) -> Set[str]:
|
||||
"""Анализирует один Python файл и возвращает используемые PySide6 модули"""
|
||||
modules = set()
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
tree = ast.parse(content)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith('PySide6.'):
|
||||
module = alias.name.split('.', 2)[1]
|
||||
if module.startswith('Qt'):
|
||||
modules.add(module)
|
||||
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module and node.module.startswith('PySide6.'):
|
||||
module = node.module.split('.', 2)[1]
|
||||
if module.startswith('Qt'):
|
||||
modules.add(module)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при анализе {file_path}: {e}")
|
||||
|
||||
return modules
|
||||
|
||||
def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]:
|
||||
"""Получает все зависимости для набора модулей, используя граф зависимостей из ldd"""
|
||||
all_deps = set(modules)
|
||||
|
||||
if not dependency_graph:
|
||||
return all_deps
|
||||
|
||||
# Повторяем до тех пор, пока не найдем все транзитивные зависимости
|
||||
changed = True
|
||||
iteration = 0
|
||||
while changed and iteration < 10: # Защита от бесконечного цикла
|
||||
changed = False
|
||||
current_deps = set(all_deps)
|
||||
|
||||
for module in current_deps:
|
||||
if module in dependency_graph:
|
||||
new_deps = dependency_graph[module] - all_deps
|
||||
if new_deps:
|
||||
all_deps.update(new_deps)
|
||||
changed = True
|
||||
|
||||
iteration += 1
|
||||
|
||||
return all_deps
|
||||
|
||||
def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict:
|
||||
"""Анализирует весь проект"""
|
||||
python_files = self.find_python_files(project_path)
|
||||
print(f"Найдено {len(python_files)} Python файлов")
|
||||
|
||||
# Анализ статических импортов
|
||||
used_modules_code = set()
|
||||
file_modules = {}
|
||||
|
||||
for file_path in python_files:
|
||||
modules = self.analyze_file_imports(file_path)
|
||||
if modules:
|
||||
file_modules[str(file_path.relative_to(project_path))] = list(modules)
|
||||
used_modules_code.update(modules)
|
||||
|
||||
print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}")
|
||||
|
||||
# Поиск PySide6 библиотек
|
||||
search_base = appdir_path if appdir_path else project_path
|
||||
pyside_libs = self.find_pyside6_libs(search_base)
|
||||
|
||||
if not pyside_libs:
|
||||
print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.")
|
||||
return {
|
||||
'error': 'PySide6 библиотеки не найдены',
|
||||
'analysis_method': 'failed',
|
||||
'found_libraries': 0,
|
||||
'directly_used_code': sorted(used_modules_code),
|
||||
'all_required': [],
|
||||
'removable': [],
|
||||
'available_modules': [],
|
||||
'file_usage': file_modules
|
||||
}
|
||||
|
||||
print(f"Найдено {len(pyside_libs)} PySide6 библиотек")
|
||||
|
||||
# Анализ реальных зависимостей с ldd
|
||||
real_dependencies = self.build_real_dependency_graph(pyside_libs)
|
||||
|
||||
# Определяем модули, которые реально используются через ldd
|
||||
used_modules_ldd = set()
|
||||
for module in used_modules_code:
|
||||
if module in real_dependencies:
|
||||
used_modules_ldd.update(real_dependencies[module])
|
||||
used_modules_ldd.add(module)
|
||||
|
||||
print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}")
|
||||
|
||||
# Объединяем результаты анализа кода и ldd
|
||||
all_used_modules = used_modules_code | used_modules_ldd
|
||||
|
||||
# Получаем все необходимые модули включая зависимости
|
||||
all_required = self.get_all_dependencies(all_used_modules, real_dependencies)
|
||||
|
||||
# Все доступные PySide6 модули
|
||||
available_modules = set(pyside_libs.keys())
|
||||
|
||||
# Модули, которые можно удалить
|
||||
removable = available_modules - all_required
|
||||
|
||||
return {
|
||||
'analysis_method': 'ldd + static analysis',
|
||||
'found_libraries': len(pyside_libs),
|
||||
'directly_used_code': sorted(used_modules_code),
|
||||
'directly_used_ldd': sorted(used_modules_ldd),
|
||||
'all_required': sorted(all_required),
|
||||
'removable': sorted(removable),
|
||||
'available_modules': sorted(available_modules),
|
||||
'file_usage': file_modules,
|
||||
'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()},
|
||||
'library_paths': {k: str(v) for k, v in pyside_libs.items()},
|
||||
'analysis_summary': {
|
||||
'total_modules': len(available_modules),
|
||||
'required_modules': len(all_required),
|
||||
'removable_modules': len(removable),
|
||||
'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%"
|
||||
}
|
||||
}
|
||||
|
||||
def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str:
|
||||
"""Генерирует обновленный AppImage рецепт с командами очистки"""
|
||||
|
||||
# Читаем существующий рецепт
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
recipe_content = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Шаблон рецепта не найден: {template_path}")
|
||||
return ""
|
||||
|
||||
# Генерируем новые команды очистки
|
||||
cleanup_lines = []
|
||||
|
||||
# QML удаляем только если не используется
|
||||
qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'}
|
||||
if qml_modules.issubset(set(removable_modules)):
|
||||
cleanup_lines.append(" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/")
|
||||
|
||||
# Инструменты разработки (всегда удаляем)
|
||||
cleanup_lines.append(" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}")
|
||||
|
||||
# Модули для удаления
|
||||
if removable_modules:
|
||||
removable_filtered = [m for m in removable_modules if m not in self.critical_modules]
|
||||
if removable_filtered:
|
||||
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_filtered)])
|
||||
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
|
||||
|
||||
# Генерируем команду для удаления нативных библиотек с сохранением нужных
|
||||
required_libs = set()
|
||||
for module in sorted(set(self.all_required_modules)):
|
||||
required_libs.add(f"libQt6{module.replace('Qt', '')}*")
|
||||
|
||||
# Добавляем системные библиотеки
|
||||
for lib in self.system_libs:
|
||||
required_libs.add(f"{lib}*")
|
||||
|
||||
keep_pattern = '|'.join(sorted(required_libs))
|
||||
|
||||
cleanup_lines.extend([
|
||||
" - shopt -s extglob",
|
||||
f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
|
||||
])
|
||||
|
||||
import re
|
||||
|
||||
# Ищем весь блок команд очистки PySide6 (от первой rm до AppDir:)
|
||||
# Паттерн: после " - cp -r lib AppDir/usr\n" идут команды rm, а затем "AppDir:"
|
||||
pattern = r'( - cp -r lib AppDir/usr\n)((?: - (?:rm|shopt).*\n)*?)(?=AppDir:)'
|
||||
|
||||
match = re.search(pattern, recipe_content)
|
||||
|
||||
if not match:
|
||||
print("ПРЕДУПРЕЖДЕНИЕ: Не удалось найти блок очистки в рецепте")
|
||||
print("Добавляем команды очистки перед блоком AppDir:")
|
||||
|
||||
# Просто вставим команды перед AppDir:
|
||||
appdir_pos = recipe_content.find('AppDir:')
|
||||
if appdir_pos != -1:
|
||||
new_content = (
|
||||
recipe_content[:appdir_pos] +
|
||||
'\n'.join(cleanup_lines) + '\n' +
|
||||
recipe_content[appdir_pos:]
|
||||
)
|
||||
return new_content
|
||||
else:
|
||||
print("ОШИБКА: Не найден блок AppDir: в рецепте")
|
||||
return ""
|
||||
|
||||
# Создаем замену - группа 1 (cp -r lib) + новые команды очистки
|
||||
replacement = r'\1' + '\n'.join(cleanup_lines) + '\n'
|
||||
|
||||
updated_recipe = re.sub(pattern, replacement, recipe_content, count=1)
|
||||
|
||||
return updated_recipe
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
|
||||
parser.add_argument('project_path', nargs='?', default='.',
|
||||
help='Путь к проекту для анализа (по умолчанию: текущая директория)')
|
||||
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
|
||||
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
|
||||
parser.add_argument('--venv', help='Путь к виртуальному окружению (по умолчанию: .venv в корне проекта)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
project_path = Path(args.project_path).resolve()
|
||||
if not project_path.exists():
|
||||
print(f"Ошибка: путь {project_path} не существует")
|
||||
sys.exit(1)
|
||||
|
||||
appdir_path = Path(args.appdir).resolve() if args.appdir else None
|
||||
if appdir_path and not appdir_path.exists():
|
||||
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
|
||||
appdir_path = None
|
||||
|
||||
# Определяем корень проекта
|
||||
# Если запущен из подпапки проекта, ищем корень
|
||||
project_root = project_path
|
||||
if (project_path / ".git").exists() or (project_path / "pyproject.toml").exists():
|
||||
project_root = project_path
|
||||
else:
|
||||
# Пытаемся найти корень проекта
|
||||
current = project_path
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists() or (current / "pyproject.toml").exists():
|
||||
project_root = current
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
print(f"Корень проекта: {project_root}")
|
||||
|
||||
analyzer = PySide6DependencyAnalyzer(project_root=project_root)
|
||||
|
||||
# Если указан custom venv путь
|
||||
if args.venv:
|
||||
analyzer.venv_path = Path(args.venv).resolve()
|
||||
print(f"Использую указанный venv: {analyzer.venv_path}")
|
||||
|
||||
results = analyzer.analyze_project(project_path, appdir_path)
|
||||
|
||||
# Сохраняем в анализатор для генерации команд
|
||||
analyzer.all_required_modules = set(results.get('all_required', []))
|
||||
|
||||
# Выводим результаты
|
||||
print("\n" + "="*60)
|
||||
print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)")
|
||||
print("="*60)
|
||||
|
||||
if 'error' in results:
|
||||
print(f"\nОШИБКА: {results['error']}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nМетод анализа: {results['analysis_method']}")
|
||||
print(f"Найдено библиотек: {results['found_libraries']}")
|
||||
|
||||
if results['directly_used_code']:
|
||||
print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):")
|
||||
for module in results['directly_used_code']:
|
||||
print(f" • {module}")
|
||||
|
||||
if results['directly_used_ldd']:
|
||||
print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):")
|
||||
for module in results['directly_used_ldd']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nВсе необходимые модули ({len(results['all_required'])}):")
|
||||
for module in results['all_required']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nМодули, которые можно удалить ({len(results['removable'])}):")
|
||||
for module in results['removable']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
|
||||
|
||||
if args.verbose and results['real_dependencies']:
|
||||
print(f"\nРеальные зависимости (ldd):")
|
||||
for module, deps in results['real_dependencies'].items():
|
||||
if deps:
|
||||
print(f" {module} → {', '.join(deps)}")
|
||||
|
||||
# Обновляем AppImage рецепт
|
||||
recipe_path = analyzer.build_path / "AppImageBuilder.yml"
|
||||
if recipe_path.exists():
|
||||
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
|
||||
if updated_recipe:
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_recipe)
|
||||
print(f"\nAppImage рецепт обновлен: {recipe_path}")
|
||||
else:
|
||||
print(f"\nОШИБКА: не удалось обновить рецепт")
|
||||
else:
|
||||
print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}")
|
||||
|
||||
# Сохраняем результаты в JSON
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
print(f"Результаты сохранены в: {args.output}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,7 +3,10 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ast
|
||||
|
||||
# Import the security checker from the main module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent)) # Add project root to path
|
||||
from portprotonqt.theme_security import ThemeSecurityChecker
|
||||
|
||||
# Запрещенные QSS-свойства
|
||||
FORBIDDEN_PROPERTIES = {
|
||||
@@ -13,57 +16,29 @@ 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:
|
||||
# Check for forbidden QSS properties first
|
||||
with open(qss_file, "r", encoding='utf-8') 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}' 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}")
|
||||
for prop in FORBIDDEN_PROPERTIES:
|
||||
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
|
||||
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
|
||||
has_errors = True
|
||||
|
||||
# Use the imported ThemeSecurityChecker to check for dangerous imports and functions
|
||||
checker = ThemeSecurityChecker()
|
||||
is_safe, errors = checker.check_theme_safety(str(qss_file))
|
||||
|
||||
if not is_safe:
|
||||
for error in errors:
|
||||
print(error)
|
||||
has_errors = True
|
||||
|
||||
return has_errors
|
||||
|
||||
if __name__ == "__main__":
|
||||
if check_qss_files():
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
@@ -31,17 +31,49 @@ mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
|
||||
|
||||
## 🎨 Style File (`styles.py`)
|
||||
|
||||
Create a `styles.py` in the theme root. It should define variables or functions that return CSS.
|
||||
Create a `styles.py` in the theme root. It should define variables or functions that return QSS (Qt Style Sheets). For better organization, you can split your theme into multiple submodules by creating a subdirectory (e.g., `styles`, `components`, etc.) with separate Python files for different components, and import them in `styles.py`.
|
||||
|
||||
**Example:**
|
||||
**Example of modular structure:**
|
||||
```
|
||||
my_custom_theme/
|
||||
├── styles.py
|
||||
├── metainfo.ini
|
||||
├── fonts/
|
||||
├── images/
|
||||
└── styles/ # This can be named anything (e.g., components, modules, etc.)
|
||||
├── __init__.py # This empty file makes the directory a Python package
|
||||
├── constants.py
|
||||
├── base.py
|
||||
├── game_card.py
|
||||
├── detail_page.py
|
||||
├── settings.py
|
||||
├── winetricks.py
|
||||
└── theme_utils.py
|
||||
```
|
||||
|
||||
**Main styles.py file:**
|
||||
```python
|
||||
def custom_button_style(color1, color2):
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {color1}, stop:1 {color2});
|
||||
}}
|
||||
"""
|
||||
# Import from the theme's submodules using absolute paths relative to the package
|
||||
# Replace 'my_custom_theme' with your actual theme folder name and 'styles' with your subdirectory name
|
||||
from portprotonqt.themes.my_custom_theme.styles.constants import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.base import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.game_card import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.detail_page import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.settings import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.winetricks import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.theme_utils import *
|
||||
```
|
||||
|
||||
**Example submodule (styles/constants.py):**
|
||||
```python
|
||||
# Theme constants
|
||||
font_family = "Play"
|
||||
font_size_a = "16px"
|
||||
font_size_b = "24px"
|
||||
border_radius_a = "10px"
|
||||
color_a = "#409EFF"
|
||||
color_b = "#282a33"
|
||||
# ... other constants
|
||||
```
|
||||
|
||||
---
|
||||
@@ -207,18 +239,52 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
```ini
|
||||
[Metainfo]
|
||||
name = My Custom Theme
|
||||
name_en = My Custom Theme
|
||||
name_ru = Моя пользовательская тема
|
||||
author = Your Name
|
||||
author_link = https://example.com
|
||||
description = Description of your theme.
|
||||
description_en = Description of your theme.
|
||||
description_ru = Описание вашей темы.
|
||||
```
|
||||
|
||||
### Translation Support
|
||||
|
||||
You must provide translations for your theme's name and description by adding language-specific fields:
|
||||
- `name_en`, `name_ru`, etc. for theme names
|
||||
- `description_en`, `description_ru`, etc. for theme descriptions
|
||||
|
||||
The application will automatically select the appropriate translation based on the user's system language, falling back to English if translations are not available for the user's language.
|
||||
|
||||
---
|
||||
|
||||
## 🖼 Screenshots
|
||||
|
||||
Folder: `images/screenshots/` — place UI screenshots there.
|
||||
|
||||
### Screenshot Translation Support
|
||||
|
||||
You can provide translations for screenshot captions by adding entries to the `[Screenshots]` section in your `metainfo.ini` file:
|
||||
|
||||
```ini
|
||||
[Screenshots]
|
||||
auto_installs_en = Auto-installs
|
||||
auto_installs_ru = Автоустановки
|
||||
library_en = Library
|
||||
library_ru = Библиотека
|
||||
game_card_en = Game Card
|
||||
game_card_ru = Карточка
|
||||
context_menu_en = Context Menu
|
||||
context_menu_ru = Контекстное меню
|
||||
portproton_settings_en = PortProton Settings
|
||||
portproton_settings_ru = Настройки PortProton
|
||||
wine_settings_en = Wine Settings
|
||||
wine_settings_ru = Настройки Wine
|
||||
themes_en = Themes
|
||||
themes_ru = Темы
|
||||
```
|
||||
|
||||
Screenshot files should be named in English (without spaces), and the application will display the appropriate translated caption based on the user's system language, falling back to English if translations are not available.
|
||||
|
||||
---
|
||||
|
||||
## 🔡 Fonts and Icons (optional)
|
||||
|
||||
@@ -31,17 +31,49 @@ mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
|
||||
|
||||
## 🎨 Файл стилей (`styles.py`)
|
||||
|
||||
Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие CSS-оформление.
|
||||
Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие QSS-оформление (Qt Style Sheets). Для лучшей организации кода, вы можете разделить тему на несколько подмодулей, создав поддиректорию (например, `styles`, `components` и т.д.) с отдельными Python-файлами для разных компонентов, и импортировать их в `styles.py`.
|
||||
|
||||
**Пример функции:**
|
||||
**Пример модульной структуры:**
|
||||
```
|
||||
my_custom_theme/
|
||||
├── styles.py
|
||||
├── metainfo.ini
|
||||
├── fonts/
|
||||
├── images/
|
||||
└── styles/ # Это может быть названо как угодно (например, components, modules и т.д.)
|
||||
├── __init__.py # Этот пустой файл делает директорию Python-пакетом
|
||||
├── constants.py
|
||||
├── base.py
|
||||
├── game_card.py
|
||||
├── detail_page.py
|
||||
├── settings.py
|
||||
├── winetricks.py
|
||||
└── theme_utils.py
|
||||
```
|
||||
|
||||
**Основной файл styles.py:**
|
||||
```python
|
||||
def custom_button_style(color1, color2):
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {color1}, stop:1 {color2});
|
||||
}}
|
||||
"""
|
||||
# Импорт из подмодулей темы с использованием абсолютных путей относительно пакета
|
||||
# Замените 'my_custom_theme' на фактическое имя папки вашей темы и 'styles' на имя вашей поддиректории
|
||||
from portprotonqt.themes.my_custom_theme.styles.constants import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.base import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.game_card import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.detail_page import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.settings import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.winetricks import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.theme_utils import *
|
||||
```
|
||||
|
||||
**Пример подмодуля (styles/constants.py):**
|
||||
```python
|
||||
# Константы темы
|
||||
font_family = "Play"
|
||||
font_size_a = "16px"
|
||||
font_size_b = "24px"
|
||||
border_radius_a = "10px"
|
||||
color_a = "#409EFF"
|
||||
color_b = "#282a33"
|
||||
# ... другие константы
|
||||
```
|
||||
|
||||
---
|
||||
@@ -207,18 +239,52 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
```ini
|
||||
[Metainfo]
|
||||
name = My Custom Theme
|
||||
name_en = My Custom Theme
|
||||
name_ru = Моя пользовательская тема
|
||||
author = Ваше имя
|
||||
author_link = https://example.com
|
||||
description = Описание вашей темы.
|
||||
description_en = Description of your theme.
|
||||
description_ru = Описание вашей темы.
|
||||
```
|
||||
|
||||
### Поддержка переводов
|
||||
|
||||
Вы должны предоставить переводы для названия и описания вашей темы, добавив поля с указанием языка:
|
||||
- `name_en`, `name_ru` и т.д. для названий тем
|
||||
- `description_en`, `description_ru` и т.д. для описаний тем
|
||||
|
||||
Приложение автоматически выберет соответствующий перевод на основе языка системы пользователя, с откатом к английскому языку, если переводы недоступны для языка пользователя.
|
||||
|
||||
---
|
||||
|
||||
## 🖼 Скриншоты
|
||||
|
||||
Папка: `images/screenshots/` — любые изображения оформления темы.
|
||||
|
||||
### Поддержка перевода скриншотов
|
||||
|
||||
Вы можете предоставить переводы для подписей к скриншотам, добавив записи в секцию `[Screenshots]` в файле `metainfo.ini`:
|
||||
|
||||
```ini
|
||||
[Screenshots]
|
||||
auto_installs_en = Auto-installs
|
||||
auto_installs_ru = Автоустановки
|
||||
library_en = Library
|
||||
library_ru = Библиотека
|
||||
game_card_en = Game Card
|
||||
game_card_ru = Карточка
|
||||
context_menu_en = Context Menu
|
||||
context_menu_ru = Контекстное меню
|
||||
portproton_settings_en = PortProton Settings
|
||||
portproton_settings_ru = Настройки PortProton
|
||||
wine_settings_en = Wine Settings
|
||||
wine_settings_ru = Настройки Wine
|
||||
themes_en = Themes
|
||||
themes_ru = Темы
|
||||
```
|
||||
|
||||
Файлы скриншотов должны быть названы на английском языке (без пробелов), и приложение будет отображать соответствующую переведенную подпись в зависимости от языка системы пользователя, с откатом к английскому языку, если переводы недоступны.
|
||||
|
||||
---
|
||||
|
||||
## 🔡 Шрифты и иконки (опционально)
|
||||
|
||||
@@ -3,6 +3,7 @@ import configparser
|
||||
import shutil
|
||||
import subprocess
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.localization import get_theme_translations
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -228,13 +229,17 @@ def load_theme_metainfo(theme_name):
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
metainfo_file = os.path.join(theme_folder, "metainfo.ini")
|
||||
if os.path.exists(metainfo_file):
|
||||
# Load translated theme name and description
|
||||
theme_translations = get_theme_translations(metainfo_file)
|
||||
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(metainfo_file, encoding="utf-8")
|
||||
if "Metainfo" in cp:
|
||||
meta["author"] = cp.get("Metainfo", "author", fallback="Unknown")
|
||||
meta["author_link"] = cp.get("Metainfo", "author_link", fallback="")
|
||||
meta["description"] = cp.get("Metainfo", "description", fallback="")
|
||||
meta["name"] = cp.get("Metainfo", "name", fallback=theme_name)
|
||||
# Use translated name and description
|
||||
meta["name"] = theme_translations.get("name", theme_name)
|
||||
meta["description"] = theme_translations.get("description", "")
|
||||
break
|
||||
return meta
|
||||
|
||||
|
||||
@@ -458,9 +458,13 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
if downloaded_count == total_covers:
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
|
||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||
def on_steam_apps(steam_data: tuple[list | None, dict | None]):
|
||||
nonlocal steam_appid
|
||||
steam_apps, steam_apps_index = steam_data
|
||||
if not steam_apps or not steam_apps_index:
|
||||
logger.info(f"No Steam data available for EGS game {game_title}, skipping cover download")
|
||||
callback((True, f"Game '{game_title}' added to Steam"))
|
||||
return
|
||||
matching_app = search_app(game_title, steam_apps_index)
|
||||
steam_appid = matching_app.get("appid") if matching_app else None
|
||||
|
||||
@@ -903,10 +907,14 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
|
||||
|
||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||
def on_steam_apps(steam_data: tuple[list | None, dict | None]):
|
||||
steam_apps, steam_apps_index = steam_data
|
||||
matching_app = search_app(title, steam_apps_index)
|
||||
steam_appid = matching_app.get("appid") if matching_app else None
|
||||
if not steam_apps or not steam_apps_index:
|
||||
logger.info(f"No Steam data available for EGS game {title}, skipping appid lookup")
|
||||
steam_appid = None
|
||||
else:
|
||||
matching_app = search_app(title, steam_apps_index)
|
||||
steam_appid = matching_app.get("appid") if matching_app else None
|
||||
|
||||
def on_protondb_tier(protondb_tier: str):
|
||||
def on_description_fetched(api_description: str):
|
||||
|
||||
890
portprotonqt/get_wine_module.py
Normal file
@@ -0,0 +1,890 @@
|
||||
import os
|
||||
import requests
|
||||
import orjson
|
||||
import tarfile
|
||||
import shutil
|
||||
from PySide6.QtWidgets import (QDialog, QTabWidget, QTableWidget,
|
||||
QTableWidgetItem, QVBoxLayout, QWidget, QCheckBox,
|
||||
QPushButton, QHeaderView, QMessageBox,
|
||||
QLabel, QTextEdit, QHBoxLayout, QProgressBar,
|
||||
QFrame, QSizePolicy)
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QMutex, QWaitCondition, QTimer
|
||||
import urllib.parse
|
||||
from portprotonqt.config_utils import read_proxy_config
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.localization import _
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def get_requests_session():
|
||||
"""Create a requests session with proxy support"""
|
||||
session = requests.Session()
|
||||
proxy = read_proxy_config() or {}
|
||||
if proxy:
|
||||
session.proxies.update(proxy)
|
||||
session.verify = True
|
||||
return session
|
||||
|
||||
class DownloadThread(QThread):
|
||||
progress = Signal(int)
|
||||
finished = Signal(str, bool)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, download_url, filename):
|
||||
super().__init__()
|
||||
self.download_url = download_url
|
||||
self.filename = filename
|
||||
self._is_running = True
|
||||
self._mutex = QMutex()
|
||||
self._condition = QWaitCondition()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
session = get_requests_session()
|
||||
response = session.get(self.download_url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded_size = 0
|
||||
|
||||
with open(self.filename, 'wb') as file:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
self._mutex.lock()
|
||||
if not self._is_running:
|
||||
self._mutex.unlock()
|
||||
# Если загрузка отменена, удаляем частично скачанный файл
|
||||
if os.path.exists(self.filename):
|
||||
os.remove(self.filename)
|
||||
return
|
||||
self._mutex.unlock()
|
||||
|
||||
if chunk:
|
||||
file.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
if total_size > 0:
|
||||
progress = int((downloaded_size / total_size) * 100)
|
||||
self.progress.emit(progress)
|
||||
|
||||
self._mutex.lock()
|
||||
if self._is_running:
|
||||
self._mutex.unlock()
|
||||
self.finished.emit(self.filename, True)
|
||||
else:
|
||||
self._mutex.unlock()
|
||||
# Если загрузка отменена в последний момент, удаляем файл
|
||||
if os.path.exists(self.filename):
|
||||
os.remove(self.filename)
|
||||
|
||||
except Exception as e:
|
||||
self._mutex.lock()
|
||||
if self._is_running:
|
||||
self._mutex.unlock()
|
||||
# Удаляем частично скачанный файл при ошибке
|
||||
if os.path.exists(self.filename):
|
||||
try:
|
||||
os.remove(self.filename)
|
||||
except OSError:
|
||||
pass
|
||||
self.error.emit(str(e))
|
||||
else:
|
||||
self._mutex.unlock()
|
||||
|
||||
def stop(self):
|
||||
"""Безопасная остановка потока"""
|
||||
self._mutex.lock()
|
||||
self._is_running = False
|
||||
self._mutex.unlock()
|
||||
|
||||
if self.isRunning():
|
||||
self.quit()
|
||||
if not self.wait(1000): # Ждем до 1 секунды
|
||||
logger.warning("Thread did not stop gracefully, but continuing...")
|
||||
|
||||
|
||||
class ExtractionThread(QThread):
|
||||
progress = Signal(int)
|
||||
finished = Signal(str, bool) # filename, success
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, archive_path, extract_dir):
|
||||
super().__init__()
|
||||
self.archive_path = archive_path
|
||||
self.extract_dir = extract_dir
|
||||
self._is_running = True
|
||||
self._mutex = QMutex()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# Update progress to show extraction is starting
|
||||
self.progress.emit(0)
|
||||
|
||||
# Create dist directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(self.extract_dir), exist_ok=True)
|
||||
|
||||
# Remove existing directory if it exists
|
||||
if os.path.exists(self.extract_dir):
|
||||
shutil.rmtree(self.extract_dir)
|
||||
|
||||
# Extract the archive (only .tar.gz and .tar.xz are supported according to metadata)
|
||||
if self.archive_path.lower().endswith(('.tar.gz', '.tar.xz')):
|
||||
with tarfile.open(self.archive_path, 'r:*') as tar_ref:
|
||||
# Get total number of members for progress tracking (only once)
|
||||
members = tar_ref.getmembers()
|
||||
total_members = len(members)
|
||||
extracted_count = 0
|
||||
|
||||
# Extract to a temporary directory first, then move contents to final destination
|
||||
# to avoid nested directory structures
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Extract with progress tracking
|
||||
last_progress = -1 # Track last emitted progress to avoid too frequent updates
|
||||
for member in members:
|
||||
self._mutex.lock()
|
||||
if not self._is_running:
|
||||
self._mutex.unlock()
|
||||
return
|
||||
self._mutex.unlock()
|
||||
|
||||
tar_ref.extract(member, temp_dir)
|
||||
extracted_count += 1
|
||||
|
||||
# Update progress
|
||||
if total_members > 0:
|
||||
progress = int((extracted_count / total_members) * 100)
|
||||
# Only emit progress if it changed significantly (at least 1%) or at start/end
|
||||
if progress != last_progress:
|
||||
self.progress.emit(progress)
|
||||
last_progress = progress
|
||||
else:
|
||||
# If total_members is 0, emit a default progress to show activity
|
||||
if extracted_count == 1:
|
||||
self.progress.emit(50) # Show partial progress to indicate activity
|
||||
# Process events to ensure UI updates
|
||||
QThread.yieldCurrentThread() # Allow other threads to run
|
||||
|
||||
# Find the actual content directory (often the archive creates a subdirectory)
|
||||
extracted_dirs = os.listdir(temp_dir)
|
||||
if len(extracted_dirs) == 1:
|
||||
# If there's only one directory, move its contents directly to extract_dir
|
||||
content_dir = os.path.join(temp_dir, extracted_dirs[0])
|
||||
if os.path.isdir(content_dir):
|
||||
# Move all contents from content_dir to extract_dir
|
||||
items = os.listdir(content_dir)
|
||||
total_items = len(items)
|
||||
moved_items = 0
|
||||
last_progress = -1 # Track last emitted progress to avoid too frequent updates
|
||||
|
||||
for item in items:
|
||||
self._mutex.lock()
|
||||
if not self._is_running:
|
||||
self._mutex.unlock()
|
||||
return
|
||||
self._mutex.unlock()
|
||||
|
||||
source = os.path.join(content_dir, item)
|
||||
destination = os.path.join(self.extract_dir, item)
|
||||
|
||||
if os.path.isdir(source):
|
||||
shutil.copytree(source, destination)
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
moved_items += 1
|
||||
# Update progress during file copying (50% to 100% of extraction)
|
||||
progress = min(100, 50 + int((moved_items / total_items) * 50))
|
||||
# Only emit progress if it changed significantly (at least 1%)
|
||||
if progress != last_progress:
|
||||
self.progress.emit(progress)
|
||||
last_progress = progress
|
||||
# Process events to ensure UI updates
|
||||
QThread.yieldCurrentThread() # Allow other threads to run
|
||||
else:
|
||||
# If it's just files, move them directly
|
||||
items = os.listdir(temp_dir)
|
||||
total_items = len(items)
|
||||
moved_items = 0
|
||||
last_progress = -1 # Track last emitted progress to avoid too frequent updates
|
||||
|
||||
for item in items:
|
||||
self._mutex.lock()
|
||||
if not self._is_running:
|
||||
self._mutex.unlock()
|
||||
return
|
||||
self._mutex.unlock()
|
||||
|
||||
source = os.path.join(temp_dir, item)
|
||||
destination = os.path.join(self.extract_dir, item)
|
||||
|
||||
if os.path.isdir(source):
|
||||
shutil.copytree(source, destination)
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
moved_items += 1
|
||||
# Update progress during file copying (50% to 100% of extraction)
|
||||
progress = min(100, 50 + int((moved_items / total_items) * 50))
|
||||
# Only emit progress if it changed significantly (at least 1%)
|
||||
if progress != last_progress:
|
||||
self.progress.emit(progress)
|
||||
last_progress = progress
|
||||
# Process events to ensure UI updates
|
||||
QThread.yieldCurrentThread() # Allow other threads to run
|
||||
else:
|
||||
# If multiple top-level items, extract directly to target
|
||||
# This is a simpler case where we extract directly to the target
|
||||
tar_ref.extractall(self.extract_dir)
|
||||
# Set progress to 100% when extraction is complete
|
||||
self.progress.emit(100)
|
||||
QThread.yieldCurrentThread() # Allow other threads to run
|
||||
else:
|
||||
raise ValueError(f"Unsupported archive format: {self.archive_path}. Only .tar.gz and .tar.xz are supported.")
|
||||
|
||||
self._mutex.lock()
|
||||
if self._is_running:
|
||||
self._mutex.unlock()
|
||||
self.finished.emit(self.archive_path, True)
|
||||
else:
|
||||
self._mutex.unlock()
|
||||
|
||||
except Exception as e:
|
||||
self._mutex.lock()
|
||||
if self._is_running:
|
||||
self._mutex.unlock()
|
||||
self.error.emit(str(e))
|
||||
else:
|
||||
self._mutex.unlock()
|
||||
|
||||
def stop(self):
|
||||
"""Безопасная остановка потока"""
|
||||
self._mutex.lock()
|
||||
self._is_running = False
|
||||
self._mutex.unlock()
|
||||
|
||||
if self.isRunning():
|
||||
self.quit()
|
||||
if not self.wait(1000): # Ждем до 1 секунды
|
||||
logger.warning("Thread did not stop gracefully, but continuing...")
|
||||
|
||||
|
||||
class ProtonManager(QDialog):
|
||||
def __init__(self, parent=None, portproton_location=None):
|
||||
super().__init__(parent)
|
||||
self.selected_assets = {} # {unique_id: asset_data}
|
||||
self.current_download_thread = None
|
||||
self.current_extraction_thread = None
|
||||
self.is_downloading = False
|
||||
self.assets_to_download = []
|
||||
self.current_download_index = 0
|
||||
self.portproton_location = portproton_location
|
||||
self.initUI()
|
||||
self.load_proton_data_from_json()
|
||||
|
||||
def initUI(self):
|
||||
self.setWindowTitle(_('Proton | WINE Download Manager'))
|
||||
self.resize(800, 600)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
layout.setSpacing(5)
|
||||
|
||||
# Info label
|
||||
self.info_label = QLabel(_("Loading Proton versions from JSON metadata..."))
|
||||
self.info_label.setMaximumHeight(20)
|
||||
layout.addWidget(self.info_label)
|
||||
|
||||
# Tab widget - основной растягивающийся элемент
|
||||
self.tab_widget = QTabWidget()
|
||||
self.tab_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.addWidget(self.tab_widget, 1)
|
||||
|
||||
# Инфо-блок для показа выбранного (компактный для информации по выбранным закачкам)
|
||||
selection_widget = QWidget()
|
||||
selection_layout = QVBoxLayout(selection_widget)
|
||||
selection_layout.setContentsMargins(0, 2, 0, 2)
|
||||
selection_layout.setSpacing(2)
|
||||
|
||||
selection_label = QLabel(_("Selected assets:"))
|
||||
selection_label.setMaximumHeight(20)
|
||||
selection_layout.addWidget(selection_label)
|
||||
|
||||
self.selection_text = QTextEdit()
|
||||
self.selection_text.setMaximumHeight(80)
|
||||
self.selection_text.setReadOnly(True)
|
||||
self.selection_text.setPlainText(_("No assets selected"))
|
||||
selection_layout.addWidget(self.selection_text)
|
||||
|
||||
layout.addWidget(selection_widget)
|
||||
|
||||
# Область прогресса загрузки
|
||||
self.download_frame = QFrame()
|
||||
self.download_frame.setFrameStyle(QFrame.Shape.StyledPanel)
|
||||
self.download_frame.setVisible(False)
|
||||
self.download_frame.setMaximumHeight(80)
|
||||
|
||||
download_layout = QVBoxLayout(self.download_frame)
|
||||
download_layout.setContentsMargins(10, 5, 10, 5)
|
||||
download_layout.setSpacing(5)
|
||||
|
||||
self.download_info_label = QLabel(_("Downloading: "))
|
||||
download_layout.addWidget(self.download_info_label)
|
||||
|
||||
progress_layout = QHBoxLayout()
|
||||
self.download_progress = QProgressBar()
|
||||
self.download_progress.setMinimum(0)
|
||||
self.download_progress.setMaximum(100)
|
||||
self.cancel_btn = QPushButton(_('Cancel'))
|
||||
self.cancel_btn.clicked.connect(self.cancel_current_download)
|
||||
progress_layout.addWidget(self.download_progress, 4)
|
||||
progress_layout.addWidget(self.cancel_btn, 1)
|
||||
download_layout.addLayout(progress_layout)
|
||||
|
||||
layout.addWidget(self.download_frame)
|
||||
|
||||
# Кнопки управления
|
||||
button_layout = QHBoxLayout()
|
||||
self.download_btn = QPushButton(_('Download Selected'))
|
||||
self.download_btn.clicked.connect(self.download_selected)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.download_btn.setMinimumHeight(40)
|
||||
self.clear_btn = QPushButton(_('Clear All'))
|
||||
self.clear_btn.clicked.connect(self.clear_selection)
|
||||
self.clear_btn.setMinimumHeight(40)
|
||||
button_layout.addWidget(self.download_btn)
|
||||
button_layout.addWidget(self.clear_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def load_proton_data_from_json(self):
|
||||
"""Загружаем данные по Протонам из файла JSON"""
|
||||
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata.json"
|
||||
|
||||
try:
|
||||
logger.debug(f"Loading JSON metadata from: {json_url}")
|
||||
session = get_requests_session()
|
||||
response = session.get(json_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
metadata = orjson.loads(response.content)
|
||||
logger.info(f"Successfully loaded JSON metadata with {len(metadata)} entries")
|
||||
|
||||
successful_tabs = self.process_metadata(metadata)
|
||||
|
||||
if successful_tabs == 0:
|
||||
self.info_label.setText(_("Error: Could not process any data from JSON."))
|
||||
else:
|
||||
self.info_label.setText(f"Loaded {successful_tabs} Proton/WINE sources from JSON")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Network error loading JSON: {e}")
|
||||
self.info_label.setText(_("Error loading JSON: {0}").format(e))
|
||||
except orjson.JSONDecodeError as e:
|
||||
logger.error(f"JSON parsing error: {e}")
|
||||
self.info_label.setText(_("Error parsing JSON: {0}").format(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading metadata: {e}")
|
||||
self.info_label.setText(_("Error: {0}").format(e))
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
"""Обработка JSON, создание Табов"""
|
||||
successful_tabs = 0
|
||||
|
||||
# Собираем табы в словарь для сортировки
|
||||
tabs_dict = {}
|
||||
|
||||
for source_key, entries in metadata.items():
|
||||
# Пропускаем таб "gdk_proton" (вроде ненужный протон, скипаем)
|
||||
if source_key.lower() == 'gdk_proton':
|
||||
logger.debug(f"Skipping tab: {source_key}")
|
||||
continue
|
||||
|
||||
tabs_dict[source_key] = entries
|
||||
|
||||
# Proton_LG в самое начало кидаем
|
||||
if 'proton_lg' in tabs_dict:
|
||||
if self.create_tab_from_entries('proton_lg', tabs_dict['proton_lg']):
|
||||
successful_tabs += 1
|
||||
del tabs_dict['proton_lg']
|
||||
|
||||
# Остальные табы после Proton_LG
|
||||
for source_key, entries in tabs_dict.items():
|
||||
if self.create_tab_from_entries(source_key, entries):
|
||||
successful_tabs += 1
|
||||
|
||||
return successful_tabs
|
||||
|
||||
def create_tab_from_entries(self, source_name, entries):
|
||||
"""Создаем вкладку с таблицей для источника Proton из записей JSON"""
|
||||
|
||||
try:
|
||||
logger.debug(f"Processing {len(entries)} entries for source: {source_name}")
|
||||
|
||||
tab = QWidget()
|
||||
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(2)
|
||||
|
||||
table = QTableWidget()
|
||||
table.setColumnCount(2) # Только Checkbox и Имя
|
||||
table.setHorizontalHeaderLabels(['', 'Asset Name'])
|
||||
|
||||
header = table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
|
||||
table.setRowCount(len(entries))
|
||||
table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
table.cellClicked.connect(self.on_cell_clicked)
|
||||
|
||||
for row_index, entry in enumerate(entries):
|
||||
self.add_asset_row_from_json(table, row_index, entry, source_name)
|
||||
|
||||
layout.addWidget(table, 1)
|
||||
|
||||
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper() # Название для Таба в верхний регистр
|
||||
self.tab_widget.addTab(tab, tab_name)
|
||||
|
||||
logger.info(f"Successfully created tab for {source_name} with {len(entries)} assets")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating tab for {source_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_short_source_name(self, full_name):
|
||||
"""Получаем короткое имя для вкладки (Таба) из полного имени источника"""
|
||||
if full_name is None:
|
||||
return "UNKNOWN"
|
||||
|
||||
short_names = {
|
||||
'proton_lg': 'PROTON_LG',
|
||||
'proton_ge': 'PROTON_GE',
|
||||
'wine_kron4ek': 'WINE_KRON4EK',
|
||||
'wine_ge': 'WINE_GE',
|
||||
'proton_cachyos': 'PROTON_CACHYOS',
|
||||
'winepak': 'WINEPAK',
|
||||
'proton_sarek': 'PROTON_SAREK',
|
||||
'wine_staging': 'WINE_STAGING',
|
||||
'wine_valve': 'WINE_VALVE',
|
||||
'proton_valve': 'PROTON_VALVE',
|
||||
'proton_em': 'PROTON_EM'
|
||||
}
|
||||
|
||||
return short_names.get(full_name.lower(), full_name.upper())
|
||||
|
||||
def add_asset_row_from_json(self, table, row_index, entry, source_name):
|
||||
"""Добавляем строку для определенной позиции из JSON"""
|
||||
checkbox_widget = QWidget()
|
||||
checkbox_layout = QHBoxLayout(checkbox_widget)
|
||||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
checkbox = QCheckBox()
|
||||
|
||||
# Извлекаем имя файла из URL
|
||||
url = entry.get('url', '')
|
||||
filename = entry.get('name', '')
|
||||
|
||||
if url:
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
url_filename = os.path.basename(parsed_url.path)
|
||||
if url_filename:
|
||||
filename = url_filename
|
||||
|
||||
# Извлекаем версию для уникального ID
|
||||
version_from_name = self.extract_version_from_name(filename)
|
||||
|
||||
# Создаем структуру для позиции (элемента)
|
||||
asset_data = {
|
||||
'name': filename, # имя с расширением
|
||||
'browser_download_url': url,
|
||||
}
|
||||
|
||||
# Проверяем, установлен ли уже этот ассет
|
||||
is_installed = self.is_asset_installed(filename, source_name)
|
||||
|
||||
checkbox.stateChanged.connect(lambda state, a=asset_data, v=version_from_name,
|
||||
s=source_name:
|
||||
self.on_asset_toggled_json(state, a, v, s))
|
||||
checkbox_layout.addWidget(checkbox)
|
||||
|
||||
table.setCellWidget(row_index, 0, checkbox_widget)
|
||||
|
||||
# Имя элемента (без расширения для красивого отображения)
|
||||
# Remove .tar.xz and .tar.gz extensions completely
|
||||
display_name = filename
|
||||
if filename.lower().endswith('.tar.xz'):
|
||||
display_name = filename[:-7] # Remove '.tar.xz'
|
||||
elif filename.lower().endswith('.tar.gz'):
|
||||
display_name = filename[:-7] # Remove '.tar.gz'
|
||||
else:
|
||||
# Fallback to removing just the last extension if needed
|
||||
display_name = os.path.splitext(filename)[0]
|
||||
asset_name_item = QTableWidgetItem(display_name)
|
||||
|
||||
# Если ассет уже установлен, делаем его недоступным для выбора
|
||||
if is_installed:
|
||||
checkbox.setEnabled(False)
|
||||
asset_name_item.setFlags(asset_name_item.flags() & ~Qt.ItemFlag.ItemIsEnabled)
|
||||
# Add "(installed)" suffix to indicate it's already installed
|
||||
asset_name_item.setText(f"{display_name} (installed)")
|
||||
|
||||
table.setItem(row_index, 1, asset_name_item)
|
||||
|
||||
# Собираем метаданные в данных элемента
|
||||
unique_id = f"{source_name}_{version_from_name}_{filename}"
|
||||
for col in range(table.columnCount()):
|
||||
item = table.item(row_index, col)
|
||||
if item:
|
||||
item.setData(Qt.ItemDataRole.UserRole, {
|
||||
'asset': asset_data,
|
||||
'unique_id': unique_id,
|
||||
'json_entry': entry,
|
||||
'source_name': source_name,
|
||||
'version': version_from_name
|
||||
})
|
||||
|
||||
def extract_version_from_name(self, name):
|
||||
"""Получаем версию из имени элемента"""
|
||||
if not name:
|
||||
return "N/A"
|
||||
|
||||
# Убираем расширение файла
|
||||
basename = os.path.splitext(name)[0]
|
||||
basename = os.path.splitext(basename)[0] # Для двойных расширений .tar.gz
|
||||
|
||||
# Получаем версию по паттернам
|
||||
if 'GE-Proton' in basename:
|
||||
parts = basename.split('-')
|
||||
if len(parts) >= 2:
|
||||
return '-'.join(parts[:2])
|
||||
elif 'wine-' in basename.lower():
|
||||
parts = basename.split('-')
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
elif 'proton-' in basename.lower():
|
||||
parts = basename.split('-')
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
|
||||
# Общий случай для всего
|
||||
return basename.split('-')[0] if '-' in basename else basename
|
||||
|
||||
def is_asset_installed(self, asset_filename, source_name):
|
||||
"""Check if asset is already installed in PortProton data/dist"""
|
||||
if not self.portproton_location:
|
||||
return False
|
||||
|
||||
# Determine the directory name without extensions
|
||||
name_without_ext = asset_filename
|
||||
for ext in ['.tar.gz', '.tar.xz']:
|
||||
if name_without_ext.lower().endswith(ext):
|
||||
name_without_ext = name_without_ext[:-len(ext)]
|
||||
break
|
||||
|
||||
# Check if the corresponding directory exists in data/dist
|
||||
dist_path = os.path.join(self.portproton_location, "data", "dist")
|
||||
expected_dir = os.path.join(dist_path, name_without_ext)
|
||||
|
||||
return os.path.exists(expected_dir)
|
||||
|
||||
def on_cell_clicked(self, row):
|
||||
"""Обработка клика по ячейке - переключение флажка при клике по любой ячейке в строке"""
|
||||
tab = self.tab_widget.currentWidget()
|
||||
table = tab.findChild(QTableWidget)
|
||||
if table:
|
||||
checkbox_widget = table.cellWidget(row, 0)
|
||||
if checkbox_widget:
|
||||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
checkbox.setChecked(not checkbox.isChecked())
|
||||
|
||||
def on_asset_toggled_json(self, state, asset, version, source_name):
|
||||
"""Обработка выбора/отмены выбора элемента из данных JSON"""
|
||||
# Всегда извлекаем имя файла из URL
|
||||
url = asset.get('browser_download_url', '')
|
||||
filename = asset.get('name', '') # Исходное имя из JSON
|
||||
|
||||
# Получаем имя файла из URL (оно всегда с расширением)
|
||||
if url:
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
url_filename = os.path.basename(parsed_url.path)
|
||||
if url_filename: # Если удалось получить имя из URL
|
||||
filename = url_filename
|
||||
|
||||
unique_id = f"{source_name}_{version}_{filename}"
|
||||
|
||||
if state == Qt.CheckState.Checked.value:
|
||||
self.selected_assets[unique_id] = {
|
||||
'source_name': source_name,
|
||||
'version': version,
|
||||
'asset': asset,
|
||||
'asset_name': filename, # Используем имя файла с расширением
|
||||
'download_url': asset['browser_download_url']
|
||||
}
|
||||
else:
|
||||
if unique_id in self.selected_assets:
|
||||
del self.selected_assets[unique_id]
|
||||
|
||||
self.update_selection_display()
|
||||
|
||||
def update_selection_display(self):
|
||||
"""Обновляем отображение выбора"""
|
||||
if self.selected_assets:
|
||||
selection_text = f"Selected {len(self.selected_assets)} assets:\n"
|
||||
|
||||
for i, asset_data in enumerate(self.selected_assets.values(), 1):
|
||||
selection_text += f"{i}. {asset_data['source_name'].upper()} - {asset_data['asset_name']}\n"
|
||||
|
||||
self.selection_text.setPlainText(selection_text)
|
||||
self.download_btn.setEnabled(True)
|
||||
else:
|
||||
self.selection_text.setPlainText(_("No assets selected"))
|
||||
self.download_btn.setEnabled(False)
|
||||
|
||||
def clear_selection(self):
|
||||
"""Очищаем (сбрасываем) всё выбранное"""
|
||||
if self.is_downloading:
|
||||
QMessageBox.warning(self, _("Download in Progress"), _("Cannot clear selection while downloading."))
|
||||
return
|
||||
|
||||
self.selected_assets.clear()
|
||||
|
||||
for tab_index in range(self.tab_widget.count()):
|
||||
tab = self.tab_widget.widget(tab_index)
|
||||
table = tab.findChild(QTableWidget)
|
||||
if table:
|
||||
for row in range(table.rowCount()):
|
||||
checkbox_widget = table.cellWidget(row, 0)
|
||||
if checkbox_widget:
|
||||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
checkbox.setChecked(False)
|
||||
|
||||
self.update_selection_display()
|
||||
|
||||
def download_selected(self):
|
||||
"""Загружаем все выбранные элементы (протоны)"""
|
||||
if not self.selected_assets:
|
||||
QMessageBox.warning(self, _("No Selection"), _("Please select at least one asset to download."))
|
||||
return
|
||||
|
||||
if self.is_downloading:
|
||||
QMessageBox.warning(self, _("Download in Progress"), _("Please wait for current download to complete."))
|
||||
return
|
||||
|
||||
downloads_dir = "proton_downloads"
|
||||
if not os.path.exists(downloads_dir):
|
||||
os.makedirs(downloads_dir)
|
||||
|
||||
self.assets_to_download = list(self.selected_assets.values())
|
||||
self.current_download_index = 0
|
||||
self.is_downloading = True
|
||||
self.start_next_download()
|
||||
|
||||
def start_next_download(self):
|
||||
"""Запуск загрузки следующего элемента в списке"""
|
||||
if self.current_download_index >= len(self.assets_to_download):
|
||||
# Все загрузки завершены
|
||||
self.download_frame.setVisible(False)
|
||||
self.download_btn.setEnabled(True)
|
||||
self.clear_btn.setEnabled(True)
|
||||
self.is_downloading = False
|
||||
QMessageBox.information(self, _("Download Complete"), _("All selected assets have been downloaded!"))
|
||||
return
|
||||
|
||||
asset_data = self.assets_to_download[self.current_download_index]
|
||||
self.download_asset(asset_data)
|
||||
|
||||
def download_asset(self, asset_data):
|
||||
"""Загрузка конкретного элемента (протона)"""
|
||||
filename = os.path.join("proton_downloads", asset_data['asset_name'])
|
||||
|
||||
download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']}"
|
||||
if len(download_info) > 80:
|
||||
download_info = download_info[:77] + "..."
|
||||
self.download_info_label.setText(f"Downloading: {download_info}")
|
||||
self.download_progress.setValue(0)
|
||||
self.download_frame.setVisible(True)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.clear_btn.setEnabled(False)
|
||||
|
||||
if os.path.exists(filename):
|
||||
reply = QMessageBox.question(self, _("File Exists"),
|
||||
_("File {0} already exists. Overwrite?").format(asset_data['asset_name']),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
self.current_download_index += 1
|
||||
self.start_next_download()
|
||||
return
|
||||
|
||||
download_url = asset_data.get('download_url', '')
|
||||
if not download_url:
|
||||
QMessageBox.critical(self, _("Download Error"), _("No download URL for {0}").format(asset_data['asset_name']))
|
||||
self.current_download_index += 1
|
||||
self.start_next_download()
|
||||
return
|
||||
|
||||
# Проверка, что предыдущий поток завершен
|
||||
if self.current_download_thread and self.current_download_thread.isRunning():
|
||||
logger.warning("Previous thread still running, waiting...")
|
||||
self.current_download_thread.stop()
|
||||
|
||||
self.current_download_thread = DownloadThread(download_url, filename)
|
||||
|
||||
def update_progress(progress):
|
||||
self.download_progress.setValue(progress)
|
||||
|
||||
def download_finished(filename, success):
|
||||
logger.debug(f"Download finished callback: {filename}, success: {success}")
|
||||
if success:
|
||||
logger.info(f"Successfully downloaded: {filename}")
|
||||
|
||||
# Update progress bar to show extraction phase
|
||||
self.download_info_label.setText(_("Extracting: {0}").format(asset_data['asset_name']))
|
||||
|
||||
# Extract archive to PortProton data/dist directory using a separate thread
|
||||
if self.portproton_location:
|
||||
try:
|
||||
# Determine the name for the extraction directory from the asset name
|
||||
asset_name = asset_data['asset_name']
|
||||
|
||||
# Remove common archive extensions to get the directory name (only .tar.gz and .tar.xz)
|
||||
name_without_ext = asset_name
|
||||
for ext in ['.tar.gz', '.tar.xz']:
|
||||
if name_without_ext.lower().endswith(ext):
|
||||
name_without_ext = name_without_ext[:-len(ext)]
|
||||
break
|
||||
|
||||
# Create the destination directory in PortProton data/dist
|
||||
dist_path = os.path.join(self.portproton_location, "data", "dist")
|
||||
extract_dir = os.path.join(dist_path, name_without_ext)
|
||||
|
||||
# Create and start extraction thread
|
||||
self.current_extraction_thread = ExtractionThread(filename, extract_dir)
|
||||
|
||||
def update_extraction_progress(progress):
|
||||
self.download_progress.setValue(progress)
|
||||
# Update the info label to show current progress percentage during extraction
|
||||
self.download_info_label.setText(_("Extracting: {0} ({1}%)").format(asset_data['asset_name'], progress))
|
||||
|
||||
def extraction_finished(archive_path, success):
|
||||
if success:
|
||||
logger.info(f"Successfully extracted: {archive_path}")
|
||||
else:
|
||||
logger.error(f"Failed to extract: {archive_path}")
|
||||
QMessageBox.critical(self, _("Extraction Error"), _("Failed to extract archive: {0}").format(archive_path))
|
||||
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Waiting for extraction thread to finish...")
|
||||
if not self.current_extraction_thread.wait(500):
|
||||
logger.warning("Extraction thread still running, but continuing...")
|
||||
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
|
||||
def extraction_error(error_msg):
|
||||
logger.error(f"Extraction error: {error_msg}")
|
||||
QMessageBox.critical(self, "Extraction Error", f"Failed to extract archive: {error_msg}")
|
||||
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Waiting for extraction thread to finish after error...")
|
||||
if not self.current_extraction_thread.wait(500):
|
||||
logger.warning("Extraction thread still running after error, but continuing...")
|
||||
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
|
||||
self.current_extraction_thread.progress.connect(update_extraction_progress)
|
||||
self.current_extraction_thread.finished.connect(extraction_finished)
|
||||
self.current_extraction_thread.error.connect(extraction_error)
|
||||
self.current_extraction_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting extraction thread for {filename}: {e}")
|
||||
QMessageBox.critical(self, "Extraction Error", f"Failed to start extraction: {e}")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
else:
|
||||
logger.warning("PortProton location not provided, skipping extraction")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
else:
|
||||
QMessageBox.critical(self, _("Download Failed"),
|
||||
_("Failed to download:\n{0}").format(filename))
|
||||
|
||||
def download_error(error_msg):
|
||||
logger.error(f"Download error: {error_msg}")
|
||||
QMessageBox.critical(self, _("Download Error"), error_msg)
|
||||
|
||||
if self.current_download_thread and self.current_download_thread.isRunning():
|
||||
if not self.current_download_thread.wait(500):
|
||||
logger.warning("Thread still running after error, but continuing...")
|
||||
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
|
||||
self.current_download_thread.progress.connect(update_progress)
|
||||
self.current_download_thread.finished.connect(download_finished)
|
||||
self.current_download_thread.error.connect(download_error)
|
||||
self.current_download_thread.start()
|
||||
|
||||
def cancel_current_download(self):
|
||||
"""Отмена текущей загрузки"""
|
||||
if self.current_download_thread and self.current_download_thread.isRunning():
|
||||
self.current_download_thread.stop()
|
||||
if not self.current_download_thread.wait(1000):
|
||||
logger.warning("Thread did not stop gracefully")
|
||||
|
||||
# Очищаем список загрузок
|
||||
self.assets_to_download = []
|
||||
self.current_download_index = 0
|
||||
self.is_downloading = False
|
||||
|
||||
# Сброс/перезапуск UI
|
||||
self.download_frame.setVisible(False)
|
||||
self.download_btn.setEnabled(True)
|
||||
self.clear_btn.setEnabled(True)
|
||||
|
||||
QMessageBox.information(self, _("Download Cancelled"), _("Download has been cancelled."))
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Проверка, что все потоки останавливаются при закрытии приложения"""
|
||||
logger.debug("Closing ProtonManager dialog...")
|
||||
|
||||
# Stop download thread if running
|
||||
if self.is_downloading and self.current_download_thread and self.current_download_thread.isRunning():
|
||||
logger.debug("Stopping current download thread...")
|
||||
self.current_download_thread.stop()
|
||||
if not self.current_download_thread.wait(2000):
|
||||
logger.warning("Thread did not stop gracefully during close")
|
||||
|
||||
# Stop extraction thread if running
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Stopping current extraction thread...")
|
||||
self.current_extraction_thread.stop()
|
||||
if not self.current_extraction_thread.wait(2000):
|
||||
logger.warning("Extraction thread did not stop gracefully during close")
|
||||
|
||||
event.accept()
|
||||
|
||||
|
||||
|
||||
def show_proton_manager(parent=None, portproton_location=None):
|
||||
"""
|
||||
Shows the Proton/WINE manager dialog.
|
||||
|
||||
Args:
|
||||
parent: Parent widget for the dialog
|
||||
portproton_location: Location of PortProton installation
|
||||
|
||||
Returns:
|
||||
ProtonManager dialog instance
|
||||
"""
|
||||
dialog = ProtonManager(parent, portproton_location)
|
||||
dialog.exec() # Use exec() for modal dialog
|
||||
return dialog
|
||||
@@ -6,7 +6,7 @@ from typing import Protocol, cast, Any
|
||||
from evdev import InputDevice, InputEvent, UInput, ecodes, list_devices, ff
|
||||
from enum import Enum
|
||||
from pyudev import Context, Monitor, Device, Devices
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem, QSlider
|
||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
||||
from PySide6.QtGui import QKeyEvent, QMouseEvent
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -35,6 +35,8 @@ class MainWindowProtocol(Protocol):
|
||||
...
|
||||
def on_slider_released(self) -> None:
|
||||
...
|
||||
def on_auto_slider_released(self) -> None:
|
||||
...
|
||||
def isActiveWindow(self) -> bool:
|
||||
...
|
||||
def refreshGames(self) -> None:
|
||||
@@ -46,6 +48,8 @@ class MainWindowProtocol(Protocol):
|
||||
currentDetailPage: QWidget | None
|
||||
current_exec_line: str | None
|
||||
current_add_game_dialog: AddGameDialog | None
|
||||
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
|
||||
auto_size_slider: QSlider | None
|
||||
|
||||
# Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo Switch controllers
|
||||
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
|
||||
@@ -1411,20 +1415,38 @@ class InputManager(QObject):
|
||||
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
|
||||
self._parent.switchTab(idx)
|
||||
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
elif button_code in BUTTONS['increase_size'] and self._parent.stackedWidget.currentIndex() == 0:
|
||||
# Increase card size with RT (Xbox) / R2 (PS)
|
||||
size_slider = getattr(self._parent, 'sizeSlider', None)
|
||||
if size_slider:
|
||||
new_value = min(size_slider.value() + 10, size_slider.maximum())
|
||||
size_slider.setValue(new_value)
|
||||
self._parent.on_slider_released()
|
||||
elif button_code in BUTTONS['decrease_size'] and self._parent.stackedWidget.currentIndex() == 0:
|
||||
# Decrease card size with LT (Xbox) / L2 (PS)
|
||||
size_slider = getattr(self._parent, 'sizeSlider', None)
|
||||
if size_slider:
|
||||
new_value = max(size_slider.value() - 10, size_slider.minimum())
|
||||
size_slider.setValue(new_value)
|
||||
self._parent.on_slider_released()
|
||||
elif button_code in BUTTONS['increase_size']:
|
||||
current_tab = self._parent.stackedWidget.currentIndex()
|
||||
if current_tab == 0: # Main games library
|
||||
if hasattr(self._parent, 'game_library_manager') and self._parent.game_library_manager:
|
||||
size_slider = getattr(self._parent.game_library_manager, 'sizeSlider', None)
|
||||
if size_slider:
|
||||
new_value = min(size_slider.value() + 10, size_slider.maximum())
|
||||
size_slider.setValue(new_value)
|
||||
self._parent.on_slider_released()
|
||||
elif current_tab == 1: # Auto-install tab
|
||||
auto_size_slider = getattr(self._parent, 'auto_size_slider', None)
|
||||
if auto_size_slider:
|
||||
new_value = min(auto_size_slider.value() + 10, auto_size_slider.maximum())
|
||||
auto_size_slider.setValue(new_value)
|
||||
if hasattr(self._parent, 'on_auto_slider_released'):
|
||||
self._parent.on_auto_slider_released()
|
||||
elif button_code in BUTTONS['decrease_size']:
|
||||
current_tab = self._parent.stackedWidget.currentIndex()
|
||||
if current_tab == 0: # Main games library
|
||||
if hasattr(self._parent, 'game_library_manager') and self._parent.game_library_manager:
|
||||
size_slider = getattr(self._parent.game_library_manager, 'sizeSlider', None)
|
||||
if size_slider:
|
||||
new_value = max(size_slider.value() - 10, size_slider.minimum())
|
||||
size_slider.setValue(new_value)
|
||||
self._parent.on_slider_released()
|
||||
elif current_tab == 1: # Auto-install tab
|
||||
auto_size_slider = getattr(self._parent, 'auto_size_slider', None)
|
||||
if auto_size_slider:
|
||||
new_value = max(auto_size_slider.value() - 10, auto_size_slider.minimum())
|
||||
auto_size_slider.setValue(new_value)
|
||||
if hasattr(self._parent, 'on_auto_slider_released'):
|
||||
self._parent.on_auto_slider_released()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gettext
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
import locale
|
||||
import os
|
||||
@@ -102,3 +103,97 @@ def read_metadata_translations(metadata_file, language_code):
|
||||
translations['description'] = line[len('description='):].strip()
|
||||
|
||||
return translations
|
||||
|
||||
def get_screenshot_caption(base_filename, metainfo_file, language_code=None):
|
||||
"""
|
||||
Возвращает перевод названия скриншота на основе языка пользователя.
|
||||
|
||||
Args:
|
||||
base_filename: Имя файла без расширения
|
||||
metainfo_file: Путь к файлу metainfo.ini
|
||||
language_code: Код языка (если None, будет определен автоматически)
|
||||
|
||||
Returns:
|
||||
Переведенное название скриншота
|
||||
"""
|
||||
if language_code is None:
|
||||
system_locale = get_system_locale()
|
||||
language_code = system_locale.split('_')[0] if '_' in system_locale else system_locale
|
||||
|
||||
# Загружаем переводы из metainfo.ini
|
||||
screenshot_translations = {}
|
||||
if metainfo_file and os.path.exists(metainfo_file):
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(metainfo_file, encoding="utf-8")
|
||||
if "Screenshots" in cp:
|
||||
for key in cp.options("Screenshots"):
|
||||
screenshot_translations[key] = cp.get("Screenshots", key)
|
||||
|
||||
# Ищем перевод в формате: base_filename_languagecode
|
||||
caption = base_filename # По умолчанию используем базовое имя файла
|
||||
|
||||
if screenshot_translations:
|
||||
# Попробуем перевод для конкретного языка (например, "library_ru")
|
||||
lang_specific_key = f"{base_filename}_{language_code}"
|
||||
# Попробуем английский перевод (например, "library_en")
|
||||
english_key = f"{base_filename}_en"
|
||||
|
||||
if lang_specific_key in screenshot_translations:
|
||||
caption = screenshot_translations[lang_specific_key]
|
||||
elif english_key in screenshot_translations:
|
||||
caption = screenshot_translations[english_key]
|
||||
elif base_filename in screenshot_translations:
|
||||
caption = screenshot_translations[base_filename] # fallback to untranslated key
|
||||
|
||||
return caption
|
||||
|
||||
def get_theme_translations(metainfo_file, language_code=None):
|
||||
"""
|
||||
Возвращает переводы названия и описания темы на основе языка пользователя.
|
||||
|
||||
Args:
|
||||
metainfo_file: Путь к файлу metainfo.ini
|
||||
language_code: Код языка (если None, будет определен автоматически)
|
||||
|
||||
Returns:
|
||||
Словарь с полями 'name' и 'description' с переведенными значениями
|
||||
"""
|
||||
if language_code is None:
|
||||
system_locale = get_system_locale()
|
||||
language_code = system_locale.split('_')[0] if '_' in system_locale else system_locale
|
||||
|
||||
# Загружаем переводы из metainfo.ini
|
||||
translations = {'name': '', 'description': ''}
|
||||
|
||||
if metainfo_file and os.path.exists(metainfo_file):
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(metainfo_file, encoding="utf-8")
|
||||
|
||||
if "Metainfo" in cp:
|
||||
# Попробуем перевод названия для конкретного языка (например, "name_ru")
|
||||
lang_specific_name_key = f"name_{language_code}"
|
||||
# Попробуем английский перевод названия (например, "name_en")
|
||||
english_name_key = "name_en"
|
||||
|
||||
# Ищем перевод названия
|
||||
if cp.has_option("Metainfo", lang_specific_name_key):
|
||||
translations['name'] = cp.get("Metainfo", lang_specific_name_key)
|
||||
elif cp.has_option("Metainfo", english_name_key):
|
||||
translations['name'] = cp.get("Metainfo", english_name_key)
|
||||
elif cp.has_option("Metainfo", "name"):
|
||||
translations['name'] = cp.get("Metainfo", "name")
|
||||
|
||||
# Попробуем перевод описания для конкретного языка (например, "description_ru")
|
||||
lang_specific_desc_key = f"description_{language_code}"
|
||||
# Попробуем английский перевод описания (например, "description_en")
|
||||
english_desc_key = "description_en"
|
||||
|
||||
# Ищем перевод описания
|
||||
if cp.has_option("Metainfo", lang_specific_desc_key):
|
||||
translations['description'] = cp.get("Metainfo", lang_specific_desc_key)
|
||||
elif cp.has_option("Metainfo", english_desc_key):
|
||||
translations['description'] = cp.get("Metainfo", english_desc_key)
|
||||
elif cp.has_option("Metainfo", "description"):
|
||||
translations['description'] = cp.get("Metainfo", "description")
|
||||
|
||||
return translations
|
||||
|
||||
@@ -37,6 +37,7 @@ from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.tray_manager import TrayManager
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.get_wine_module import show_proton_manager
|
||||
|
||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
|
||||
QDialog, QFormLayout, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider)
|
||||
@@ -1724,6 +1725,7 @@ class MainWindow(QMainWindow):
|
||||
(_("Delete Compatibility Tool"), self.delete_compat_tool),
|
||||
(_("Delete Prefix"), self.delete_prefix),
|
||||
(_("Clear Prefix"), self.clear_prefix),
|
||||
(_("Download other WINE"), self.show_proton_manager),
|
||||
]
|
||||
|
||||
for i, (text, callback) in enumerate(additional_buttons):
|
||||
@@ -1844,6 +1846,10 @@ class MainWindow(QMainWindow):
|
||||
logger.error(f"Wine tool {cli_arg} error: {error}")
|
||||
QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
|
||||
|
||||
def show_proton_manager(self):
|
||||
"""Shows the Proton/WINE manager for downloading other WINE versions"""
|
||||
show_proton_manager(self, self.portproton_location)
|
||||
|
||||
def clear_prefix(self):
|
||||
"""Очищает префикс"""
|
||||
selected_prefix = self.prefixCombo.currentText()
|
||||
@@ -2488,8 +2494,8 @@ class MainWindow(QMainWindow):
|
||||
screenshots = load_theme_screenshots(theme_name)
|
||||
if screenshots:
|
||||
self.screenshotsCarousel.update_images([
|
||||
(pixmap, os.path.splitext(filename)[0])
|
||||
for pixmap, filename in screenshots
|
||||
(pixmap, caption)
|
||||
for pixmap, caption in screenshots
|
||||
])
|
||||
self.screenshotsCarousel.show()
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import functools
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
@@ -262,21 +261,58 @@ def remove_duplicates(candidates):
|
||||
"""
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
# Simple TTL cache for exiftool data with max entries to control memory usage
|
||||
_EXIFTOOL_CACHE = {}
|
||||
_CACHE_MAX_ENTRIES = 64 # Limit cache size to control memory
|
||||
_CACHE_TTL = 300 # 5 minutes TTL
|
||||
|
||||
def get_exiftool_data(game_exe):
|
||||
"""Retrieves metadata using exiftool."""
|
||||
"""Retrieves metadata using exiftool with TTL-based caching."""
|
||||
import time
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Clean up expired entries periodically
|
||||
if len(_EXIFTOOL_CACHE) > _CACHE_MAX_ENTRIES // 2: # Clean when half full
|
||||
# Remove expired entries
|
||||
expired_keys = [
|
||||
key for key, (data, timestamp) in _EXIFTOOL_CACHE.items()
|
||||
if current_time - timestamp > _CACHE_TTL
|
||||
]
|
||||
for key in expired_keys:
|
||||
del _EXIFTOOL_CACHE[key]
|
||||
|
||||
# Check cache first
|
||||
if game_exe in _EXIFTOOL_CACHE:
|
||||
data, timestamp = _EXIFTOOL_CACHE[game_exe]
|
||||
if current_time - timestamp <= _CACHE_TTL:
|
||||
return data
|
||||
else:
|
||||
# Entry expired, remove it
|
||||
del _EXIFTOOL_CACHE[game_exe]
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["exiftool", "-j", game_exe],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
check=False,
|
||||
timeout=10 # Add timeout to prevent hanging
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
|
||||
return {}
|
||||
meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
|
||||
return meta_data_list[0] if meta_data_list else {}
|
||||
result = meta_data_list[0] if meta_data_list else {}
|
||||
|
||||
# Add to cache if we have a reasonable result
|
||||
if result and len(_EXIFTOOL_CACHE) < _CACHE_MAX_ENTRIES:
|
||||
_EXIFTOOL_CACHE[game_exe] = (result, current_time)
|
||||
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"exiftool timed out for {game_exe}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
|
||||
return {}
|
||||
@@ -323,6 +359,17 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
logger.info("Deleted archive: %s", cache_tar)
|
||||
# Delete all cached app detail files (steam_app_*.json)
|
||||
delete_cached_app_files(cache_dir, "steam_app_*.json")
|
||||
|
||||
# Build the new index in the background and atomically update the cache
|
||||
new_index = build_index(data) if isinstance(data, list) else {}
|
||||
current_time = time.time()
|
||||
|
||||
# Atomically update the cache
|
||||
with _STEAM_APPS_LOCK:
|
||||
_STEAM_APPS_CACHE['data'] = data if isinstance(data, list) else []
|
||||
_STEAM_APPS_CACHE['index'] = new_index
|
||||
_STEAM_APPS_CACHE['timestamp'] = current_time
|
||||
|
||||
steam_apps = data if isinstance(data, list) else []
|
||||
logger.info("Loaded %d apps from archive", len(steam_apps))
|
||||
callback(steam_apps)
|
||||
@@ -373,25 +420,31 @@ def build_index(steam_apps):
|
||||
return steam_apps_index
|
||||
logger.info("Building Steam apps index")
|
||||
for app in steam_apps:
|
||||
normalized = app["normalized_name"]
|
||||
steam_apps_index[normalized] = app
|
||||
normalized = app.get("normalized_name", "")
|
||||
if normalized: # Only add if normalized_name exists
|
||||
steam_apps_index[normalized] = app
|
||||
return steam_apps_index
|
||||
|
||||
def search_app(candidate, steam_apps_index):
|
||||
"""
|
||||
Searches for an application by candidate: tries exact match first, then substring match.
|
||||
Searches for an application by candidate: tries exact match first, then partial match.
|
||||
"""
|
||||
candidate_norm = normalize_name(candidate)
|
||||
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||
|
||||
# Exact match first (O(1) lookup)
|
||||
if candidate_norm in steam_apps_index:
|
||||
logger.info("Found exact match: '%s'", candidate_norm)
|
||||
return steam_apps_index[candidate_norm]
|
||||
|
||||
# If no exact match, try partial matching
|
||||
for name_norm, app in steam_apps_index.items():
|
||||
if candidate_norm in name_norm:
|
||||
ratio = len(candidate_norm) / len(name_norm)
|
||||
if ratio > 0.8:
|
||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
|
||||
return app
|
||||
|
||||
logger.info("No app found for candidate '%s'", candidate_norm)
|
||||
return None
|
||||
|
||||
@@ -531,6 +584,16 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
||||
if os.path.exists(cache_tar):
|
||||
os.remove(cache_tar)
|
||||
logger.info("Deleted archive: %s", cache_tar)
|
||||
# Build the new index in the background and atomically update the cache
|
||||
new_index = build_weanticheatyet_index(data) if isinstance(data, list) else {}
|
||||
current_time = time.time()
|
||||
|
||||
# Atomically update the cache
|
||||
with _ANTICHEAT_LOCK:
|
||||
_ANTICHEAT_CACHE['data'] = data if isinstance(data, list) else []
|
||||
_ANTICHEAT_CACHE['index'] = new_index
|
||||
_ANTICHEAT_CACHE['timestamp'] = current_time
|
||||
|
||||
anti_cheat_data = data or []
|
||||
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
|
||||
callback(anti_cheat_data)
|
||||
@@ -577,17 +640,25 @@ def build_weanticheatyet_index(anti_cheat_data):
|
||||
return anti_cheat_index
|
||||
logger.info("Building WeAntiCheatYet data index")
|
||||
for entry in anti_cheat_data:
|
||||
normalized = entry["normalized_name"]
|
||||
anti_cheat_index[normalized] = entry
|
||||
normalized = entry.get("normalized_name", "")
|
||||
if normalized: # Only add if normalized_name exists
|
||||
anti_cheat_index[normalized] = entry
|
||||
return anti_cheat_index
|
||||
|
||||
def search_anticheat_status(candidate, anti_cheat_index):
|
||||
"""
|
||||
Searches for anti-cheat status by candidate: tries exact match first, then partial match.
|
||||
"""
|
||||
candidate_norm = normalize_name(candidate)
|
||||
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||
|
||||
# Exact match first (O(1) lookup)
|
||||
if candidate_norm in anti_cheat_index:
|
||||
status = anti_cheat_index[candidate_norm]["status"]
|
||||
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
|
||||
return status
|
||||
|
||||
# If no exact match, try partial matching
|
||||
for name_norm, entry in anti_cheat_index.items():
|
||||
if candidate_norm in name_norm:
|
||||
ratio = len(candidate_norm) / len(name_norm)
|
||||
@@ -595,20 +666,122 @@ def search_anticheat_status(candidate, anti_cheat_index):
|
||||
status = entry["status"]
|
||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
|
||||
return status
|
||||
|
||||
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
|
||||
return ""
|
||||
|
||||
# Cache for WeAntiCheatYet data with timestamp for expiration
|
||||
_ANTICHEAT_CACHE = {
|
||||
'data': None,
|
||||
'index': None,
|
||||
'timestamp': 0
|
||||
}
|
||||
_ANTICHEAT_LOCK = threading.RLock() # Use RLock to allow reentrant calls
|
||||
|
||||
# Use a class to track loading state instead of dynamic function attributes
|
||||
class AntiCheatDataLoader:
|
||||
def __init__(self):
|
||||
self._loading = False
|
||||
self._pending_callbacks = []
|
||||
|
||||
def get_anticheat_data_and_index_async(self, callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||
"""
|
||||
Asynchronously loads and caches anti-cheat data and their index.
|
||||
Calls the callback with (anti_cheat_data, anti_cheat_index).
|
||||
Implements proper cache expiration and thread safety with single index building.
|
||||
"""
|
||||
cache_duration = CACHE_DURATION
|
||||
current_time = time.time()
|
||||
|
||||
with _ANTICHEAT_LOCK:
|
||||
# Check if we have valid cached data
|
||||
if (_ANTICHEAT_CACHE['data'] is not None and
|
||||
_ANTICHEAT_CACHE['index'] is not None and
|
||||
current_time - _ANTICHEAT_CACHE['timestamp'] < cache_duration):
|
||||
callback((_ANTICHEAT_CACHE['data'], _ANTICHEAT_CACHE['index']))
|
||||
return
|
||||
|
||||
# Check if there's already a loading operation in progress
|
||||
if self._loading:
|
||||
# Add this callback to the pending list to be called when loading completes
|
||||
self._pending_callbacks.append(callback)
|
||||
return
|
||||
|
||||
# Mark that loading is in progress
|
||||
self._loading = True
|
||||
self._pending_callbacks = []
|
||||
|
||||
def on_anticheat_data(anti_cheat_data: list):
|
||||
current_time = time.time()
|
||||
with _ANTICHEAT_LOCK:
|
||||
# Only update cache if data is valid
|
||||
if anti_cheat_data:
|
||||
_ANTICHEAT_CACHE['data'] = anti_cheat_data
|
||||
_ANTICHEAT_CACHE['index'] = build_weanticheatyet_index(anti_cheat_data)
|
||||
_ANTICHEAT_CACHE['timestamp'] = current_time
|
||||
cached_data = (_ANTICHEAT_CACHE['data'], _ANTICHEAT_CACHE['index'])
|
||||
else:
|
||||
# If loading failed, clear the cache to force reload on next attempt
|
||||
_ANTICHEAT_CACHE['data'] = None
|
||||
_ANTICHEAT_CACHE['index'] = None
|
||||
_ANTICHEAT_CACHE['timestamp'] = 0
|
||||
cached_data = (None, None)
|
||||
|
||||
# Mark loading as complete
|
||||
self._loading = False
|
||||
pending_callbacks = self._pending_callbacks
|
||||
self._pending_callbacks = []
|
||||
|
||||
# Call the original callback
|
||||
callback(cached_data)
|
||||
# Call any pending callbacks that accumulated during loading
|
||||
for pending_callback in pending_callbacks:
|
||||
pending_callback(cached_data)
|
||||
|
||||
load_weanticheatyet_data_async(on_anticheat_data)
|
||||
|
||||
# Create a global instance for the anti-cheat data loader
|
||||
_anticheat_loader = AntiCheatDataLoader()
|
||||
|
||||
def get_anticheat_data_and_index_async(callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||
"""
|
||||
Asynchronously loads and caches anti-cheat data and their index.
|
||||
Calls the callback with (anti_cheat_data, anti_cheat_index).
|
||||
Implements proper cache expiration and thread safety with single index building.
|
||||
"""
|
||||
_anticheat_loader.get_anticheat_data_and_index_async(callback)
|
||||
|
||||
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
|
||||
"""
|
||||
Asynchronously retrieves WeAntiCheatYet status for a game by name.
|
||||
Calls the callback with the status string or empty string if not found.
|
||||
"""
|
||||
def on_anticheat_data(anti_cheat_data: list):
|
||||
anti_cheat_index = build_weanticheatyet_index(anti_cheat_data)
|
||||
status = search_anticheat_status(game_name, anti_cheat_index)
|
||||
def on_anticheat_data_and_index(data_and_index: tuple[list | None, dict | None]):
|
||||
anti_cheat_data, anti_cheat_index = data_and_index
|
||||
if anti_cheat_data and anti_cheat_index:
|
||||
status = search_anticheat_status(game_name, anti_cheat_index)
|
||||
else:
|
||||
status = ""
|
||||
callback(status)
|
||||
|
||||
load_weanticheatyet_data_async(on_anticheat_data)
|
||||
get_anticheat_data_and_index_async(on_anticheat_data_and_index)
|
||||
|
||||
def clear_steam_api_caches():
|
||||
"""Clears all cached data to force reload from files."""
|
||||
global _STEAM_APPS_CACHE, _ANTICHEAT_CACHE
|
||||
with _STEAM_APPS_LOCK:
|
||||
_STEAM_APPS_CACHE = {
|
||||
'data': None,
|
||||
'index': None,
|
||||
'timestamp': 0
|
||||
}
|
||||
with _ANTICHEAT_LOCK:
|
||||
_ANTICHEAT_CACHE = {
|
||||
'data': None,
|
||||
'index': None,
|
||||
'timestamp': 0
|
||||
}
|
||||
logger.info("Cleared Steam API caches")
|
||||
|
||||
def load_protondb_status(appid):
|
||||
"""Loads cached ProtonDB data for a game by appid if not outdated."""
|
||||
@@ -760,9 +933,30 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True)
|
||||
logger.info("Sorted candidates: %s", candidates_ordered)
|
||||
|
||||
def on_steam_apps(steam_apps: list):
|
||||
steam_apps_index = build_index(steam_apps)
|
||||
def on_steam_apps_and_index(data_and_index: tuple[list | None, dict | None]):
|
||||
steam_apps, steam_apps_index = data_and_index
|
||||
matching_app = None
|
||||
if not steam_apps or not steam_apps_index:
|
||||
# Handle case where data loading failed
|
||||
game_name = desktop_name or exe_name
|
||||
cover = fetch_sgdb_cover(game_name) or ""
|
||||
logger.info("Using SGDB cover for non-Steam game due to data loading failure: %s", game_name)
|
||||
|
||||
def on_anticheat_status(anticheat_status: str):
|
||||
callback({
|
||||
"appid": "",
|
||||
"name": decode_text(game_name),
|
||||
"description": "",
|
||||
"cover": cover,
|
||||
"controller_support": "",
|
||||
"protondb_tier": "",
|
||||
"steam_game": "false",
|
||||
"anticheat_status": anticheat_status
|
||||
})
|
||||
|
||||
get_weanticheatyet_status_async(game_name, on_anticheat_status)
|
||||
return
|
||||
|
||||
for candidate in candidates_ordered:
|
||||
if not candidate:
|
||||
continue
|
||||
@@ -839,31 +1033,88 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
|
||||
fetch_app_info_async(appid, on_app_info)
|
||||
|
||||
load_steam_apps_async(on_steam_apps)
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
|
||||
_STEAM_APPS = None
|
||||
_STEAM_APPS_INDEX = None
|
||||
_STEAM_APPS_LOCK = threading.Lock()
|
||||
# Cache for Steam apps data with timestamp for expiration
|
||||
_STEAM_APPS_CACHE = {
|
||||
'data': None,
|
||||
'index': None,
|
||||
'timestamp': 0
|
||||
}
|
||||
_STEAM_APPS_LOCK = threading.RLock() # Use RLock to allow reentrant calls
|
||||
|
||||
def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]):
|
||||
# Use a class to track loading state instead of dynamic function attributes
|
||||
class SteamAppsLoader:
|
||||
def __init__(self):
|
||||
self._loading = False
|
||||
self._pending_callbacks = []
|
||||
|
||||
def get_steam_apps_and_index_async(self, callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||
"""
|
||||
Asynchronously loads and caches Steam apps and their index.
|
||||
Calls the callback with (steam_apps, steam_apps_index).
|
||||
Implements proper cache expiration and thread safety with single index building.
|
||||
"""
|
||||
cache_duration = CACHE_DURATION
|
||||
current_time = time.time()
|
||||
|
||||
with _STEAM_APPS_LOCK:
|
||||
# Check if we have valid cached data
|
||||
if (_STEAM_APPS_CACHE['data'] is not None and
|
||||
_STEAM_APPS_CACHE['index'] is not None and
|
||||
current_time - _STEAM_APPS_CACHE['timestamp'] < cache_duration):
|
||||
callback((_STEAM_APPS_CACHE['data'], _STEAM_APPS_CACHE['index']))
|
||||
return
|
||||
|
||||
# Check if there's already a loading operation in progress
|
||||
if self._loading:
|
||||
# Add this callback to the pending list to be called when loading completes
|
||||
self._pending_callbacks.append(callback)
|
||||
return
|
||||
|
||||
# Mark that loading is in progress
|
||||
self._loading = True
|
||||
self._pending_callbacks = []
|
||||
|
||||
def on_steam_apps(steam_apps: list):
|
||||
current_time = time.time()
|
||||
with _STEAM_APPS_LOCK:
|
||||
# Only update cache if data is valid
|
||||
if steam_apps:
|
||||
_STEAM_APPS_CACHE['data'] = steam_apps
|
||||
_STEAM_APPS_CACHE['index'] = build_index(steam_apps)
|
||||
_STEAM_APPS_CACHE['timestamp'] = current_time
|
||||
cached_data = (_STEAM_APPS_CACHE['data'], _STEAM_APPS_CACHE['index'])
|
||||
else:
|
||||
# If loading failed, clear the cache to force reload on next attempt
|
||||
_STEAM_APPS_CACHE['data'] = None
|
||||
_STEAM_APPS_CACHE['index'] = None
|
||||
_STEAM_APPS_CACHE['timestamp'] = 0
|
||||
cached_data = (None, None)
|
||||
|
||||
# Mark loading as complete
|
||||
self._loading = False
|
||||
pending_callbacks = self._pending_callbacks
|
||||
self._pending_callbacks = []
|
||||
|
||||
# Call the original callback
|
||||
callback(cached_data)
|
||||
# Call any pending callbacks that accumulated during loading
|
||||
for pending_callback in pending_callbacks:
|
||||
pending_callback(cached_data)
|
||||
|
||||
load_steam_apps_async(on_steam_apps)
|
||||
|
||||
# Create a global instance for the Steam apps loader
|
||||
_steam_apps_loader = SteamAppsLoader()
|
||||
|
||||
def get_steam_apps_and_index_async(callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||
"""
|
||||
Asynchronously loads and caches Steam apps and their index.
|
||||
Calls the callback with (steam_apps, steam_apps_index).
|
||||
Implements proper cache expiration and thread safety with single index building.
|
||||
"""
|
||||
global _STEAM_APPS, _STEAM_APPS_INDEX
|
||||
with _STEAM_APPS_LOCK:
|
||||
if _STEAM_APPS is not None and _STEAM_APPS_INDEX is not None:
|
||||
callback((_STEAM_APPS, _STEAM_APPS_INDEX))
|
||||
return
|
||||
|
||||
def on_steam_apps(steam_apps: list):
|
||||
global _STEAM_APPS, _STEAM_APPS_INDEX
|
||||
with _STEAM_APPS_LOCK:
|
||||
_STEAM_APPS = steam_apps
|
||||
_STEAM_APPS_INDEX = build_index(steam_apps)
|
||||
callback((_STEAM_APPS, _STEAM_APPS_INDEX))
|
||||
|
||||
load_steam_apps_async(on_steam_apps)
|
||||
_steam_apps_loader.get_steam_apps_and_index_async(callback)
|
||||
|
||||
def enable_steam_cef() -> tuple[bool, str]:
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import ast
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_security import check_theme_safety, is_safe_image_file
|
||||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
|
||||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
|
||||
from portprotonqt.localization import get_screenshot_caption
|
||||
|
||||
# Icon caching for performance optimization
|
||||
_icon_cache = {}
|
||||
@@ -18,57 +19,6 @@ THEMES_DIRS = [
|
||||
]
|
||||
_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():
|
||||
"""
|
||||
@@ -86,20 +36,36 @@ def list_themes():
|
||||
def load_theme_screenshots(theme_name):
|
||||
"""
|
||||
Загружает все скриншоты из папки "screenshots", расположенной в папке темы.
|
||||
Возвращает список кортежей (pixmap, filename).
|
||||
Возвращает список кортежей (pixmap, caption), где caption - это перевод названия скриншота.
|
||||
Если папка отсутствует или пуста, возвращается пустой список.
|
||||
"""
|
||||
screenshots = []
|
||||
|
||||
# Find the metainfo file for the theme
|
||||
metainfo_file = None
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
temp_metainfo_file = os.path.join(theme_folder, "metainfo.ini")
|
||||
if os.path.exists(temp_metainfo_file):
|
||||
metainfo_file = temp_metainfo_file
|
||||
break
|
||||
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
screenshots_folder = os.path.join(theme_folder, "images", "screenshots")
|
||||
if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder):
|
||||
for file in os.listdir(screenshots_folder):
|
||||
screenshot_path = os.path.join(screenshots_folder, file)
|
||||
if os.path.isfile(screenshot_path):
|
||||
if os.path.isfile(screenshot_path) and is_safe_image_file(screenshot_path):
|
||||
pixmap = QPixmap(screenshot_path)
|
||||
if not pixmap.isNull():
|
||||
screenshots.append((pixmap, file))
|
||||
# Get the base filename without extension
|
||||
base_filename = os.path.splitext(file)[0]
|
||||
|
||||
# Get translated caption using localization function
|
||||
caption = get_screenshot_caption(base_filename, metainfo_file)
|
||||
|
||||
screenshots.append((pixmap, caption))
|
||||
return screenshots
|
||||
|
||||
def load_theme_fonts(theme_name):
|
||||
@@ -288,14 +254,14 @@ class ThemeManager:
|
||||
# Если передано имя с расширением, проверяем только этот файл
|
||||
if has_extension:
|
||||
candidate = os.path.join(icons_folder, str(base_name))
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
icon_path = candidate
|
||||
break
|
||||
else:
|
||||
# Проверяем все поддерживаемые расширения
|
||||
for ext in supported_extensions:
|
||||
candidate = os.path.join(icons_folder, str(base_name) + str(ext))
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
icon_path = candidate
|
||||
break
|
||||
if icon_path:
|
||||
@@ -309,12 +275,12 @@ class ThemeManager:
|
||||
# Аналогично проверяем в стандартной теме
|
||||
if has_extension:
|
||||
icon_path = os.path.join(standard_icons_folder, base_name)
|
||||
if not os.path.exists(icon_path):
|
||||
if not os.path.exists(icon_path) or not is_safe_image_file(icon_path):
|
||||
icon_path = None
|
||||
else:
|
||||
for ext in supported_extensions:
|
||||
candidate = os.path.join(standard_icons_folder, base_name + ext)
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
icon_path = candidate
|
||||
break
|
||||
|
||||
@@ -357,13 +323,13 @@ class ThemeManager:
|
||||
|
||||
if has_extension:
|
||||
candidate = os.path.join(images_folder, str(base_name))
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
image_path = candidate
|
||||
break
|
||||
else:
|
||||
for ext in supported_extensions:
|
||||
candidate = os.path.join(images_folder, str(base_name) + str(ext))
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
image_path = candidate
|
||||
break
|
||||
if image_path:
|
||||
@@ -376,12 +342,12 @@ class ThemeManager:
|
||||
|
||||
if has_extension:
|
||||
image_path = os.path.join(standard_images_folder, base_name)
|
||||
if not os.path.exists(image_path):
|
||||
if not os.path.exists(image_path) or not is_safe_image_file(image_path):
|
||||
image_path = None
|
||||
else:
|
||||
for ext in supported_extensions:
|
||||
candidate = os.path.join(standard_images_folder, base_name + ext)
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
image_path = candidate
|
||||
break
|
||||
|
||||
|
||||
444
portprotonqt/theme_security.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Theme security module for PortProtonQt.
|
||||
Provides enhanced security checks for theme files to prevent malicious code execution.
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ThemeSecurityChecker:
|
||||
"""
|
||||
Enhanced security checker for theme files.
|
||||
Identifies and blocks various attack vectors in theme Python files.
|
||||
"""
|
||||
|
||||
# Basic forbidden modules that could allow dangerous operations
|
||||
FORBIDDEN_MODULES = {
|
||||
# File system operations
|
||||
"os", "shutil", "pathlib", "glob", "tempfile", "filecmp", "fileinput",
|
||||
"linecache", "io", "mmap", "fnmatch", "difflib",
|
||||
|
||||
# Process and system operations
|
||||
"subprocess", "sys", "ctypes", "cffi", "platform", "resource", "signal",
|
||||
"multiprocessing", "concurrent", "threading", "asyncio", "select",
|
||||
"selectors", "queue", "sched", "contextvars",
|
||||
|
||||
# Network operations
|
||||
"socket", "urllib", "urllib2", "urllib.request", "urllib.parse",
|
||||
"urllib.error", "urllib.robotparser", "http", "http.client",
|
||||
"http.cookies", "http.cookiejar", "ftplib", "telnetlib", "smtplib",
|
||||
"poplib", "imaplib", "nntplib", "socketserver", "xmlrpc", "xmlrpc.client",
|
||||
"xmlrpc.server", "ipaddress", "webbrowser", "ssl", "uuid",
|
||||
|
||||
# Code execution and dynamic imports
|
||||
"code", "codeop", "compileall", "py_compile", "runpy", "zipimport",
|
||||
"pkgutil", "pkg_resources", "importlib", "importlib.util",
|
||||
"importlib.import_module", "importlib.resources", "importlib.metadata",
|
||||
"builtins", "exec", "eval", "__import__", "compile", "execfile",
|
||||
"imp", "importlib.machinery", "importlib.abc", "importlib.load_module",
|
||||
"importlib.reload", "imp.load_source", "imp.load_compiled", "imp.find_module",
|
||||
"imp.get_suffixes", "imp.init_builtin", "imp.init_frozen", "imp.is_builtin",
|
||||
"imp.is_frozen", "imp.lock_held", "imp.lock", "imp.reload", "imp.load_module",
|
||||
|
||||
# Data serialization and code execution
|
||||
"pickle", "marshal", "shelve", "json", "yaml", "configparser", "binascii", "base64",
|
||||
|
||||
# Databases and storage
|
||||
"sqlite3", "dbapi2", "sqlite_web", "dataset", "records", "tinydb",
|
||||
|
||||
# Cryptography and security
|
||||
"hashlib", "hmac", "secrets", "crypt", "cryptography",
|
||||
|
||||
# External libraries that could be dangerous
|
||||
"requests", "aiohttp", "selenium", "paramiko", "fabric", "docker",
|
||||
"boto", "boto3", "pymongo", "pymysql", "psycopg2", "redis", "pika",
|
||||
"kafka", "celery", "rq", "playwright", "mechanize", "scrapy",
|
||||
"beautifulsoup4", "lxml", "html5lib", "pyautogui",
|
||||
"keyboard", "mouse", "pynput", "psutil", "wmi", "pywin32",
|
||||
|
||||
# GUI and UI libraries that could be used for malicious purposes
|
||||
"tkinter", "PyQt4", "PyQt5", "PyQt6", "PySide", "PySide2", "PySide6",
|
||||
"kivy", "kivymd", "wx", "wxPython", "pygame", "flask", "django",
|
||||
"fastapi", "tornado", "bottle", "cherrypy", "falcon", "sanic",
|
||||
|
||||
|
||||
}
|
||||
|
||||
# Forbidden functions that could allow dangerous operations
|
||||
FORBIDDEN_FUNCTIONS = {
|
||||
# Code execution
|
||||
"exec", "eval", "compile", "execfile", "__import__",
|
||||
|
||||
# Import-related functions that allow dynamic imports
|
||||
"importlib.import_module", "importlib.util", "importlib.resources",
|
||||
"importlib.metadata", "builtins.__import__", "builtins.eval",
|
||||
"builtins.exec", "builtins.compile", "builtins.open",
|
||||
|
||||
# File system operations
|
||||
"open", "file", "os.open", "os.fdopen", "io.open", "tempfile.mktemp",
|
||||
"tempfile.mkdtemp", "tempfile.NamedTemporaryFile", "tempfile.SpooledTemporaryFile",
|
||||
|
||||
# System operations
|
||||
"os.system", "os.popen", "os.spawnl", "os.spawnle", "os.spawnlp",
|
||||
"os.spawnlpe", "os.spawnv", "os.spawnve", "os.spawnvp", "os.spawnvpe",
|
||||
"os.startfile", "os.execv", "os.execve", "os.execl", "os.execle", "os.execlp",
|
||||
"os.execlpe", "subprocess.run", "subprocess.call",
|
||||
"subprocess.check_call", "subprocess.check_output", "subprocess.Popen",
|
||||
|
||||
# Network operations
|
||||
"socket.socket", "socket.create_connection", "urllib.request.urlopen",
|
||||
"urllib.request.Request", "requests.get", "requests.post", "requests.put",
|
||||
"requests.delete", "requests.patch", "requests.head", "requests.options",
|
||||
"aiohttp.ClientSession", "http.client.HTTPConnection", "http.client.HTTPSConnection",
|
||||
|
||||
# Reflection and introspection that could be dangerous
|
||||
"getattr", "setattr", "hasattr", "delattr", "globals", "locals", "vars",
|
||||
"dir", "type", "id", "object", "issubclass", "isinstance", "callable",
|
||||
"iter", "next", "reversed", "slice", "sorted", "filter", "map", "reduce",
|
||||
|
||||
# Input functions
|
||||
"input", "raw_input",
|
||||
|
||||
# Built-in functions that could be dangerous in certain contexts
|
||||
"breakpoint", "quit", "exit", "copyright", "credits", "license", "help",
|
||||
|
||||
# Dynamic attribute access that could be dangerous
|
||||
"operator.attrgetter", "operator.itemgetter", "operator.methodcaller",
|
||||
|
||||
"apply", "buffer", "coerce", "intern", "long", "unichr",
|
||||
"unicode", "xrange", "cmp", "reload", "basestring",
|
||||
}
|
||||
|
||||
# Forbidden attributes that could be dangerous
|
||||
FORBIDDEN_ATTRIBUTES = {
|
||||
# Special methods and attributes that could be used for code execution
|
||||
"__class__", "__dict__", "__module__", "__subclasses__", "__bases__",
|
||||
"__mro__", "__call__", "__func__", "__self__", "__code__", "__closure__",
|
||||
"__globals__", "__name__", "__file__", "__path__", "__package__",
|
||||
"__loader__", "__spec__", "__builtins__", "__import__", "__new__",
|
||||
"__init__", "__del__", "__repr__", "__str__", "__bytes__", "__format__",
|
||||
"__lt__", "__le__", "__eq__", "__ne__", "__gt__", "__ge__", "__hash__",
|
||||
"__bool__", "__dir__", "__delattr__", "__getattribute__",
|
||||
"__setattr__", "__delete__", "__set__", "__get__", "__set_name__",
|
||||
"__prepare__", "__init_subclass__", "__instancecheck__", "__subclasscheck__",
|
||||
"__subclasshook__", "__class_getitem__", "__annotations__", "__weakref__",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.has_errors = False
|
||||
self.errors = []
|
||||
|
||||
def check_theme_safety(self, theme_file: str) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Enhanced security check for theme files.
|
||||
Returns (is_safe, list_of_errors).
|
||||
"""
|
||||
self.has_errors = False
|
||||
self.errors = []
|
||||
|
||||
try:
|
||||
with open(theme_file, encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for syntax errors first
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except SyntaxError as e:
|
||||
self.errors.append(f"Syntax error in file {theme_file}: {e}")
|
||||
self.has_errors = True
|
||||
return not self.has_errors, self.errors
|
||||
|
||||
# Walk through the AST and check for dangerous patterns
|
||||
for node in ast.walk(tree):
|
||||
self._check_node_safety(node, theme_file)
|
||||
|
||||
except Exception as e:
|
||||
self.errors.append(f"Failed to check theme safety for {theme_file}: {e}")
|
||||
self.has_errors = True
|
||||
|
||||
return not self.has_errors, self.errors
|
||||
|
||||
def _check_node_safety(self, node, theme_file: str):
|
||||
"""Check individual AST nodes for security issues."""
|
||||
# Check for forbidden imports
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
for alias in node.names:
|
||||
module_name = alias.name
|
||||
# Handle from ... import ... cases
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
module_name = node.module
|
||||
|
||||
# Check if the module is in the forbidden list
|
||||
if module_name in self.FORBIDDEN_MODULES:
|
||||
error_msg = f"Forbidden module '{module_name}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
# Also check submodules (e.g., "os.path" should trigger on "os")
|
||||
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||
if module_name.startswith(forbidden_module + "."):
|
||||
error_msg = f"Forbidden submodule '{module_name}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
break
|
||||
|
||||
# Check for forbidden function calls
|
||||
elif isinstance(node, ast.Call):
|
||||
# Check for direct function calls (e.g., eval(), exec())
|
||||
if isinstance(node.func, ast.Name):
|
||||
if node.func.id in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{node.func.id}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for method calls (e.g., os.system(), requests.get())
|
||||
elif isinstance(node.func, ast.Attribute):
|
||||
# Get the full function path (e.g., "os.system")
|
||||
full_func_name = self._get_attribute_path(node.func)
|
||||
if full_func_name in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{full_func_name}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
# Check just the attribute name
|
||||
elif node.func.attr in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden method '{node.func.attr}' called in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for import expressions that might be used dynamically
|
||||
elif isinstance(node, ast.Expr):
|
||||
# Check if the expression is a call to an import-related function
|
||||
if isinstance(node.value, ast.Call):
|
||||
if isinstance(node.value.func, ast.Name):
|
||||
if node.value.func.id in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{node.value.func.id}' found in expression in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
elif isinstance(node.value.func, ast.Attribute):
|
||||
full_func_name = self._get_attribute_path(node.value.func)
|
||||
if full_func_name in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{full_func_name}' found in expression in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for forbidden attributes
|
||||
elif isinstance(node, ast.Attribute):
|
||||
if node.attr in self.FORBIDDEN_ATTRIBUTES:
|
||||
error_msg = f"Forbidden attribute access '{node.attr}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for dangerous expressions (like accessing builtins)
|
||||
elif isinstance(node, ast.Name):
|
||||
if node.id in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{node.id}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for potentially dangerous f-strings that might execute code
|
||||
elif isinstance(node, ast.FormattedValue):
|
||||
# Check if the format value contains dangerous expressions
|
||||
if hasattr(node, 'value'):
|
||||
# Recursively check the value for dangerous patterns
|
||||
if isinstance(node.value, ast.Call):
|
||||
func_name = self._get_attribute_path(node.value.func)
|
||||
if func_name in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{func_name}' found in f-string in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
elif isinstance(node.value, ast.Attribute) and node.value.attr in self.FORBIDDEN_ATTRIBUTES:
|
||||
error_msg = f"Forbidden attribute access '{node.value.attr}' found in f-string in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
elif isinstance(node.value, ast.Name) and node.value.id in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{node.value.id}' found in f-string in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
# Recursively check nested expressions in f-strings
|
||||
elif isinstance(node.value, (ast.BinOp, ast.UnaryOp, ast.BoolOp)):
|
||||
# Check for complex expressions that might contain dangerous operations
|
||||
self._check_node_safety(node.value, theme_file)
|
||||
# Check for nested function calls that might be dangerous
|
||||
elif isinstance(node.value, ast.Subscript):
|
||||
# Check if we're accessing something potentially dangerous
|
||||
if hasattr(node.value, 'value') and isinstance(node.value.value, ast.Call):
|
||||
func_name = self._get_attribute_path(node.value.value.func)
|
||||
if func_name in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{func_name}' found in f-string subscript in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for string concatenation attacks (e.g., "im" + "port", "exec", etc.)
|
||||
elif isinstance(node, ast.BinOp):
|
||||
# Check for string concatenations that might be used to obfuscate dangerous code
|
||||
if isinstance(node.op, ast.Add): # String concatenation with +
|
||||
left_val = self._get_constant_value(node.left)
|
||||
right_val = self._get_constant_value(node.right)
|
||||
|
||||
if left_val is not None and right_val is not None:
|
||||
concatenated = str(left_val) + str(right_val)
|
||||
# Check if concatenated string forms a dangerous module/function name
|
||||
if concatenated in self.FORBIDDEN_MODULES or concatenated in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Potential string concatenation attack detected: '{concatenated}' in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
# Also check if it's a substring of forbidden items
|
||||
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||
if concatenated in forbidden_module or forbidden_module in concatenated:
|
||||
error_msg = f"Potential string concatenation attack detected: '{concatenated}' matches forbidden module '{forbidden_module}' in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||
if concatenated in forbidden_func or forbidden_func in concatenated:
|
||||
error_msg = f"Potential string concatenation attack detected: '{concatenated}' matches forbidden function '{forbidden_func}' in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for common obfuscation techniques
|
||||
elif isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name) and node.func.id in ['eval', 'exec']:
|
||||
# Check if eval/exec is being called with obfuscated content
|
||||
if len(node.args) > 0:
|
||||
first_arg = node.args[0]
|
||||
arg_value = self._get_constant_value(first_arg)
|
||||
if arg_value:
|
||||
# Check if eval/exec argument contains dangerous content
|
||||
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||
if forbidden_func in str(arg_value):
|
||||
error_msg = f"Potential obfuscated code execution detected: '{forbidden_func}' found in eval/exec argument in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for character code arrays (another obfuscation method)
|
||||
elif isinstance(node, ast.List) or isinstance(node, ast.Tuple):
|
||||
# Check if it's a list of character codes that might be converted to dangerous strings
|
||||
if all(isinstance(elt, (ast.Num, ast.Constant)) and isinstance(self._get_constant_value(elt), int) for elt in node.elts):
|
||||
# This might be an array of ASCII codes
|
||||
try:
|
||||
char_codes = [self._get_constant_value(elt) for elt in node.elts if self._get_constant_value(elt) is not None]
|
||||
# Filter to only include actual integers for character codes
|
||||
int_char_codes = [code for code in char_codes if isinstance(code, int)]
|
||||
if int_char_codes and all(isinstance(code, int) and 32 <= code <= 126 for code in int_char_codes): # Printable ASCII range
|
||||
decoded_str = ''.join(chr(code) for code in int_char_codes)
|
||||
# Check if decoded string contains dangerous content
|
||||
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||
if forbidden_module in decoded_str:
|
||||
error_msg = f"Potential character code obfuscation detected: '{forbidden_module}' found in decoded array in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||
if forbidden_func in decoded_str:
|
||||
error_msg = f"Potential character code obfuscation detected: '{forbidden_func}' found in decoded array in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
# If conversion fails, continue
|
||||
pass
|
||||
|
||||
def _get_attribute_path(self, attr_node):
|
||||
"""Extract the full attribute path from an AST node (e.g., 'os.path.join')."""
|
||||
if isinstance(attr_node, ast.Name):
|
||||
return attr_node.id
|
||||
elif isinstance(attr_node, ast.Attribute):
|
||||
parent_path = self._get_attribute_path(attr_node.value)
|
||||
return f"{parent_path}.{attr_node.attr}"
|
||||
return ""
|
||||
|
||||
def _get_constant_value(self, node):
|
||||
"""Extract the constant value from an AST node if it's a constant."""
|
||||
if isinstance(node, ast.Str): # Python < 3.8
|
||||
return node.s
|
||||
elif isinstance(node, ast.Constant): # Python 3.8+
|
||||
return node.value
|
||||
elif isinstance(node, ast.Num): # Python < 3.8 for numbers
|
||||
return node.n
|
||||
elif isinstance(node, ast.Bytes): # For bytes
|
||||
return node.s
|
||||
return None
|
||||
|
||||
|
||||
def check_theme_safety(theme_file: str) -> bool:
|
||||
"""
|
||||
Convenience function to check theme safety.
|
||||
Returns True if the theme is safe, False otherwise.
|
||||
"""
|
||||
checker = ThemeSecurityChecker()
|
||||
is_safe, errors = checker.check_theme_safety(theme_file)
|
||||
|
||||
for error in errors:
|
||||
logger.error(error)
|
||||
|
||||
return is_safe
|
||||
|
||||
|
||||
def is_safe_image_file(file_path: str) -> bool:
|
||||
"""
|
||||
Check if an image file is safe to load by verifying its extension and basic file properties.
|
||||
This helps prevent loading malicious files that might be disguised as images.
|
||||
"""
|
||||
|
||||
# Check file extension first
|
||||
safe_extensions = {'.png', '.jpg', '.jpeg', '.svg', '.bmp', '.gif', '.webp', '.ico'}
|
||||
_, ext = os.path.splitext(file_path.lower())
|
||||
|
||||
if ext not in safe_extensions:
|
||||
logger.warning(f"Unsafe image file extension for {file_path}: {ext}")
|
||||
return False
|
||||
|
||||
# Check file size (prevent loading extremely large files)
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
# Limit to 50MB to prevent memory exhaustion attacks
|
||||
if file_size > 50 * 1024 * 1024: # 50MB
|
||||
logger.warning(f"Image file too large ({file_size} bytes): {file_path}")
|
||||
return False
|
||||
except OSError:
|
||||
logger.error(f"Could not get file size for {file_path}")
|
||||
return False
|
||||
|
||||
# For security, we can also check the file's magic bytes (first few bytes)
|
||||
# to ensure it's actually an image file and not a disguised executable
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
header = f.read(32) # Read first 32 bytes
|
||||
|
||||
# Check for common image file signatures (magic bytes)
|
||||
if ext == '.png':
|
||||
# PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
if not header.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||
logger.warning(f"File {file_path} does not have PNG signature")
|
||||
return False
|
||||
elif ext in ['.jpg', '.jpeg']:
|
||||
# JPEG signature: FF D8 FF
|
||||
if not header.startswith(b'\xff\xd8\xff'):
|
||||
logger.warning(f"File {file_path} does not have JPEG signature")
|
||||
return False
|
||||
elif ext == '.gif':
|
||||
# GIF signature: 47 49 46 38 (GIF8)
|
||||
if not header.startswith(b'GIF8'):
|
||||
logger.warning(f"File {file_path} does not have GIF signature")
|
||||
return False
|
||||
elif ext == '.bmp':
|
||||
# BMP signature: 42 4D (BM)
|
||||
if not header.startswith(b'BM'):
|
||||
logger.warning(f"File {file_path} does not have BMP signature")
|
||||
return False
|
||||
# SVG is text-based, so we just check if it contains XML-like structure
|
||||
elif ext == '.svg':
|
||||
try:
|
||||
header_str = header.decode('utf-8', errors='ignore')
|
||||
# Basic check for SVG XML structure
|
||||
if not ('<svg' in header_str or '<?xml' in header_str):
|
||||
logger.warning(f"File {file_path} does not appear to be a valid SVG")
|
||||
return False
|
||||
except UnicodeDecodeError:
|
||||
logger.warning(f"SVG file {file_path} contains invalid UTF-8")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking image file signature for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
@@ -1,5 +1,23 @@
|
||||
[Metainfo]
|
||||
author = Dervart
|
||||
author_link =
|
||||
description = Стандартная тема PortProtonQt (тёмный вариант)
|
||||
name = Clean Dark
|
||||
name_en = Clean Dark
|
||||
name_ru = Чистая темная
|
||||
description_en = Standard PortProtonQt theme (dark variant)
|
||||
description_ru = Стандартная тема PortProtonQt (тёмный вариант)
|
||||
|
||||
[Screenshots]
|
||||
auto_installs_en = Auto-installs
|
||||
auto_installs_ru = Автоустановки
|
||||
library_en = Library
|
||||
library_ru = Библиотека
|
||||
game_card_en = Game Card
|
||||
game_card_ru = Карточка
|
||||
context_menu_en = Context Menu
|
||||
context_menu_ru = Контекстное меню
|
||||
portproton_settings_en = PortProton Settings
|
||||
portproton_settings_ru = Настройки PortProton
|
||||
wine_settings_en = Wine Settings
|
||||
wine_settings_ru = Настройки Wine
|
||||
themes_en = Themes
|
||||
themes_ru = Темы
|
||||
|
||||
0
portprotonqt/themes/standart/styles/__init__.py
Normal file
378
portprotonqt/themes/standart/styles/base.py
Normal file
@@ -0,0 +1,378 @@
|
||||
from .constants import *
|
||||
|
||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
|
||||
MAIN_WINDOW_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QLabel {{
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QPushButton {{
|
||||
background: {color_c};
|
||||
border: {border_c} rgba(255, 255, 255, 0.01);
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-size: {font_size_a};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПРОГРЕСС-БАРА
|
||||
PROGRESS_BAR_STYLE = f"""
|
||||
QProgressBar {{
|
||||
color: {color_f};
|
||||
background-color: {color_c};
|
||||
text-align: center;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background-color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ СТАТУС-БАРА
|
||||
STATUS_BAR_STYLE = f"""
|
||||
QStatusBar {{
|
||||
color: {color_f};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||
MAIN_WINDOW_HEADER_STYLE = f"""
|
||||
QFrame {{
|
||||
background: {color_h};
|
||||
border: 10px solid {color_g};
|
||||
border-bottom: 0px solid {color_g};
|
||||
border-top-left-radius: 30px;
|
||||
border-top-right-radius: 30px;
|
||||
border: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
|
||||
NAV_WIDGET_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_h};
|
||||
border: {border_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
|
||||
NAV_BUTTON_STYLE = f"""
|
||||
NavLabel {{
|
||||
background: rgba(0,0,0,0);
|
||||
padding: 12px 3px;
|
||||
margin: 10px 0 10px 10px;
|
||||
color: #7f7f7f;
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
text-transform: uppercase;
|
||||
border: {color_a};
|
||||
border-radius: {border_radius_b};
|
||||
}}
|
||||
NavLabel[checked = true] {{
|
||||
background: rgba(0,0,0,0);
|
||||
color: {color_a};
|
||||
font-weight: normal;
|
||||
text-decoration: underline;
|
||||
border-radius: {border_radius_b};
|
||||
}}
|
||||
NavLabel:hover {{
|
||||
background: none;
|
||||
color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПОЛЯ ПОИСКА
|
||||
SEARCH_EDIT_STYLE = f"""
|
||||
QLineEdit {{
|
||||
background-color: rgba(30, 30, 30, 0.50);
|
||||
border: {border_b} rgba(255, 255, 255, 0.5);
|
||||
border-radius: {border_radius_a};
|
||||
padding: 7px 14px;
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: {border_b} {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
|
||||
SCROLL_AREA_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_h};
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:vertical {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::sub-line:vertical {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
|
||||
QScrollBar:horizontal {{
|
||||
height: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:horizontal {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::sub-line:horizontal {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
# SLIDER_SIZE_STYLE
|
||||
SLIDER_SIZE_STYLE= f"""
|
||||
QWidget {{
|
||||
background: {color_h};
|
||||
height: 25px;
|
||||
}}
|
||||
QSlider::groove:horizontal {{
|
||||
border: {border_a};
|
||||
border-radius: 3px;
|
||||
height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
margin: 6px 0;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
|
||||
border-radius: 9px;
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
|
||||
LIST_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: none;
|
||||
border: {border_a} {color_g};
|
||||
border-radius: 25px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
|
||||
INSTALLED_TAB_TITLE_STYLE = f"""
|
||||
QLabel {{
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_b};
|
||||
color: {color_f};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
|
||||
ACTION_BUTTON_STYLE = f"""
|
||||
QPushButton {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-size: {font_size_a};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОВЕРЛЕЯ
|
||||
OVERLAY_WINDOW_STYLE = f"background: {color_b};"
|
||||
OVERLAY_BUTTON_STYLE = f"""
|
||||
QPushButton {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-size: {font_size_a};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
|
||||
TAB_TITLE_STYLE = f"font-family: '{font_family}'; font-size: {font_size_b}; color: {color_f}; background-color: none;"
|
||||
CONTENT_STYLE = f"""
|
||||
QLabel {{
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
color: {color_f};
|
||||
background-color: none;
|
||||
border-bottom: {border_b} rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 15px;
|
||||
}}
|
||||
"""
|
||||
|
||||
PREVIEW_WIDGET_STYLE = f"""
|
||||
QWidget {{
|
||||
margin-top: 3px;
|
||||
background-color: {color_c};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
|
||||
# LIBRARY_WIDGET_STYLE
|
||||
LIBRARY_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(112,20,132,1),
|
||||
stop:1 rgba(50,134,182,1));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# CONTAINER_STYLE
|
||||
CONTAINER_STYLE= """
|
||||
QWidget {
|
||||
background-color: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# OTHER_PAGES_WIDGET_STYLE
|
||||
OTHER_PAGES_WIDGET_STYLE= f"""
|
||||
QWidget {{
|
||||
background: {color_d};
|
||||
border-radius: 0px;
|
||||
}}
|
||||
"""
|
||||
|
||||
# CAROUSEL_WIDGET_STYLE
|
||||
CAROUSEL_WIDGET_STYLE= f"""
|
||||
QWidget {{
|
||||
background: {color_c};
|
||||
border-radius: 0px;
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
|
||||
# PARAMS_TITLE_STYLE
|
||||
PARAMS_TITLE_STYLE = f"color: {color_f}; font-family: '{font_family}'; font-size: {font_size_a}; padding: 10px; background: {color_h};"
|
||||
|
||||
PROXY_INPUT_STYLE = f"""
|
||||
QLineEdit {{
|
||||
background: {color_b};
|
||||
border: {border_c} rgba(255, 255, 255, 0.01);
|
||||
border-radius: {border_radius_a};
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QLineEdit:hover {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_e};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
|
||||
MESSAGE_BOX_STYLE = f"""
|
||||
QMessageBox {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(40, 40, 40, 0.95),
|
||||
stop:1 rgba(25, 25, 25, 0.95));
|
||||
border: {border_b} rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QMessageBox QLabel {{
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QMessageBox QPushButton {{
|
||||
background: rgba(30, 30, 30, 0.6);
|
||||
border: {border_b} rgba(255, 255, 255, 0.2);
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 20px;
|
||||
min-width: 80px;
|
||||
}}
|
||||
QMessageBox QPushButton:hover {{
|
||||
background: #09bec8;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}}
|
||||
QMessageBox QPushButton:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background: {color_e};
|
||||
}}
|
||||
"""
|
||||
|
||||
# Favorite Star
|
||||
FAVORITE_LABEL_STYLE = f"color: gold; font-size: 32px; background: {color_h};"
|
||||
176
portprotonqt/themes/standart/styles/constants.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
|
||||
theme_manager = ThemeManager()
|
||||
current_theme_name = read_theme_from_config()
|
||||
|
||||
# КОНСТАНТЫ
|
||||
favoriteLabelSize = 48, 48
|
||||
|
||||
# VARS
|
||||
font_family = "Play"
|
||||
font_size_a = "16px"
|
||||
font_size_b = "24px"
|
||||
border_a = "0px solid"
|
||||
border_b = "1px solid"
|
||||
border_c = "2px solid"
|
||||
border_radius_a = "10px"
|
||||
border_radius_b = "15px"
|
||||
color_a = "#409EFF"
|
||||
color_b = "#282a33"
|
||||
color_c = "#3f424d"
|
||||
color_d = "#32343d"
|
||||
color_e = "#404554"
|
||||
color_f = "#ffffff"
|
||||
color_g = "rgba(0, 0, 0, 0)"
|
||||
color_h = "transparent"
|
||||
color_i = "rgba(40, 42, 51, 0.9)"
|
||||
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Тип анимации при входе и выходе на детальную страницу
|
||||
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
|
||||
# Определяет, как детальная страница появляется и исчезает
|
||||
"detail_page_animation_type": "fade",
|
||||
|
||||
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
|
||||
# Влияет на толщину рамки вокруг карточки, когда она не выделена
|
||||
# Значение в пикселях
|
||||
"default_border_width": 2,
|
||||
|
||||
# Ширина обводки при наведении курсора
|
||||
# Увеличивает толщину рамки, когда курсор находится над карточкой
|
||||
# Значение в пикселях
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Ширина обводки при фокусе (например, при выборе с клавиатуры)
|
||||
# Увеличивает толщину рамки, когда карточка в фокусе
|
||||
# Значение в пикселях
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Минимальная ширина обводки во время пульсирующей анимации
|
||||
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
|
||||
# Значение в пикселях
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Максимальная ширина обводки во время пульсирующей анимации
|
||||
# Определяет максимальную толщину рамки при пульсации
|
||||
# Значение в пикселях
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
|
||||
# Влияет на скорость перехода от одной ширины обводки к другой
|
||||
# Значение в миллисекундах
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Длительность одного цикла пульсирующей анимации
|
||||
# Определяет, как быстро рамка "пульсирует" между min и max значениями
|
||||
# Значение в миллисекундах
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Длительность анимации вращения градиента
|
||||
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
|
||||
# Значение в миллисекундах
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Начальный угол градиента (в градусах)
|
||||
# Определяет начальную точку вращения градиента при старте анимации
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Конечный угол градиента (в градусах)
|
||||
# Определяет конечную точку вращения градиента
|
||||
# Значение 0 означает полный поворот на 360 градусов
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Тип анимации для карточки при наведении или фокусе
|
||||
# Возможные значения: "gradient", "scale"
|
||||
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
|
||||
"card_animation_type": "gradient",
|
||||
|
||||
# Масштаб карточки в состоянии покоя
|
||||
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
|
||||
# Значение в долях (например, 1.0 для нормального размера)
|
||||
"default_scale": 1.0,
|
||||
|
||||
# Масштаб карточки при наведении курсора
|
||||
# Увеличивает размер карточки при наведении
|
||||
# Значение в долях (например, 1.1 = 110% от исходного размера)
|
||||
"hover_scale": 1.1,
|
||||
|
||||
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
|
||||
# Увеличивает размер карточки при фокусе
|
||||
# Значение в долях (например, 1.05 = 105% от исходного размера)
|
||||
"focus_scale": 1.05,
|
||||
|
||||
# Длительность анимации масштабирования
|
||||
# Влияет на скорость изменения размера карточки при наведении или фокусе
|
||||
# Значение в миллисекундах
|
||||
"scale_anim_duration": 200,
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
|
||||
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"scale_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходному масштабу
|
||||
"scale_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex
|
||||
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||
],
|
||||
|
||||
# Длительность анимации fade при входе на детальную страницу
|
||||
# Влияет на скорость появления страницы при fade-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_fade_duration": 350,
|
||||
|
||||
# Длительность анимации slide при входе на детальную страницу
|
||||
# Влияет на скорость скольжения страницы при slide-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_slide_duration": 500,
|
||||
|
||||
# Длительность анимации bounce при входе на детальную страницу
|
||||
# Влияет на скорость "прыжка" страницы при bounce-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_bounce_duration": 400,
|
||||
|
||||
# Длительность анимации fade при выходе из детальной страницы
|
||||
# Влияет на скорость исчезновения страницы при fade-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_fade_duration_exit": 350,
|
||||
|
||||
# Длительность анимации slide при выходе из детальной страницы
|
||||
# Влияет на скорость скольжения страницы при slide-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_slide_duration_exit": 500,
|
||||
|
||||
# Длительность анимации bounce при выходе из детальной страницы
|
||||
# Влияет на скорость "сжатия" страницы при bounce-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_bounce_duration_exit": 400,
|
||||
|
||||
# Тип кривой сглаживания для анимации при входе на детальную страницу
|
||||
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Тип кривой сглаживания для анимации при выходе из детальной страницы
|
||||
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
115
portprotonqt/themes/standart/styles/detail_page.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from .constants import *
|
||||
|
||||
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
|
||||
DETAIL_PAGE_NO_COVER_STYLE = f"background: rgba(20,20,20,0.95); border-radius: {border_radius_b};"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
|
||||
ADDGAME_BACK_BUTTON_STYLE = f"""
|
||||
QPushButton {{
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: {border_b} rgba(255, 255, 255, 0.5);
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-size: {font_size_a};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
|
||||
DETAIL_CONTENT_FRAME_STYLE = f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(20, 20, 20, 0.40),
|
||||
stop:1 rgba(20, 20, 20, 0.35));
|
||||
border: {border_a} {color_g};
|
||||
border-radius: {border_radius_b};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ФРЕЙМ ПОД ОБЛОЖКОЙ
|
||||
COVER_FRAME_STYLE = f"""
|
||||
QFrame {{
|
||||
background: rgba(30, 30, 30, 0.80);
|
||||
border-radius: {border_radius_b};
|
||||
border: {border_a} {color_g};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
|
||||
COVER_LABEL_STYLE = f"border-radius: {border_radius_b};"
|
||||
|
||||
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
|
||||
DETAILS_WIDGET_STYLE = f"background: rgba(20,20,20,0.40); border-radius: {border_radius_b}; padding: 10px;"
|
||||
|
||||
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
|
||||
DETAIL_PAGE_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 32px; color: #007AFF;"
|
||||
|
||||
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
|
||||
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
|
||||
|
||||
# ТЕКСТ ОПИСАНИЯ
|
||||
DETAIL_PAGE_DESC_STYLE = f"font-family: '{font_family}'; font-size: {font_size_a}; color: {color_f}; line-height: 1.5;"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ИГРАТЬ"
|
||||
PLAY_BUTTON_STYLE = f"""
|
||||
QPushButton {{
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: {border_b} rgba(255, 255, 255, 0.5);
|
||||
border-radius: {border_radius_a};
|
||||
font-size: 18px;
|
||||
color: {color_f};
|
||||
font-weight: bold;
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
min-width: 120px;
|
||||
min-height: 40px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_a};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
background: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
ADDGAME_INPUT_STYLE = f"""
|
||||
QLineEdit {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QLineEdit:hover {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_e};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ФУНКЦИЯ ДЛЯ ДИНАМИЧЕСКОГО ГРАДИЕНТА (ДЕТАЛИ ИГР)
|
||||
# Функции из этой темы срабатывает всегда вне зависимости от выбранной темы, функции из других тем работают только в этих темах
|
||||
def detail_page_style(stops):
|
||||
return f"""
|
||||
QWidget {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
{stops});
|
||||
border-radius: {border_radius_b};
|
||||
}}
|
||||
"""
|
||||
87
portprotonqt/themes/standart/styles/game_card.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from .constants import *
|
||||
|
||||
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
|
||||
GAME_CARD_WINDOW_STYLE = f"""
|
||||
QFrame {{
|
||||
border-radius: 20px;
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: {border_a} {color_g};
|
||||
}}
|
||||
"""
|
||||
|
||||
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
|
||||
GAME_CARD_NAME_LABEL_STYLE = f"""
|
||||
QLabel {{
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
font-weight: bold;
|
||||
background-color: {color_g};
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
padding: 14px, 7px, 3px, 7px;
|
||||
qproperty-wordWrap: true;
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
|
||||
def get_protondb_badge_style(tier):
|
||||
tier = tier.lower()
|
||||
tier_colors = {
|
||||
"platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
|
||||
"gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
|
||||
"silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
|
||||
"bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
|
||||
"borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
|
||||
"pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
|
||||
}
|
||||
colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||
return f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background-color: {colors["background"]};
|
||||
color: {colors["color"]};
|
||||
border-radius: 5px;
|
||||
font-family: '{font_family}';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА WEANTICHEATYET
|
||||
def get_anticheat_badge_style(status):
|
||||
status = status.lower()
|
||||
status_colors = {
|
||||
"supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
|
||||
"running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
|
||||
"planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
|
||||
"broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
|
||||
"denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
|
||||
}
|
||||
colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||
return f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background-color: {colors["background"]};
|
||||
color: {colors["color"]};
|
||||
font-size: {font_size_a};
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА STEAM
|
||||
STEAM_BADGE_STYLE= f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
font-family: '{font_family}';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
|
||||
LAST_LAUNCH_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
LAST_LAUNCH_VALUE_STYLE = f"font-family: '{font_family}'; font-size: 13px; color: {color_f}; font-weight: 600; letter-spacing: 0.75px;"
|
||||
PLAY_TIME_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
PLAY_TIME_VALUE_STYLE = f"font-family: '{font_family}'; font-size: 13px; color: {color_f}; font-weight: 600; letter-spacing: 0.75px;"
|
||||
GAMEPAD_SUPPORT_VALUE_STYLE = f"""
|
||||
font-family: '{font_family}'; font-size: {font_size_a}; color: #00ff00;
|
||||
font-weight: bold; background: {color_g};
|
||||
border-radius: 5px; padding: 4px 8px;
|
||||
"""
|
||||
111
portprotonqt/themes/standart/styles/settings.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from .constants import *
|
||||
|
||||
SETTINGS_COMBO_STYLE = f"""
|
||||
QComboBox {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
min-width: 120px;
|
||||
combobox-popup: 0;
|
||||
}}
|
||||
QComboBox:on {{
|
||||
background: {color_b};
|
||||
border: {border_c} {color_a};
|
||||
border-bottom-style: none;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}}
|
||||
QComboBox:hover {{
|
||||
border: {border_c} {color_a};
|
||||
background: {color_a};
|
||||
}}
|
||||
/* Состояние фокуса */
|
||||
QComboBox:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
border-left: {border_b} rgba(255, 255, 255, 0.05);
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow:on {{
|
||||
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
/* Список при открытом комбобоксе */
|
||||
QComboBox QAbstractItemView {{
|
||||
outline: none;
|
||||
border: {border_c} {color_a};
|
||||
border-top-style: none;
|
||||
border-top-left-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}}
|
||||
QListView {{
|
||||
background: {color_c};
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 7px 7px 7px 12px;
|
||||
margin: 3px;
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: {color_b};
|
||||
}}
|
||||
/* Выделение в списке при фокусе на элементе */
|
||||
QListView::item:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
"""
|
||||
|
||||
SETTINGS_CHECKBOX_STYLE = f"""
|
||||
QCheckBox {{
|
||||
height: 34px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QCheckBox::indicator {{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
background: {color_b};
|
||||
}}
|
||||
QCheckBox::indicator:hover {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QCheckBox::indicator:focus {{
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QCheckBox::indicator:checked {{
|
||||
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
"""
|
||||
84
portprotonqt/themes/standart/styles/theme_utils.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from .constants import *
|
||||
|
||||
CONTEXT_MENU_STYLE = f"""
|
||||
QMenu {{
|
||||
background: {color_b};
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
padding: 5px;
|
||||
min-width: 150px;
|
||||
}}
|
||||
QMenu::icon {{
|
||||
margin-left: 15px;
|
||||
}}
|
||||
QMenu::item {{
|
||||
padding: 10px 20px 10px 10px;
|
||||
background: {color_h};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QMenu::item:selected {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QMenu::item:disabled {{
|
||||
color: #7f7f7f;
|
||||
}}
|
||||
QMenu::item:hover {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QMenu::item:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
border: {border_b} rgba(255, 255, 255, 0.3);
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QMenu::separator {{
|
||||
height: 1px;
|
||||
background-color: #7f7f7f;
|
||||
margin: 3px 6px;
|
||||
}}
|
||||
"""
|
||||
|
||||
VIRTUAL_KEYBOARD_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_i};
|
||||
}}
|
||||
QPushButton {{
|
||||
font-size: 14px;
|
||||
border: {border_a} {color_h};
|
||||
border-radius: {border_radius_a};
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 5px;
|
||||
background-color: {color_c};
|
||||
color: {color_f};
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {color_a};
|
||||
border: {border_b} {color_a};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_b} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {color_c};
|
||||
border: {border_a} {color_h};
|
||||
}}
|
||||
QPushButton[checked="true"] {{
|
||||
background-color: {color_a};
|
||||
color: {color_f};
|
||||
border: {border_a} {color_h};
|
||||
}}
|
||||
QPushButton[checked="true"]:focus {{
|
||||
border: {border_b} {color_f};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
|
||||
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
CAPTION_LABEL_STYLE=f"color: white; font-size: {font_size_a};"
|
||||
317
portprotonqt/themes/standart/styles/winetricks.py
Normal file
@@ -0,0 +1,317 @@
|
||||
from .constants import *
|
||||
|
||||
WINETRICKS_TAB_STYLE = f"""
|
||||
QTabWidget::pane {{
|
||||
border-top: 1px solid {color_c};
|
||||
background: {color_h};
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {color_c};
|
||||
color: {color_f};
|
||||
padding: 8px 16px;
|
||||
border-top-left-radius: {border_radius_a};
|
||||
border-top-right-radius: {border_radius_a};
|
||||
margin-right: 2px;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QTabBar::tab:hover {{
|
||||
background: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
WINETRICKS_TABBLE_STYLE = f"""
|
||||
QComboBox {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
min-width: 120px;
|
||||
combobox-popup: 0;
|
||||
}}
|
||||
QComboBox:on {{
|
||||
background: {color_b};
|
||||
border: {border_c} {color_a};
|
||||
border-bottom-style: none;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}}
|
||||
QComboBox:hover {{
|
||||
border: {border_c} {color_a};
|
||||
background: {color_a};
|
||||
}}
|
||||
/* Состояние фокуса */
|
||||
QComboBox:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
QComboBox:disabled {{
|
||||
background: #2a2c35;
|
||||
border: {border_c} #2a2c35;
|
||||
color: #777a84;
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
border-left: {border_b} rgba(255, 255, 255, 0.05);
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow:on {{
|
||||
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
/* Список при открытом комбобоксе */
|
||||
QComboBox QAbstractItemView {{
|
||||
outline: none;
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
border-top-style: none;
|
||||
border-top-left-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}}
|
||||
QListView {{
|
||||
background: {color_c};
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 7px 7px 7px 12px;
|
||||
margin: 3px;
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: {color_b};
|
||||
}}
|
||||
/* Выделение в списке при фокусе на элементе */
|
||||
QListView::item:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QLineEdit {{
|
||||
background: {color_c};
|
||||
border: {border_c} rgba(255, 255, 255, 0.01);
|
||||
border-radius: {border_radius_a};
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QLineEdit:hover {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_e};
|
||||
}}
|
||||
QTableWidget {{
|
||||
background: {color_h};
|
||||
color: {color_f};
|
||||
gridline-color: {color_h};
|
||||
alternate-background-color: {color_d};
|
||||
border: {border_a};
|
||||
border-radius: {border_radius_a};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QHeaderView::section {{
|
||||
background: {color_d};
|
||||
color: {color_f};
|
||||
padding: 5px;
|
||||
border: {border_a};
|
||||
font-weight: bold;
|
||||
}}
|
||||
QTableWidget::item {{
|
||||
padding: 8px;
|
||||
border-bottom: {border_a } {color_c};
|
||||
height: 36px;
|
||||
}}
|
||||
QTableWidget::item:selected,
|
||||
QTableWidget::item:focus,
|
||||
QTableWidget::item:selected:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
selection-background-color: {color_a};
|
||||
}}
|
||||
QTableWidget::item:hover {{
|
||||
background: {color_h};
|
||||
}}
|
||||
QTableWidget::indicator {{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: {border_c} {color_h};
|
||||
border-radius: {border_radius_a};
|
||||
background: {color_b};
|
||||
}}
|
||||
QTableWidget::indicator:unchecked {{
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
image: none;
|
||||
}}
|
||||
QTableWidget::indicator:checked {{
|
||||
background: {color_b};
|
||||
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QTableWidget::indicator:hover {{
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QTableWidget::indicator:focus {{
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:vertical {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::sub-line:vertical {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar:horizontal {{
|
||||
height: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:horizontal {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::sub-line:horizontal {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
WINETRICKS_LOG_STYLE = f"""
|
||||
QTextEdit {{
|
||||
background: {color_c};
|
||||
border: {border_a};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
padding: 5px;
|
||||
}}
|
||||
"""
|
||||
|
||||
FILE_EXPLORER_STYLE = f"""
|
||||
QListView {{
|
||||
font-size: {font_size_a};
|
||||
font-family: {font_family};
|
||||
background: {color_c};
|
||||
alternate-background-color: {color_c};
|
||||
color: {color_f};
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 8px;
|
||||
margin: 0px 5px;
|
||||
}}
|
||||
QListView::item:alternate {{
|
||||
margin: 0px 5px;
|
||||
background: {color_d};
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QListView::item:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: {color_c};
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:vertical {{
|
||||
border: {border_a};
|
||||
background: {color_c};
|
||||
border-bottom-right-radius: 5px;
|
||||
}}
|
||||
QScrollBar::sub-line:vertical {{
|
||||
border: {border_a};
|
||||
background: {color_c};
|
||||
border-top-right-radius: 5px;
|
||||
}}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
FILE_EXPLORER_PATH_LABEL_STYLE = f"""
|
||||
QLabel {{
|
||||
color: {color_a};
|
||||
font-size: {font_size_a};
|
||||
font-family: {font_family};
|
||||
}}
|
||||
"""
|
||||