forked from Boria138/PortProtonQt
Compare commits
20 Commits
233dab1269
...
v0.1.4
Author | SHA1 | Date | |
---|---|---|---|
85e9aba836
|
|||
4d3499d2c1
|
|||
a13c15bc28
|
|||
83076d3dfc
|
|||
04aaf68e36
|
|||
e91037708a
|
|||
1b743026c2
|
|||
30b4cec4d1
|
|||
db68c9050c
|
|||
1a93d5b82c
|
|||
cc0690cf9e
|
|||
809ba2c976
|
|||
68c9636e10
|
|||
f0df1f89be
|
|||
f25224b668
|
|||
0cda47fdfd
|
|||
1a8c733580
|
|||
2476bea32a
|
|||
1bbc95a5c1
|
|||
d12b801191
|
@@ -17,10 +17,12 @@ jobs:
|
|||||||
- name: Install required dependencies
|
- name: Install required dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync
|
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git
|
||||||
|
|
||||||
- name: Install tools
|
- name: Install tools
|
||||||
run: pip3 install appimage-builder uv
|
run: |
|
||||||
|
pip3 install git+https://github.com/Frederic98/appimage-builder.git
|
||||||
|
pip3 install uv
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
|
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Common version, will be used for tagging the release
|
# Common version, will be used for tagging the release
|
||||||
VERSION: 0.1.3
|
VERSION: 0.1.4
|
||||||
PKGDEST: "/tmp/portprotonqt"
|
PKGDEST: "/tmp/portprotonqt"
|
||||||
PACKAGE: "portprotonqt"
|
PACKAGE: "portprotonqt"
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -23,10 +23,12 @@ jobs:
|
|||||||
- name: Install required dependencies
|
- name: Install required dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync
|
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git
|
||||||
|
|
||||||
- name: Install tools
|
- name: Install tools
|
||||||
run: pip3 install appimage-builder uv
|
run: |
|
||||||
|
pip3 install git+https://github.com/Frederic98/appimage-builder.git
|
||||||
|
pip3 install uv
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
|
187
.gitea/workflows/code-build.yml
Normal file
187
.gitea/workflows/code-build.yml
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
name: Build Check - AppImage, Arch, Fedora
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'build-aux/**'
|
||||||
|
|
||||||
|
env:
|
||||||
|
PKGDEST: "/tmp/portprotonqt"
|
||||||
|
PACKAGE: "portprotonqt"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
appimage: ${{ steps.check.outputs.appimage }}
|
||||||
|
fedora: ${{ steps.check.outputs.fedora }}
|
||||||
|
arch: ${{ steps.check.outputs.arch }}
|
||||||
|
steps:
|
||||||
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Ensure git is installed
|
||||||
|
run: |
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y git
|
||||||
|
|
||||||
|
- name: Check changed files
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
# Get changed files
|
||||||
|
git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} > changed_files.txt
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
cat changed_files.txt
|
||||||
|
|
||||||
|
# Check AppImage files
|
||||||
|
if grep -q "build-aux/AppImageBuilder.yml" changed_files.txt; then
|
||||||
|
echo "appimage=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "appimage=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Fedora spec files (only fedora-git.spec)
|
||||||
|
if grep -q "build-aux/fedora-git.spec" changed_files.txt; then
|
||||||
|
echo "fedora=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "fedora=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Arch PKGBUILD-git
|
||||||
|
if grep -q "build-aux/PKGBUILD-git" changed_files.txt; then
|
||||||
|
echo "arch=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "arch=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-appimage:
|
||||||
|
name: Build AppImage
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
|
steps:
|
||||||
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install required dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git
|
||||||
|
|
||||||
|
- name: Install tools
|
||||||
|
run: |
|
||||||
|
pip3 install git+https://github.com/Frederic98/appimage-builder.git
|
||||||
|
pip3 install uv
|
||||||
|
|
||||||
|
- name: Build AppImage
|
||||||
|
run: |
|
||||||
|
cd build-aux
|
||||||
|
appimage-builder
|
||||||
|
|
||||||
|
- name: Upload AppImage
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: PortProtonQt-AppImage
|
||||||
|
path: build-aux/PortProtonQt*.AppImage
|
||||||
|
|
||||||
|
build-fedora:
|
||||||
|
name: Build Fedora RPM
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.fedora == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
fedora_version: [41, 42, rawhide]
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: fedora:${{ matrix.fedora_version }}
|
||||||
|
options: --privileged
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
|
||||||
|
python3-build pyproject-rpm-macros python3-setuptools \
|
||||||
|
redhat-rpm-config nodejs npm
|
||||||
|
|
||||||
|
- name: Setup rpmbuild environment
|
||||||
|
run: |
|
||||||
|
useradd rpmbuild -u 5002 -g users || true
|
||||||
|
mkdir -p /home/rpmbuild/{BUILD,RPMS,SPECS,SRPMS,SOURCES}
|
||||||
|
chown -R rpmbuild:users /home/rpmbuild
|
||||||
|
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||||
|
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Copy fedora-git.spec
|
||||||
|
run: |
|
||||||
|
cp build-aux/fedora-git.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
|
||||||
|
chown -R rpmbuild:users /home/rpmbuild
|
||||||
|
|
||||||
|
- name: Build RPM
|
||||||
|
run: |
|
||||||
|
su rpmbuild -c "rpmbuild -ba /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec"
|
||||||
|
|
||||||
|
- name: Upload RPM package
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: PortProtonQt-RPM-Fedora-${{ matrix.fedora_version }}
|
||||||
|
path: /home/rpmbuild/RPMS/**/*.rpm
|
||||||
|
|
||||||
|
build-arch:
|
||||||
|
name: Build Arch Package
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
|
container:
|
||||||
|
image: archlinux:base-devel
|
||||||
|
volumes:
|
||||||
|
- /usr:/usr-host
|
||||||
|
- /opt:/opt-host
|
||||||
|
options: --privileged
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Prepare container
|
||||||
|
run: |
|
||||||
|
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||||
|
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||||
|
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||||
|
yes | pacman -Scc
|
||||||
|
pacman-key --init
|
||||||
|
pacman -S --noconfirm archlinux-keyring
|
||||||
|
mkdir -p /__w/portproton-repo
|
||||||
|
pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com
|
||||||
|
pacman-key --lsign-key 3056513887B78AEB
|
||||||
|
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst'
|
||||||
|
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst'
|
||||||
|
cat << EOM >> /etc/pacman.conf
|
||||||
|
|
||||||
|
[chaotic-aur]
|
||||||
|
Include = /etc/pacman.d/chaotic-mirrorlist
|
||||||
|
EOM
|
||||||
|
pacman -Syy
|
||||||
|
useradd -m user -G wheel && echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||||
|
echo "PACKAGER=\"Boris Yumankulov <boria138@altlinux.org>\"" >> /etc/makepkg.conf
|
||||||
|
chown user -R /tmp
|
||||||
|
chown user -R ..
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
cd /__w/portproton-repo
|
||||||
|
git clone https://git.linux-gaming.ru/Boria138/PortProtonQt.git
|
||||||
|
cd /__w/portproton-repo/PortProtonQt/build-aux
|
||||||
|
chown user -R ..
|
||||||
|
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Upload Arch package
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: PortProtonQt-Arch
|
||||||
|
path: ${{ env.PKGDEST }}/*
|
@@ -1,4 +1,4 @@
|
|||||||
name: Code and build check
|
name: Code check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -35,20 +35,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||||
|
|
||||||
build-uv:
|
|
||||||
name: Build with uv
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: https://github.com/astral-sh/setup-uv@v6
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- name: Sync dependencies
|
|
||||||
run: uv sync
|
|
||||||
|
|
||||||
- name: Build project
|
|
||||||
run: uv build
|
|
||||||
|
@@ -3,21 +3,27 @@
|
|||||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.1.4] - 2025-07-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Переводы в переопределениях (за подробностями в документацию)
|
- Переводы в переопределениях (за подробностями в документацию)
|
||||||
- Обложки и описания для всех автоинсталлов
|
- Обложки и описания для всех автоинсталлов
|
||||||
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры
|
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры
|
||||||
|
- Интеграция с howlongtobeat.com
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Оптимизированны обложки автоинсталлов
|
- Оптимизированны обложки автоинсталлов
|
||||||
- Папка custom_data исключена из сборки модуля для уменьшение его размера
|
- Папка custom_data исключена из сборки модуля для уменьшение его размера
|
||||||
- Бейдж PortProton теперь открывает PortProtonDB
|
- Бейдж PortProton теперь открывает PortProtonDB
|
||||||
|
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии
|
||||||
|
- Удалён аргумент `--session` так как тестирование gamescope сессии завершено
|
||||||
|
- В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton"
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
|
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
|
||||||
- Путь к portprotonqt-session-select в оверлее
|
- Путь к portprotonqt-session-select в оверлее
|
||||||
|
- Работа exiftool в AppImage
|
||||||
|
- Открытие контекстного меню у игр без exe
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
- @Vector_null
|
- @Vector_null
|
||||||
|
@@ -51,11 +51,11 @@ pre-commit run --all-files
|
|||||||
|
|
||||||
PortProtonQt использует код и зависимости от следующих проектов:
|
PortProtonQt использует код и зависимости от следующих проектов:
|
||||||
|
|
||||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html).
|
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
|
||||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://opensource.org/licenses/MIT).
|
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
|
||||||
- [PortProton 2.0](https://git.linux-gaming.ru/CastroFidel/PortProton_2.0) — библиотека для взаимодействия с PortProton, лицензия [MIT](https://opensource.org/licenses/MIT).
|
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
|
||||||
|
|
||||||
Полный текст лицензий см. в файлах [LICENSE](LICENSE), [LICENSE-icoextract](documentation/licenses/icoextract), [LICENSE-portproton](documentation/licenses/portproton), [LICENSE-legendary](documentation/licenses/legendary).
|
Полный текст лицензий см. в файле [LICENSE](LICENSE).
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
|
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
|
||||||
|
5
TODO.md
5
TODO.md
@@ -17,7 +17,6 @@
|
|||||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
||||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
||||||
- [X] Избавиться от вызовов yad
|
- [X] Избавиться от вызовов yad
|
||||||
- [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0))
|
|
||||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
||||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
|
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
|
||||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
||||||
@@ -42,6 +41,7 @@
|
|||||||
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
||||||
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
|
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
|
||||||
- [X] Добавить поддержку версий Steam для Flatpak и Snap
|
- [X] Добавить поддержку версий Steam для Flatpak и Snap
|
||||||
|
- [ ] Реализовать добавление игры как сторонней в Steam без перезапуска
|
||||||
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
|
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
|
||||||
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
|
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
|
||||||
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
|
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
|
||||||
@@ -57,13 +57,12 @@
|
|||||||
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
|
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
|
||||||
- [ ] Добавить поддержку GOG (?)
|
- [ ] Добавить поддержку GOG (?)
|
||||||
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
||||||
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?)
|
- [X] Добавить данные с HowLongToBeat на страницу с деталями игры
|
||||||
- [X] Добавить виброотдачу на геймпаде при запуске игры
|
- [X] Добавить виброотдачу на геймпаде при запуске игры
|
||||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
||||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
||||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||||
- [ ] Доделать светлую тему
|
- [ ] Доделать светлую тему
|
||||||
- [ ] Добавить подсказки к управлению с геймпада
|
- [ ] Добавить подсказки к управлению с геймпада
|
||||||
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
|
|
||||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
||||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
version: 1
|
version: 1
|
||||||
|
|
||||||
script:
|
script:
|
||||||
# 1) чистим старый AppDir
|
# 1) чистим старый AppDir
|
||||||
- rm -rf AppDir || true
|
- rm -rf AppDir || true
|
||||||
@@ -17,26 +16,45 @@ script:
|
|||||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*}
|
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*}
|
||||||
- shopt -s extglob
|
- shopt -s extglob
|
||||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*)
|
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*)
|
||||||
|
|
||||||
AppDir:
|
AppDir:
|
||||||
path: ./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
|
||||||
|
# Strip ELF бинарников (исключая Python extensions)
|
||||||
|
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
|
||||||
|
# Удаление пустых папок
|
||||||
|
- find $TARGET_APPDIR -type d -empty -delete || true
|
||||||
app_info:
|
app_info:
|
||||||
id: ru.linux_gaming.PortProtonQt
|
id: ru.linux_gaming.PortProtonQt
|
||||||
name: PortProtonQt
|
name: PortProtonQt
|
||||||
icon: ru.linux_gaming.PortProtonQt
|
icon: ru.linux_gaming.PortProtonQt
|
||||||
version: 0.1.3
|
version: 0.1.4
|
||||||
exec: usr/bin/python3
|
exec: usr/bin/python3
|
||||||
exec_args: "-m portprotonqt.app $@"
|
exec_args: "-m portprotonqt.app $@"
|
||||||
|
|
||||||
apt:
|
apt:
|
||||||
arch: amd64
|
arch: amd64
|
||||||
sources:
|
sources:
|
||||||
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
|
- 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'
|
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
|
||||||
|
|
||||||
include:
|
include:
|
||||||
- python3
|
- python3-minimal
|
||||||
- python3-pkg-resources
|
- python3-pkg-resources
|
||||||
- libopengl0
|
- libopengl0
|
||||||
- libk5crypto3
|
- libk5crypto3
|
||||||
@@ -45,13 +63,24 @@ AppDir:
|
|||||||
- libxcb-cursor0
|
- libxcb-cursor0
|
||||||
- libimage-exiftool-perl
|
- libimage-exiftool-perl
|
||||||
- xdg-utils
|
- xdg-utils
|
||||||
exclude: []
|
exclude:
|
||||||
|
# Документация и man-страницы
|
||||||
|
- "*-doc"
|
||||||
|
- "*-man"
|
||||||
|
- manpages
|
||||||
|
- mandb
|
||||||
|
# Статические библиотеки
|
||||||
|
- "*-dev"
|
||||||
|
- "*-static"
|
||||||
|
# Дебаг-символы
|
||||||
|
- "*-dbg"
|
||||||
|
- "*-dbgsym"
|
||||||
runtime:
|
runtime:
|
||||||
env:
|
env:
|
||||||
PYTHONHOME: '${APPDIR}/usr'
|
PYTHONHOME: '${APPDIR}/usr'
|
||||||
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
|
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:
|
AppImage:
|
||||||
sign-key: None
|
sign-key: None
|
||||||
|
comp: xz
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
pkgname=portprotonqt
|
pkgname=portprotonqt
|
||||||
pkgver=0.1.3
|
pkgver=0.1.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
%global pypi_name portprotonqt
|
%global pypi_name portprotonqt
|
||||||
%global pypi_version 0.1.3
|
%global pypi_version 0.1.4
|
||||||
%global oname PortProtonQt
|
%global oname PortProtonQt
|
||||||
%global _python_no_extras_requires 1
|
%global _python_no_extras_requires 1
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ _portprotonqt() {
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ "$cur" == -* ]]; then
|
if [[ "$cur" == -* ]]; then
|
||||||
COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) )
|
COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@@ -20,9 +20,9 @@ Current translation status:
|
|||||||
|
|
||||||
| Locale | Progress | Translated |
|
| Locale | Progress | Translated |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 194 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 197 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 194 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 197 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 of 194 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 of 197 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -20,9 +20,9 @@
|
|||||||
|
|
||||||
| Локаль | Прогресс | Переведено |
|
| Локаль | Прогресс | Переведено |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 194 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 197 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 194 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 197 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 из 194 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 из 197 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -1,6 +1,4 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
from PySide6.QtGui import QIcon
|
from PySide6.QtGui import QIcon
|
||||||
@@ -14,7 +12,7 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||||
__app_name__ = "PortProtonQt"
|
__app_name__ = "PortProtonQt"
|
||||||
__app_version__ = "0.1.3"
|
__app_version__ = "0.1.4"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
@@ -35,13 +33,6 @@ def main():
|
|||||||
|
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
|
|
||||||
if args.session:
|
|
||||||
gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2")
|
|
||||||
cmd = f"{gamescope_cmd} -- portprotonqt"
|
|
||||||
logger.info(f"Executing: {cmd}")
|
|
||||||
subprocess.Popen(cmd, shell=True)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if args.fullscreen:
|
if args.fullscreen:
|
||||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||||
save_fullscreen_config(True)
|
save_fullscreen_config(True)
|
||||||
|
@@ -13,9 +13,4 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--session",
|
|
||||||
action="store_true",
|
|
||||||
help="Запустить приложение с использованием gamescope"
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
@@ -148,10 +148,7 @@ class ContextMenuManager:
|
|||||||
return False
|
return False
|
||||||
current_exe = os.path.basename(exe_path)
|
current_exe = os.path.basename(exe_path)
|
||||||
|
|
||||||
# Check if the current_exe matches the target_exe in MainWindow
|
return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
|
||||||
if hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def show_context_menu(self, game_card, pos: QPoint):
|
def show_context_menu(self, game_card, pos: QPoint):
|
||||||
"""
|
"""
|
||||||
@@ -161,7 +158,6 @@ class ContextMenuManager:
|
|||||||
game_card: The GameCard instance requesting the context menu.
|
game_card: The GameCard instance requesting the context menu.
|
||||||
pos: The position (in widget coordinates) where the menu should appear.
|
pos: The position (in widget coordinates) where the menu should appear.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_safe_icon(icon_name: str) -> QIcon:
|
def get_safe_icon(icon_name: str) -> QIcon:
|
||||||
icon = self.theme_manager.get_icon(icon_name)
|
icon = self.theme_manager.get_icon(icon_name)
|
||||||
if isinstance(icon, QIcon):
|
if isinstance(icon, QIcon):
|
||||||
@@ -173,7 +169,18 @@ class ContextMenuManager:
|
|||||||
menu = QMenu(self.parent)
|
menu = QMenu(self.parent)
|
||||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||||
|
|
||||||
# Check if the game is running
|
# For non-Steam and non-Epic games, check if exe exists
|
||||||
|
if game_card.game_source not in ("steam", "epic"):
|
||||||
|
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||||
|
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
|
||||||
|
if not exe_path:
|
||||||
|
# Show only "Delete from PortProton" if no valid exe
|
||||||
|
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
|
||||||
|
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||||
|
menu.exec(game_card.mapToGlobal(pos))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Normal menu for games with valid exe or from Steam/Epic
|
||||||
is_running = self._is_game_running(game_card)
|
is_running = self._is_game_running(game_card)
|
||||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||||
action_icon = "stop" if is_running else "play"
|
action_icon = "stop" if is_running else "play"
|
||||||
@@ -697,15 +704,12 @@ Icon={icon_path}
|
|||||||
return None
|
return None
|
||||||
return exec_line
|
return exec_line
|
||||||
|
|
||||||
def _parse_exe_path(self, exec_line, game_name):
|
def _parse_exe_path(self, exec_line: str, game_name: str) -> str | None:
|
||||||
"""Parse the executable path from exec_line."""
|
"""Parse the executable path from exec_line."""
|
||||||
try:
|
try:
|
||||||
entry_exec_split = shlex.split(exec_line)
|
entry_exec_split = shlex.split(exec_line)
|
||||||
if not entry_exec_split:
|
if not entry_exec_split:
|
||||||
self.signals.show_warning_dialog.emit(
|
logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
|
||||||
_("Error"),
|
|
||||||
_("Invalid executable command: {exec_line}").format(exec_line=exec_line)
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
|
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
|
||||||
exe_path = entry_exec_split[2]
|
exe_path = entry_exec_split[2]
|
||||||
@@ -714,17 +718,11 @@ Icon={icon_path}
|
|||||||
else:
|
else:
|
||||||
exe_path = entry_exec_split[-1]
|
exe_path = entry_exec_split[-1]
|
||||||
if not exe_path or not os.path.exists(exe_path):
|
if not exe_path or not os.path.exists(exe_path):
|
||||||
self.signals.show_warning_dialog.emit(
|
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
|
||||||
_("Error"),
|
|
||||||
_("Executable not found: {path}").format(path=exe_path or "None")
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
return exe_path
|
return exe_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.signals.show_warning_dialog.emit(
|
logger.debug("Failed to parse executable for '%s': %s", game_name, e)
|
||||||
_("Error"),
|
|
||||||
_("Failed to parse executable: {error}").format(error=str(e))
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
|
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
|
||||||
|
@@ -1,71 +1,37 @@
|
|||||||
import orjson
|
import orjson
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
from threading import Thread
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup, Tag
|
from bs4 import BeautifulSoup, Tag
|
||||||
from portprotonqt.config_utils import read_proxy_config
|
from portprotonqt.config_utils import read_proxy_config
|
||||||
|
from portprotonqt.time_utils import format_playtime
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
class SearchModifiers(Enum):
|
|
||||||
"""Модификаторы поиска для фильтрации результатов."""
|
|
||||||
NONE = ""
|
|
||||||
ONLY_DLC = "only_dlc"
|
|
||||||
ONLY_MODS = "only_mods"
|
|
||||||
ONLY_HACKS = "only_hacks"
|
|
||||||
HIDE_DLC = "hide_dlc"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GameEntry:
|
class GameEntry:
|
||||||
"""Информация об игре из HowLongToBeat."""
|
"""Информация об игре из HowLongToBeat."""
|
||||||
# Основная информация
|
|
||||||
game_id: int = -1
|
game_id: int = -1
|
||||||
game_name: str | None = None
|
game_name: str | None = None
|
||||||
game_alias: str | None = None
|
|
||||||
game_type: str | None = None
|
|
||||||
game_image_url: str | None = None
|
|
||||||
game_web_link: str | None = None
|
|
||||||
review_score: float | None = None
|
|
||||||
developer: str | None = None
|
|
||||||
platforms: list[str] = field(default_factory=list)
|
|
||||||
release_year: int | None = None
|
|
||||||
similarity: float = -1.0
|
|
||||||
|
|
||||||
# Времена прохождения (в часах)
|
|
||||||
main_story: float | None = None
|
main_story: float | None = None
|
||||||
main_extra: float | None = None
|
main_extra: float | None = None
|
||||||
completionist: float | None = None
|
completionist: float | None = None
|
||||||
all_styles: float | None = None
|
similarity: float = -1.0
|
||||||
coop_time: float | None = None
|
|
||||||
multiplayer_time: float | None = None
|
|
||||||
|
|
||||||
# Флаги сложности
|
|
||||||
has_single_player: bool = False
|
|
||||||
has_coop: bool = False
|
|
||||||
has_multiplayer: bool = False
|
|
||||||
has_combined_complexity: bool = False
|
|
||||||
|
|
||||||
# Исходные данные JSON
|
|
||||||
raw_data: dict[str, Any] = field(default_factory=dict)
|
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SearchConfig:
|
class SearchConfig:
|
||||||
"""Конфигурация для поиска."""
|
"""Конфигурация для поиска."""
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
search_url: str | None = None
|
search_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class APIKeyExtractor:
|
class APIKeyExtractor:
|
||||||
"""Извлекает API ключ и URL поиска из скриптов сайта."""
|
"""Извлекает API ключ и URL поиска из скриптов сайта."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract_from_script(script_content: str) -> SearchConfig:
|
def extract_from_script(script_content: str) -> SearchConfig:
|
||||||
"""Извлекает конфигурацию из содержимого скрипта."""
|
|
||||||
config = SearchConfig()
|
config = SearchConfig()
|
||||||
config.api_key = APIKeyExtractor._extract_api_key(script_content)
|
config.api_key = APIKeyExtractor._extract_api_key(script_content)
|
||||||
config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key)
|
config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key)
|
||||||
@@ -73,53 +39,40 @@ class APIKeyExtractor:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_api_key(script_content: str) -> str | None:
|
def _extract_api_key(script_content: str) -> str | None:
|
||||||
"""Извлекает API ключ из скрипта."""
|
|
||||||
# Паттерн для поиска user ID
|
|
||||||
user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"'
|
user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"'
|
||||||
matches = re.findall(user_id_pattern, script_content)
|
matches = re.findall(user_id_pattern, script_content)
|
||||||
if matches:
|
if matches:
|
||||||
return ''.join(matches)
|
return ''.join(matches)
|
||||||
|
|
||||||
# Паттерн для поиска конкатенированного API ключа
|
|
||||||
concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+'
|
concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+'
|
||||||
matches = re.findall(concat_pattern, script_content)
|
matches = re.findall(concat_pattern, script_content)
|
||||||
if matches:
|
if matches:
|
||||||
parts = str(matches).split('.concat')
|
parts = str(matches).split('.concat')
|
||||||
cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]]
|
cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]]
|
||||||
return ''.join(cleaned_parts)
|
return ''.join(cleaned_parts)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_search_url(script_content: str, api_key: str | None) -> str | None:
|
def _extract_search_url(script_content: str, api_key: str | None) -> str | None:
|
||||||
"""Извлекает URL поиска из скрипта."""
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
pattern = re.compile(
|
pattern = re.compile(
|
||||||
r'fetch\(\s*["\'](\/api\/[^"\']*)["\']'
|
r'fetch\(\s*["\'](\/api\/[^"\']*)["\']'
|
||||||
r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)'
|
r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)'
|
||||||
r'\s*,',
|
r'\s*,',
|
||||||
re.DOTALL
|
re.DOTALL
|
||||||
)
|
)
|
||||||
|
|
||||||
for match in pattern.finditer(script_content):
|
for match in pattern.finditer(script_content):
|
||||||
endpoint = match.group(1)
|
endpoint = match.group(1)
|
||||||
concat_calls = match.group(2)
|
concat_calls = match.group(2)
|
||||||
concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls)
|
concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls)
|
||||||
concatenated_str = ''.join(concat_strings)
|
concatenated_str = ''.join(concat_strings)
|
||||||
|
|
||||||
if concatenated_str == api_key:
|
if concatenated_str == api_key:
|
||||||
return endpoint
|
return endpoint
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class HTTPClient:
|
class HTTPClient:
|
||||||
"""HTTP клиент для работы с API HowLongToBeat."""
|
"""HTTP клиент для работы с API HowLongToBeat."""
|
||||||
|
|
||||||
BASE_URL = 'https://howlongtobeat.com/'
|
BASE_URL = 'https://howlongtobeat.com/'
|
||||||
GAME_URL = BASE_URL + "game"
|
|
||||||
SEARCH_URL = BASE_URL + "api/s/"
|
SEARCH_URL = BASE_URL + "api/s/"
|
||||||
|
|
||||||
def __init__(self, timeout: int = 60):
|
def __init__(self, timeout: int = 60):
|
||||||
@@ -129,35 +82,23 @@ class HTTPClient:
|
|||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
'referer': self.BASE_URL
|
'referer': self.BASE_URL
|
||||||
})
|
})
|
||||||
# Apply proxy settings from config
|
|
||||||
proxy_config = read_proxy_config()
|
proxy_config = read_proxy_config()
|
||||||
if proxy_config:
|
if proxy_config:
|
||||||
self.session.proxies.update(proxy_config)
|
self.session.proxies.update(proxy_config)
|
||||||
|
|
||||||
def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None:
|
def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None:
|
||||||
"""Получает конфигурацию поиска с главной страницы."""
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(self.BASE_URL, timeout=self.timeout)
|
response = self.session.get(self.BASE_URL, timeout=self.timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
scripts = soup.find_all('script', src=True)
|
scripts = soup.find_all('script', src=True)
|
||||||
|
|
||||||
# Filter for Tag objects and ensure src is a string
|
|
||||||
if parse_all_scripts:
|
|
||||||
script_urls = []
|
script_urls = []
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if isinstance(script, Tag):
|
if isinstance(script, Tag):
|
||||||
src = script.get('src')
|
src = script.get('src')
|
||||||
if src is not None and isinstance(src, str):
|
if src is not None and isinstance(src, str):
|
||||||
|
if parse_all_scripts or '_app-' in src:
|
||||||
script_urls.append(src)
|
script_urls.append(src)
|
||||||
else:
|
|
||||||
script_urls = []
|
|
||||||
for script in scripts:
|
|
||||||
if isinstance(script, Tag):
|
|
||||||
src = script.get('src')
|
|
||||||
if src is not None and isinstance(src, str) and '_app-' in src:
|
|
||||||
script_urls.append(src)
|
|
||||||
|
|
||||||
for script_url in script_urls:
|
for script_url in script_urls:
|
||||||
full_url = self.BASE_URL + script_url
|
full_url = self.BASE_URL + script_url
|
||||||
script_response = self.session.get(full_url, timeout=self.timeout)
|
script_response = self.session.get(full_url, timeout=self.timeout)
|
||||||
@@ -169,28 +110,21 @@ class HTTPClient:
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def search_games(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE,
|
def search_games(self, game_name: str, page: int = 1, config: SearchConfig | None = None) -> str | None:
|
||||||
page: int = 1, config: SearchConfig | None = None) -> str | None:
|
|
||||||
"""Выполняет поиск игр."""
|
|
||||||
if not config:
|
if not config:
|
||||||
config = self.get_search_config()
|
config = self.get_search_config()
|
||||||
if not config:
|
if not config:
|
||||||
config = self.get_search_config(parse_all_scripts=True)
|
config = self.get_search_config(parse_all_scripts=True)
|
||||||
|
|
||||||
if not config or not config.api_key:
|
if not config or not config.api_key:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
search_url = self.SEARCH_URL
|
search_url = self.SEARCH_URL
|
||||||
if config.search_url:
|
if config.search_url:
|
||||||
search_url = self.BASE_URL + config.search_url.lstrip('/')
|
search_url = self.BASE_URL + config.search_url.lstrip('/')
|
||||||
|
payload = self._build_search_payload(game_name, page, config)
|
||||||
payload = self._build_search_payload(game_name, search_modifiers, page, config)
|
|
||||||
headers = {
|
headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
'accept': '*/*'
|
'accept': '*/*'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Попытка с API ключом в URL
|
|
||||||
try:
|
try:
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
search_url + config.api_key,
|
search_url + config.api_key,
|
||||||
@@ -202,8 +136,6 @@ class HTTPClient:
|
|||||||
return response.text
|
return response.text
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Попытка с API ключом в payload
|
|
||||||
try:
|
try:
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
search_url,
|
search_url,
|
||||||
@@ -215,37 +147,14 @@ class HTTPClient:
|
|||||||
return response.text
|
return response.text
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_game_title(self, game_id: int) -> str | None:
|
def _build_search_payload(self, game_name: str, page: int, config: SearchConfig) -> dict[str, Any]:
|
||||||
"""Получает название игры по ID."""
|
|
||||||
try:
|
|
||||||
params = {'id': str(game_id)}
|
|
||||||
response = self.session.get(self.GAME_URL, params=params, timeout=self.timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
|
||||||
title_tag = soup.title
|
|
||||||
|
|
||||||
if title_tag and title_tag.string:
|
|
||||||
# Обрезаем стандартные части заголовка
|
|
||||||
title = title_tag.string[12:-17].strip()
|
|
||||||
return title
|
|
||||||
|
|
||||||
except requests.RequestException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _build_search_payload(self, game_name: str, search_modifiers: SearchModifiers,
|
|
||||||
page: int, config: SearchConfig) -> dict[str, Any]:
|
|
||||||
"""Строит payload для поискового запроса."""
|
|
||||||
payload = {
|
payload = {
|
||||||
'searchType': "games",
|
'searchType': "games",
|
||||||
'searchTerms': game_name.split(),
|
'searchTerms': game_name.split(),
|
||||||
'searchPage': page,
|
'searchPage': page,
|
||||||
'size': 20,
|
'size': 1, # Limit to 1 result
|
||||||
'searchOptions': {
|
'searchOptions': {
|
||||||
'games': {
|
'games': {
|
||||||
'userId': 0,
|
'userId': 0,
|
||||||
@@ -260,7 +169,7 @@ class HTTPClient:
|
|||||||
"difficulty": ""
|
"difficulty": ""
|
||||||
},
|
},
|
||||||
'rangeYear': {'max': "", 'min': ""},
|
'rangeYear': {'max': "", 'min': ""},
|
||||||
'modifier': search_modifiers.value,
|
'modifier': "" # Hardcoded to empty string for SearchModifiers.NONE
|
||||||
},
|
},
|
||||||
'users': {'sortCategory': "postcount"},
|
'users': {'sortCategory': "postcount"},
|
||||||
'lists': {'sortCategory': "follows"},
|
'lists': {'sortCategory': "follows"},
|
||||||
@@ -268,194 +177,195 @@ class HTTPClient:
|
|||||||
'sort': 0,
|
'sort': 0,
|
||||||
'randomizer': 0
|
'randomizer': 0
|
||||||
},
|
},
|
||||||
'useCache': True
|
'useCache': True,
|
||||||
|
'fields': ["game_id", "game_name", "comp_main", "comp_plus", "comp_100"] # Request only needed fields
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.api_key:
|
if config.api_key:
|
||||||
payload['searchOptions']['users']['id'] = config.api_key
|
payload['searchOptions']['users']['id'] = config.api_key
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
class ResultParser:
|
class ResultParser:
|
||||||
"""Парсер результатов поиска."""
|
"""Парсер результатов поиска."""
|
||||||
|
def __init__(self, search_query: str, minimum_similarity: float = 0.4, case_sensitive: bool = True):
|
||||||
IMAGE_URL_PREFIX = "https://howlongtobeat.com/games/"
|
|
||||||
GAME_URL_PREFIX = "https://howlongtobeat.com/game/"
|
|
||||||
|
|
||||||
def __init__(self, search_query: str, minimum_similarity: float = 0.4,
|
|
||||||
case_sensitive: bool = True, auto_filter_times: bool = False):
|
|
||||||
self.search_query = search_query
|
self.search_query = search_query
|
||||||
self.minimum_similarity = minimum_similarity
|
self.minimum_similarity = minimum_similarity
|
||||||
self.case_sensitive = case_sensitive
|
self.case_sensitive = case_sensitive
|
||||||
self.auto_filter_times = auto_filter_times
|
|
||||||
self.search_numbers = self._extract_numbers(search_query)
|
self.search_numbers = self._extract_numbers(search_query)
|
||||||
|
|
||||||
def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]:
|
def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]:
|
||||||
"""Парсит JSON ответ и возвращает список игр."""
|
|
||||||
try:
|
try:
|
||||||
data = orjson.loads(json_response)
|
data = orjson.loads(json_response)
|
||||||
games = []
|
games = []
|
||||||
|
# Only process the first result
|
||||||
for game_data in data.get("data", []):
|
if data.get("data"):
|
||||||
|
game_data = data["data"][0]
|
||||||
game = self._parse_game_entry(game_data)
|
game = self._parse_game_entry(game_data)
|
||||||
|
|
||||||
if target_game_id is not None:
|
if target_game_id is not None:
|
||||||
if game.game_id == target_game_id:
|
if game.game_id == target_game_id:
|
||||||
games.append(game)
|
games.append(game)
|
||||||
elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity:
|
elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity:
|
||||||
games.append(game)
|
games.append(game)
|
||||||
|
|
||||||
return games
|
return games
|
||||||
|
except (orjson.JSONDecodeError, KeyError, IndexError):
|
||||||
except (orjson.JSONDecodeError, KeyError):
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry:
|
def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry:
|
||||||
"""Парсит данные одной игры."""
|
|
||||||
game = GameEntry()
|
game = GameEntry()
|
||||||
|
|
||||||
# Основная информация
|
|
||||||
game.game_id = game_data.get("game_id", -1)
|
game.game_id = game_data.get("game_id", -1)
|
||||||
game.game_name = game_data.get("game_name")
|
game.game_name = game_data.get("game_name")
|
||||||
game.game_alias = game_data.get("game_alias")
|
|
||||||
game.game_type = game_data.get("game_type")
|
|
||||||
game.review_score = game_data.get("review_score")
|
|
||||||
game.developer = game_data.get("profile_dev")
|
|
||||||
game.release_year = game_data.get("release_world")
|
|
||||||
game.raw_data = game_data
|
game.raw_data = game_data
|
||||||
|
|
||||||
# URL изображения
|
|
||||||
if "game_image" in game_data:
|
|
||||||
game.game_image_url = self.IMAGE_URL_PREFIX + game_data["game_image"]
|
|
||||||
|
|
||||||
# Ссылка на игру
|
|
||||||
game.game_web_link = f"{self.GAME_URL_PREFIX}{game.game_id}"
|
|
||||||
|
|
||||||
# Платформы
|
|
||||||
if "profile_platform" in game_data:
|
|
||||||
game.platforms = game_data["profile_platform"].split(", ")
|
|
||||||
|
|
||||||
# Времена прохождения (конвертация из секунд в часы)
|
|
||||||
time_fields = [
|
time_fields = [
|
||||||
("comp_main", "main_story"),
|
("comp_main", "main_story"),
|
||||||
("comp_plus", "main_extra"),
|
("comp_plus", "main_extra"),
|
||||||
("comp_100", "completionist"),
|
("comp_100", "completionist")
|
||||||
("comp_all", "all_styles"),
|
|
||||||
("invested_co", "coop_time"),
|
|
||||||
("invested_mp", "multiplayer_time")
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for json_field, attr_name in time_fields:
|
for json_field, attr_name in time_fields:
|
||||||
if json_field in game_data:
|
if json_field in game_data:
|
||||||
time_hours = round(game_data[json_field] / 3600, 2)
|
time_hours = round(game_data[json_field] / 3600, 2)
|
||||||
setattr(game, attr_name, time_hours)
|
setattr(game, attr_name, time_hours)
|
||||||
|
|
||||||
# Флаги сложности
|
|
||||||
game.has_combined_complexity = bool(game_data.get("comp_lvl_combine", 0))
|
|
||||||
game.has_single_player = bool(game_data.get("comp_lvl_sp", 0))
|
|
||||||
game.has_coop = bool(game_data.get("comp_lvl_co", 0))
|
|
||||||
game.has_multiplayer = bool(game_data.get("comp_lvl_mp", 0))
|
|
||||||
|
|
||||||
# Автофильтрация времен
|
|
||||||
if self.auto_filter_times:
|
|
||||||
if not game.has_single_player:
|
|
||||||
game.main_story = None
|
|
||||||
game.main_extra = None
|
|
||||||
game.completionist = None
|
|
||||||
game.all_styles = None
|
|
||||||
if not game.has_coop:
|
|
||||||
game.coop_time = None
|
|
||||||
if not game.has_multiplayer:
|
|
||||||
game.multiplayer_time = None
|
|
||||||
|
|
||||||
# Вычисление similarity
|
|
||||||
game.similarity = self._calculate_similarity(game)
|
game.similarity = self._calculate_similarity(game)
|
||||||
|
|
||||||
return game
|
return game
|
||||||
|
|
||||||
def _calculate_similarity(self, game: GameEntry) -> float:
|
def _calculate_similarity(self, game: GameEntry) -> float:
|
||||||
"""Вычисляет similarity между поисковым запросом и игрой."""
|
return self._compare_strings(self.search_query, game.game_name)
|
||||||
name_similarity = self._compare_strings(self.search_query, game.game_name)
|
|
||||||
alias_similarity = self._compare_strings(self.search_query, game.game_alias)
|
|
||||||
|
|
||||||
return max(name_similarity, alias_similarity)
|
|
||||||
|
|
||||||
def _compare_strings(self, a: str | None, b: str | None) -> float:
|
def _compare_strings(self, a: str | None, b: str | None) -> float:
|
||||||
"""Сравнивает две строки и возвращает коэффициент similarity."""
|
|
||||||
if not a or not b:
|
if not a or not b:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
if self.case_sensitive:
|
if self.case_sensitive:
|
||||||
similarity = SequenceMatcher(None, a, b).ratio()
|
similarity = SequenceMatcher(None, a, b).ratio()
|
||||||
else:
|
else:
|
||||||
similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio()
|
similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio()
|
||||||
|
|
||||||
# Штраф за отсутствие чисел из оригинального запроса
|
|
||||||
if self.search_numbers and not self._contains_numbers(b, self.search_numbers):
|
if self.search_numbers and not self._contains_numbers(b, self.search_numbers):
|
||||||
similarity -= 0.1
|
similarity -= 0.1
|
||||||
|
|
||||||
return max(0.0, similarity)
|
return max(0.0, similarity)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_numbers(text: str) -> list[str]:
|
def _extract_numbers(text: str) -> list[str]:
|
||||||
"""Извлекает числа из текста."""
|
|
||||||
return [word for word in text.split() if word.isdigit()]
|
return [word for word in text.split() if word.isdigit()]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _contains_numbers(text: str, numbers: list[str]) -> bool:
|
def _contains_numbers(text: str, numbers: list[str]) -> bool:
|
||||||
"""Проверяет, содержит ли текст указанные числа."""
|
|
||||||
if not numbers:
|
if not numbers:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
cleaned_text = re.sub(r'([^\s\w]|_)+', '', text)
|
cleaned_text = re.sub(r'([^\s\w]|_)+', '', text)
|
||||||
text_numbers = [word for word in cleaned_text.split() if word.isdigit()]
|
text_numbers = [word for word in cleaned_text.split() if word.isdigit()]
|
||||||
|
|
||||||
return any(num in text_numbers for num in numbers)
|
return any(num in text_numbers for num in numbers)
|
||||||
|
|
||||||
|
def get_cache_dir():
|
||||||
|
"""Возвращает путь к каталогу кэша, создаёт его при необходимости."""
|
||||||
|
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||||
|
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
return cache_dir
|
||||||
|
|
||||||
class HowLongToBeat:
|
class HowLongToBeat(QObject):
|
||||||
"""Основной класс для работы с API HowLongToBeat."""
|
"""Основной класс для работы с API HowLongToBeat."""
|
||||||
|
searchCompleted = Signal(list)
|
||||||
|
|
||||||
def __init__(self, minimum_similarity: float = 0.4, auto_filter_times: bool = False,
|
def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None):
|
||||||
timeout: int = 60):
|
super().__init__(parent)
|
||||||
self.minimum_similarity = minimum_similarity
|
self.minimum_similarity = minimum_similarity
|
||||||
self.auto_filter_times = auto_filter_times
|
|
||||||
self.http_client = HTTPClient(timeout)
|
self.http_client = HTTPClient(timeout)
|
||||||
|
self.cache_dir = get_cache_dir()
|
||||||
|
|
||||||
def search(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE,
|
def _get_cache_file_path(self, game_name: str) -> str:
|
||||||
case_sensitive: bool = True) -> list[GameEntry] | None:
|
"""Возвращает путь к файлу кэша для заданного имени игры."""
|
||||||
"""Ищет игры по названию."""
|
safe_game_name = re.sub(r'[^\w\s-]', '', game_name).replace(' ', '_').lower()
|
||||||
|
cache_file = f"hltb_{safe_game_name}.json"
|
||||||
|
return os.path.join(self.cache_dir, cache_file)
|
||||||
|
|
||||||
|
def _load_from_cache(self, game_name: str) -> str | None:
|
||||||
|
"""Пытается загрузить данные из кэша, если они существуют."""
|
||||||
|
cache_file = self._get_cache_file_path(game_name)
|
||||||
|
try:
|
||||||
|
if os.path.exists(cache_file):
|
||||||
|
with open(cache_file, 'rb') as f:
|
||||||
|
return f.read().decode('utf-8')
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _save_to_cache(self, game_name: str, json_response: str):
|
||||||
|
"""Сохраняет данные в кэш, храня только первую игру и необходимые поля."""
|
||||||
|
cache_file = self._get_cache_file_path(game_name)
|
||||||
|
try:
|
||||||
|
# Парсим JSON и берем только первую игру
|
||||||
|
data = orjson.loads(json_response)
|
||||||
|
if data.get("data"):
|
||||||
|
first_game = data["data"][0]
|
||||||
|
simplified_data = {
|
||||||
|
"data": [{
|
||||||
|
"game_id": first_game.get("game_id", -1),
|
||||||
|
"game_name": first_game.get("game_name"),
|
||||||
|
"comp_main": first_game.get("comp_main", 0),
|
||||||
|
"comp_plus": first_game.get("comp_plus", 0),
|
||||||
|
"comp_100": first_game.get("comp_100", 0)
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
with open(cache_file, 'wb') as f:
|
||||||
|
f.write(orjson.dumps(simplified_data))
|
||||||
|
except (OSError, orjson.JSONDecodeError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def search(self, game_name: str, case_sensitive: bool = True) -> list[GameEntry] | None:
|
||||||
if not game_name or not game_name.strip():
|
if not game_name or not game_name.strip():
|
||||||
return None
|
return None
|
||||||
|
# Проверяем кэш
|
||||||
json_response = self.http_client.search_games(game_name, search_modifiers)
|
cached_response = self._load_from_cache(game_name)
|
||||||
if not json_response:
|
if cached_response:
|
||||||
return None
|
try:
|
||||||
|
cached_data = orjson.loads(cached_response)
|
||||||
|
full_json = {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"game_id": game["game_id"],
|
||||||
|
"game_name": game["game_name"],
|
||||||
|
"comp_main": game["comp_main"],
|
||||||
|
"comp_plus": game["comp_plus"],
|
||||||
|
"comp_100": game["comp_100"]
|
||||||
|
}
|
||||||
|
for game in cached_data.get("data", [])
|
||||||
|
]
|
||||||
|
}
|
||||||
parser = ResultParser(
|
parser = ResultParser(
|
||||||
game_name,
|
game_name,
|
||||||
self.minimum_similarity,
|
self.minimum_similarity,
|
||||||
case_sensitive,
|
case_sensitive
|
||||||
self.auto_filter_times
|
|
||||||
)
|
)
|
||||||
|
return parser.parse_results(orjson.dumps(full_json).decode('utf-8'))
|
||||||
return parser.parse_results(json_response)
|
except orjson.JSONDecodeError:
|
||||||
|
pass
|
||||||
def search_by_id(self, game_id: int) -> GameEntry | None:
|
# Если нет в кэше, делаем запрос
|
||||||
"""Ищет игру по ID."""
|
json_response = self.http_client.search_games(game_name)
|
||||||
if not game_id or game_id <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
game_title = self.http_client.get_game_title(game_id)
|
|
||||||
if not game_title:
|
|
||||||
return None
|
|
||||||
|
|
||||||
json_response = self.http_client.search_games(game_title)
|
|
||||||
if not json_response:
|
if not json_response:
|
||||||
return None
|
return None
|
||||||
|
# Сохраняем в кэш только первую игру
|
||||||
|
self._save_to_cache(game_name, json_response)
|
||||||
|
parser = ResultParser(
|
||||||
|
game_name,
|
||||||
|
self.minimum_similarity,
|
||||||
|
case_sensitive
|
||||||
|
)
|
||||||
|
return parser.parse_results(json_response)
|
||||||
|
|
||||||
parser = ResultParser(game_title, 0.0, False, self.auto_filter_times)
|
def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None:
|
||||||
results = parser.parse_results(json_response, target_game_id=game_id)
|
time_value = getattr(game_entry, time_field, None)
|
||||||
|
if time_value is None:
|
||||||
|
return None
|
||||||
|
time_seconds = int(time_value * 3600)
|
||||||
|
return format_playtime(time_seconds)
|
||||||
|
|
||||||
return results[0] if results else None
|
def search_with_callback(self, game_name: str, case_sensitive: bool = True):
|
||||||
|
"""Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами."""
|
||||||
|
def search_thread():
|
||||||
|
try:
|
||||||
|
results = self.search(game_name, case_sensitive)
|
||||||
|
self.searchCompleted.emit(results if results else [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in search_with_callback: {e}")
|
||||||
|
self.searchCompleted.emit([])
|
||||||
|
|
||||||
|
thread = Thread(target=search_thread)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
@@ -111,6 +111,8 @@ class InputManager(QObject):
|
|||||||
self.stick_value = 0 # Текущее значение стика (для плавности)
|
self.stick_value = 0 # Текущее значение стика (для плавности)
|
||||||
self.dead_zone = 8000 # Мертвая зона стика
|
self.dead_zone = 8000 # Мертвая зона стика
|
||||||
|
|
||||||
|
self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower()
|
||||||
|
|
||||||
# Add variables for continuous D-pad movement
|
# Add variables for continuous D-pad movement
|
||||||
self.dpad_timer = QTimer(self)
|
self.dpad_timer = QTimer(self)
|
||||||
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
|
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
|
||||||
@@ -849,7 +851,7 @@ class InputManager(QObject):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Toggle fullscreen with F11
|
# Toggle fullscreen with F11
|
||||||
if key == Qt.Key.Key_F11:
|
if key == Qt.Key.Key_F11 and not self._is_gamescope_session:
|
||||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -946,7 +948,7 @@ class InputManager(QObject):
|
|||||||
continue
|
continue
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if event.type == ecodes.EV_KEY and event.value == 1:
|
if event.type == ecodes.EV_KEY and event.value == 1:
|
||||||
if event.code in BUTTONS['menu']:
|
if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
|
||||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||||
else:
|
else:
|
||||||
self.button_pressed.emit(event.code)
|
self.button_pressed.emit(event.code)
|
||||||
|
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
"POT-Creation-Date: 2025-07-14 13:16+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: de_DE\n"
|
"Language: de_DE\n"
|
||||||
@@ -563,6 +563,15 @@ msgstr ""
|
|||||||
msgid "PLAY TIME"
|
msgid "PLAY TIME"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN STORY"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN + SIDES"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "COMPLETIONIST"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "full"
|
msgid "full"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
"POT-Creation-Date: 2025-07-14 13:16+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: es_ES\n"
|
"Language: es_ES\n"
|
||||||
@@ -563,6 +563,15 @@ msgstr ""
|
|||||||
msgid "PLAY TIME"
|
msgid "PLAY TIME"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN STORY"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN + SIDES"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "COMPLETIONIST"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "full"
|
msgid "full"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
"POT-Creation-Date: 2025-07-14 13:16+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -561,6 +561,15 @@ msgstr ""
|
|||||||
msgid "PLAY TIME"
|
msgid "PLAY TIME"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN STORY"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN + SIDES"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "COMPLETIONIST"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "full"
|
msgid "full"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
Binary file not shown.
@@ -9,8 +9,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
"POT-Creation-Date: 2025-07-14 13:16+0500\n"
|
||||||
"PO-Revision-Date: 2025-07-06 17:56+0500\n"
|
"PO-Revision-Date: 2025-07-14 13:16+0500\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language: ru_RU\n"
|
"Language: ru_RU\n"
|
||||||
"Language-Team: ru_RU <LL@li.org>\n"
|
"Language-Team: ru_RU <LL@li.org>\n"
|
||||||
@@ -572,6 +572,15 @@ msgstr "Последний запуск"
|
|||||||
msgid "PLAY TIME"
|
msgid "PLAY TIME"
|
||||||
msgstr "Время игры"
|
msgstr "Время игры"
|
||||||
|
|
||||||
|
msgid "MAIN STORY"
|
||||||
|
msgstr "СЮЖЕТ"
|
||||||
|
|
||||||
|
msgid "MAIN + SIDES"
|
||||||
|
msgstr "СЮЖЕТ + ПОБОЧКИ"
|
||||||
|
|
||||||
|
msgid "COMPLETIONIST"
|
||||||
|
msgstr "100%"
|
||||||
|
|
||||||
msgid "full"
|
msgid "full"
|
||||||
msgstr "полная"
|
msgstr "полная"
|
||||||
|
|
||||||
|
@@ -31,6 +31,7 @@ from portprotonqt.config_utils import (
|
|||||||
)
|
)
|
||||||
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
|
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
|
from portprotonqt.howlongtobeat_api import HowLongToBeat
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
|
|
||||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
|
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
|
||||||
@@ -1517,6 +1518,8 @@ class MainWindow(QMainWindow):
|
|||||||
self._animations = {}
|
self._animations = {}
|
||||||
imageLabel = QLabel()
|
imageLabel = QLabel()
|
||||||
imageLabel.setFixedSize(300, 400)
|
imageLabel.setFixedSize(300, 400)
|
||||||
|
self._detail_page_active = True
|
||||||
|
self._current_detail_page = detailPage
|
||||||
|
|
||||||
if cover_path:
|
if cover_path:
|
||||||
def on_pixmap_ready(pixmap):
|
def on_pixmap_ready(pixmap):
|
||||||
@@ -1589,7 +1592,7 @@ class MainWindow(QMainWindow):
|
|||||||
badge_spacing = 5
|
badge_spacing = 5
|
||||||
top_y = 10
|
top_y = 10
|
||||||
badge_y_positions = []
|
badge_y_positions = []
|
||||||
badge_width = int(300 * 2/3) # 2/3 ширины обложки (300 px)
|
badge_width = int(300 * 2/3)
|
||||||
|
|
||||||
# ProtonDB бейдж
|
# ProtonDB бейдж
|
||||||
protondb_text = GameCard.getProtonDBText(protondb_tier)
|
protondb_text = GameCard.getProtonDBText(protondb_tier)
|
||||||
@@ -1678,11 +1681,6 @@ class MainWindow(QMainWindow):
|
|||||||
anticheat_visible = False
|
anticheat_visible = False
|
||||||
|
|
||||||
# Расположение бейджей
|
# Расположение бейджей
|
||||||
right_margin = 8
|
|
||||||
badge_spacing = 5
|
|
||||||
top_y = 10
|
|
||||||
badge_y_positions = []
|
|
||||||
badge_width = int(300 * 2/3)
|
|
||||||
if steam_visible:
|
if steam_visible:
|
||||||
steam_x = 300 - badge_width - right_margin
|
steam_x = 300 - badge_width - right_margin
|
||||||
steamLabel.move(steam_x, top_y)
|
steamLabel.move(steam_x, top_y)
|
||||||
@@ -1736,22 +1734,102 @@ class MainWindow(QMainWindow):
|
|||||||
descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE)
|
descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE)
|
||||||
detailsLayout.addWidget(descLabel)
|
detailsLayout.addWidget(descLabel)
|
||||||
|
|
||||||
infoLayout = QHBoxLayout()
|
# Инициализация HowLongToBeat
|
||||||
infoLayout.setSpacing(10)
|
hltb = HowLongToBeat(parent=self)
|
||||||
|
|
||||||
|
# Создаем общий layout для всей игровой информации
|
||||||
|
gameInfoLayout = QVBoxLayout()
|
||||||
|
gameInfoLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# Первая строка: Last Launch и Play Time
|
||||||
|
firstRowLayout = QHBoxLayout()
|
||||||
|
firstRowLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# Last Launch
|
||||||
lastLaunchTitle = QLabel(_("LAST LAUNCH"))
|
lastLaunchTitle = QLabel(_("LAST LAUNCH"))
|
||||||
lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
|
lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
lastLaunchValue = QLabel(last_launch)
|
lastLaunchValue = QLabel(last_launch)
|
||||||
lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
|
lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
firstRowLayout.addWidget(lastLaunchTitle)
|
||||||
|
firstRowLayout.addWidget(lastLaunchValue)
|
||||||
|
firstRowLayout.addSpacing(30)
|
||||||
|
|
||||||
|
# Play Time
|
||||||
playTimeTitle = QLabel(_("PLAY TIME"))
|
playTimeTitle = QLabel(_("PLAY TIME"))
|
||||||
playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
|
playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
|
||||||
playTimeValue = QLabel(formatted_playtime)
|
playTimeValue = QLabel(formatted_playtime)
|
||||||
playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
|
playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
|
||||||
infoLayout.addWidget(lastLaunchTitle)
|
firstRowLayout.addWidget(playTimeTitle)
|
||||||
infoLayout.addWidget(lastLaunchValue)
|
firstRowLayout.addWidget(playTimeValue)
|
||||||
infoLayout.addSpacing(30)
|
|
||||||
infoLayout.addWidget(playTimeTitle)
|
gameInfoLayout.addLayout(firstRowLayout)
|
||||||
infoLayout.addWidget(playTimeValue)
|
|
||||||
detailsLayout.addLayout(infoLayout)
|
# Создаем placeholder для второй строки (HLTB данные)
|
||||||
|
hltbLayout = QHBoxLayout()
|
||||||
|
hltbLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# Время прохождения (Main Story, Main + Sides, Completionist)
|
||||||
|
def on_hltb_results(results):
|
||||||
|
if not hasattr(self, '_detail_page_active') or not self._detail_page_active:
|
||||||
|
return
|
||||||
|
if not self._current_detail_page or self._current_detail_page.isHidden() or not self._current_detail_page.parent():
|
||||||
|
return
|
||||||
|
|
||||||
|
if results:
|
||||||
|
game = results[0] # Берем первый результат
|
||||||
|
main_story_time = hltb.format_game_time(game, "main_story")
|
||||||
|
main_extra_time = hltb.format_game_time(game, "main_extra")
|
||||||
|
completionist_time = hltb.format_game_time(game, "completionist")
|
||||||
|
|
||||||
|
# Очищаем layout перед добавлением новых элементов
|
||||||
|
while hltbLayout.count():
|
||||||
|
child = hltbLayout.takeAt(0)
|
||||||
|
if child.widget():
|
||||||
|
child.widget().deleteLater()
|
||||||
|
|
||||||
|
has_data = False
|
||||||
|
|
||||||
|
if main_story_time is not None:
|
||||||
|
mainStoryTitle = QLabel(_("MAIN STORY"))
|
||||||
|
mainStoryTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
|
mainStoryValue = QLabel(main_story_time)
|
||||||
|
mainStoryValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(mainStoryTitle)
|
||||||
|
hltbLayout.addWidget(mainStoryValue)
|
||||||
|
hltbLayout.addSpacing(30)
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
if main_extra_time is not None:
|
||||||
|
mainExtraTitle = QLabel(_("MAIN + SIDES"))
|
||||||
|
mainExtraTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
|
||||||
|
mainExtraValue = QLabel(main_extra_time)
|
||||||
|
mainExtraValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(mainExtraTitle)
|
||||||
|
hltbLayout.addWidget(mainExtraValue)
|
||||||
|
hltbLayout.addSpacing(30)
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
if completionist_time is not None:
|
||||||
|
completionistTitle = QLabel(_("COMPLETIONIST"))
|
||||||
|
completionistTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
|
completionistValue = QLabel(completionist_time)
|
||||||
|
completionistValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(completionistTitle)
|
||||||
|
hltbLayout.addWidget(completionistValue)
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
# Если есть данные, добавляем layout во вторую строку
|
||||||
|
if has_data:
|
||||||
|
gameInfoLayout.addLayout(hltbLayout)
|
||||||
|
|
||||||
|
# Подключаем сигнал searchCompleted к on_hltb_results
|
||||||
|
hltb.searchCompleted.connect(on_hltb_results)
|
||||||
|
|
||||||
|
# Запускаем поиск в фоновом потоке
|
||||||
|
hltb.search_with_callback(name, case_sensitive=False)
|
||||||
|
|
||||||
|
# Добавляем общий layout с игровой информацией
|
||||||
|
detailsLayout.addLayout(gameInfoLayout)
|
||||||
|
|
||||||
if controller_support:
|
if controller_support:
|
||||||
cs = controller_support.lower()
|
cs = controller_support.lower()
|
||||||
@@ -1769,7 +1847,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
detailsLayout.addStretch(1)
|
detailsLayout.addStretch(1)
|
||||||
|
|
||||||
# Определяем текущий идентификатор игры по exec_line для корректного отображения кнопки
|
# Определяем текущий идентификатор игры по exec_line
|
||||||
entry_exec_split = shlex.split(exec_line)
|
entry_exec_split = shlex.split(exec_line)
|
||||||
if not entry_exec_split:
|
if not entry_exec_split:
|
||||||
return
|
return
|
||||||
@@ -1870,6 +1948,8 @@ class MainWindow(QMainWindow):
|
|||||||
def goBackDetailPage(self, page: QWidget | None) -> None:
|
def goBackDetailPage(self, page: QWidget | None) -> None:
|
||||||
if page is None or page != self.stackedWidget.currentWidget():
|
if page is None or page != self.stackedWidget.currentWidget():
|
||||||
return
|
return
|
||||||
|
self._detail_page_active = False
|
||||||
|
self._current_detail_page = None
|
||||||
self.stackedWidget.setCurrentIndex(0)
|
self.stackedWidget.setCurrentIndex(0)
|
||||||
self.stackedWidget.removeWidget(page)
|
self.stackedWidget.removeWidget(page)
|
||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "portprotonqt"
|
name = "portprotonqt"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "GPL-3.0" }
|
license = { text = "GPL-3.0" }
|
||||||
|
Reference in New Issue
Block a user