Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c02366d76a
|
||
|
|
742e4e646c
|
||
|
|
b134c73d2a
|
||
|
|
0d969f40de
|
No files matched your search
@@ -2,26 +2,16 @@ name: Build AppImage, Arch, Fedora and Debian Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: "Run mode"
|
||||
required: true
|
||||
default: "hotfix"
|
||||
type: choice
|
||||
options:
|
||||
- hotfix
|
||||
- release
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]*.[0-9]*'
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 1.3.1
|
||||
VERSION: 1.3.2
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RUN_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'release' }}
|
||||
|
||||
jobs:
|
||||
build-appimage:
|
||||
@@ -43,13 +33,7 @@ jobs:
|
||||
run: |
|
||||
cd build-aux/AppImage
|
||||
chmod +x get-dependencies.sh portprotonqt-appimage.sh
|
||||
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
|
||||
./get-dependencies.sh --branch "${{ github.ref_name }}"
|
||||
elif [ "${{ github.event_name }}" = "push" ]; then
|
||||
./get-dependencies.sh --tag "${{ github.ref_name }}"
|
||||
else
|
||||
./get-dependencies.sh
|
||||
fi
|
||||
./get-dependencies.sh
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
@@ -101,9 +85,6 @@ jobs:
|
||||
cd /__w/portproton-repo
|
||||
cp -r "$GITHUB_WORKSPACE" PortProtonQt
|
||||
cd /__w/portproton-repo/PortProtonQt/build-aux
|
||||
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
|
||||
sed -i "s|^source=.*|source=(\"git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git#branch=${{ github.ref_name }}\")|" PKGBUILD
|
||||
fi
|
||||
chown user -R ..
|
||||
su user -c "yes '' | makepkg --noconfirm -s"
|
||||
|
||||
@@ -145,9 +126,6 @@ jobs:
|
||||
- name: Copy fedora.spec
|
||||
run: |
|
||||
cp build-aux/fedora.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
|
||||
if [ "${{ env.RUN_MODE }}" = "hotfix" ]; then
|
||||
sed -i "s|git checkout v%{pypi_version}|git checkout ${{ github.ref_name }}|" /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
|
||||
fi
|
||||
chown -R rpmbuild:users /home/rpmbuild
|
||||
|
||||
- name: Build RPM
|
||||
@@ -192,7 +170,6 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Create and Publish Release
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.mode == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@v4
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Download stats
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
stats:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update download stats
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_ACTOR: ${{ gitea.actor }}
|
||||
GITEA_SERVER: "git.linux-gaming.ru"
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
run: bash dev-scripts/get_download_stats.sh
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:b21289eb1954872de0dc9f88976627e38611b1817be75e50946c83ab7b9c474d
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
@@ -186,9 +186,6 @@ if key_value >= Qt.Key.Key_Escape.value: ...
|
||||
- Do not refactor unrelated code
|
||||
- Do not modify localization files (`portprotonqt/locales/`) unless explicitly requested
|
||||
- Do not extract single-use logic into a helper function without a clear need
|
||||
- **Before adding any helper, wrapper, class, or infrastructure, search for an existing method that already performs the operation and call it directly**
|
||||
- **NEVER add a task-local helper that only wraps an existing method or a single call; keep the call in the modified block unless extraction is required by the function limits**
|
||||
- If the user identifies overengineering, remove the unnecessary abstraction before continuing; do not layer another fix on top of it
|
||||
- Do not add comments for obvious code
|
||||
- **NEVER leave outdated comments after refactoring** (e.g., "without numpy" after numpy removal, "legacy" after rewrite)
|
||||
- **ALWAYS update or remove comments that reference removed dependencies, patterns, or context**
|
||||
@@ -388,7 +385,6 @@ tests/
|
||||
├── test_cli.py # normalize_launch_path, URL/resolution parsing
|
||||
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
|
||||
├── test_input_manager.py # Gamepad input navigation and focus regressions
|
||||
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
|
||||
├── test_main_window.py # Main window data processing and callback regressions
|
||||
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
|
||||
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
|
||||
@@ -441,7 +437,6 @@ pre-commit run pytest
|
||||
| `classic/styles.py` | Missing styles after theme rewrite (NAV, COMBOBOX, TAB, etc.) | 519edd1 |
|
||||
| `classic-light/styles.py` | NameError: border_none not defined (no styles/constants.py) | 519edd1 |
|
||||
| `detail_pages` | DETAIL_PAGE_GRADIENT stops override, wave background modes | — |
|
||||
| `detail_pages` | Edit/Add shortcut buttons shown together for non-Steam games | 7e38d9a |
|
||||
|
||||
---
|
||||
|
||||
@@ -946,6 +941,6 @@ PortProtonQt/
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2026-07-20
|
||||
**Last updated:** 2026-06-18
|
||||
**Version:** 1.2
|
||||
**Status:** Release
|
||||
+2
-43
@@ -3,51 +3,10 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Добавлен анализатор проблем в случае вылета Windows-программы
|
||||
- Добавлены анимированные и статические фоны детальной страницы `aurora`, `diagnostics`, `metaballs`, `leaf` и `veins`, вдохновлённые проектом omikuji
|
||||
- Добавлена интеграция GOG с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
|
||||
- Добавлено отображение иконок `.exe` вместо обложек на компактной детальной странице
|
||||
- Добавлено редактирование ярлыков Steam по AppID
|
||||
- Добавлены тихий и логируемый режимы запуска `.exe` через меню «Открыть с помощью»
|
||||
- Добавлена пользовательская интеграция AppImage
|
||||
- В игровые ярлыки добавлены действия для запуска в тихом режиме и с записью лога
|
||||
- Добавлен аргумент командной строки `--log` для запуска игры в режиме логирования
|
||||
- Добавлены звуки интерфейса
|
||||
- Добавлен выбор аккаунта Steam в настройках с автоматическим режимом по умолчанию и режимом `All` для добавления ярлыков всем пользователям
|
||||
- Добавлено безопасное чтение вариантов dark/light палитры matugen из Dank Material Shell
|
||||
|
||||
### Changed
|
||||
- API и ссылки PPDB переведены с `ppdb.linux-gaming.ru` на `linux-gaming.ru`
|
||||
- Блок игровой статистики на полной детальной странице помещён в тематизируемый контейнер, как в компактном режиме
|
||||
- Кэш EXE-иконок унифицирован для карточек и детальных страниц
|
||||
- Папки `custom_data` теперь используют имя exe и MD5 полного пути для предотвращения конфликтов одинаковых имён
|
||||
- Управление геймпадом переведено с `pygame` на SDL3, зависимость `pygame` удалена
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
|
||||
- Возвращено отображение нативных Linux-игр в библиотеке Steam без отображения саундтреков
|
||||
- После создания или удаления ярлыков меню обновляется база desktop-файлов
|
||||
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
|
||||
## [1.3.2] - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
|
||||
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
|
||||
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
|
||||
- Системные действия в режиме `--ppqtos` переведены на `systemctl` и `loginctl`
|
||||
- Исправлена перекраска SVG-иконок подсказок управления через `ICON_COLORS`
|
||||
- Исправлен ввод кириллицы при поиске игр
|
||||
- Поиск игр ограничен названиями без совпадений по описаниям
|
||||
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
|
||||
- Исправлено зависание интерфейса после завершения игры
|
||||
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
|
||||
|
||||
### Contributors
|
||||
- @Rubin13
|
||||
- @rogerio-mattos
|
||||
- @Vekt0R_null
|
||||
- Исправлено определение SteamID по `AutoLogin` или единственному профилю `userdata`, если в `loginusers.vdf` отсутствует `MostRecent`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -434,20 +434,3 @@ proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Library General
|
||||
Public License instead of this License.
|
||||
|
||||
=====================================================================
|
||||
|
||||
===========================
|
||||
= Kenney Interface Sounds =
|
||||
===========================
|
||||
|
||||
Interface Sounds (1.0)
|
||||
|
||||
Created/distributed by Kenney (www.kenney.nl)
|
||||
|
||||
License: Creative Commons Zero, CC0
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
|
||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
||||
@@ -5,7 +5,6 @@
|
||||
<div align="center">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-5471-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton and Steam. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
</div>
|
||||
|
||||
@@ -68,8 +67,6 @@ PortProtonQt uses code and dependencies from the following projects:
|
||||
- [iat](https://sourceforge.net/projects/iat.berlios) — library for converting mdf and nrg to iso, GPLv3 license.
|
||||
- [pyte](https://github.com/selectel/pyte) — ANSI escape code parser, [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file) license.
|
||||
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — base for virtual keyboard layout data, GPLv3 license.
|
||||
- [omikuji](https://github.com/omikuji-launcher/omikuji) — inspiration and source material for the procedural detail page backgrounds, [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE) license.
|
||||
- [Bottles](https://github.com/bottlesdevs/Bottles) — source of the compatibility analysis rules, GPL-3.0-only license.
|
||||
|
||||
See the [LICENSE](LICENSE) file for the full text of the licenses.
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<div align="center">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-5471-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
|
||||
</div>
|
||||
|
||||
@@ -68,9 +67,6 @@ PortProtonQt использует код и зависимости от след
|
||||
- [iat](https://sourceforge.net/projects/iat.berlios) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv3
|
||||
- [pyte](https://github.com/selectel/pyte) — разбор ANSI escape-кодов, лицензия [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file)
|
||||
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — основа данных раскладок виртуальной клавиатуры, лицензия GPLv3.
|
||||
- [omikuji](https://github.com/omikuji-launcher/omikuji) — источник вдохновения и материалов для процедурных фонов детальной страницы, лицензия [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE).
|
||||
- [Bottles](https://github.com/bottlesdevs/Bottles) — источник правил анализа совместимости, лицензия GPL-3.0-only.
|
||||
|
||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
|
||||
- [X] Добавить возможность управления с геймпада
|
||||
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
|
||||
- [X] Добавить возможность управления с мыши и клавиатуры
|
||||
- [X] Добавить систему тем [Документация](documentation/theme_guide)
|
||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
|
||||
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
|
||||
- [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
||||
- [X] Разобраться почему теряется часть стилей в Gamescope
|
||||
- [x] Разработать адаптивный дизайн (за эталон берётся 1000x700 из гайдлайнов Flathub)
|
||||
- [x] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
|
||||
- [X] Получать описания и названия игр из базы данных Steam
|
||||
- [X] Получать обложки для игр из CDN Steam
|
||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
|
||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
|
||||
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
||||
- [X] Избавиться от вызовов yad
|
||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
||||
- [X] Добавить экранную клавиатуру в поиск
|
||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
||||
- [X] Добавить индикацию запуска приложения
|
||||
- [X] Достигнуть паритета функциональности с Ingame
|
||||
- [X] Достигнуть паритета функциональности с PortProton
|
||||
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
|
||||
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
|
||||
- [X] Добавить переводы в переопределения
|
||||
- [X] Добавить в карточку игры сведения о поддержке геймпада
|
||||
- [X] Добавить в карточки данные с ProtonDB
|
||||
- [X] Добавить в карточки данные с AreWeAntiCheatYet
|
||||
- [X] Продублировать бейджи с карточки на страницу с деталями игры
|
||||
- [X] Добавить парсинг ярлыков из Steam
|
||||
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
|
||||
- [X] Добавить поддержку версий Steam для Flatpak и Snap
|
||||
- [X] Реализовать добавление игры как сторонней в Steam без перезапуска
|
||||
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
|
||||
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
|
||||
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
|
||||
- [X] Отображать описания игр и другие данные на языке системы
|
||||
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
|
||||
- [X] Добавить систему избранного для карточек
|
||||
- [X] Заменить все `print` на `logging`
|
||||
- [X] Привести все логи к единому языку
|
||||
- [X] Уменьшить количество подстановок в переводах
|
||||
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
|
||||
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
|
||||
- [X] Исправить частичное применение тем на лету
|
||||
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
|
||||
- [ ] Добавить поддержку GOG (?)
|
||||
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
||||
- [X] Добавить данные с HowLongToBeat на страницу с деталями игры
|
||||
- [X] Добавить виброотдачу на геймпаде при запуске игры
|
||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||
- [X] Добавить подсказки к управлению с геймпада
|
||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
||||
@@ -5,8 +5,6 @@ set -eu
|
||||
# Initialize variables
|
||||
LOCAL_MODE=false
|
||||
BRANCH="main"
|
||||
USE_BRANCH=false
|
||||
TAG=""
|
||||
REPO_URL="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git"
|
||||
|
||||
# Parse arguments
|
||||
@@ -18,22 +16,12 @@ while [ $# -gt 0 ]; do
|
||||
--branch)
|
||||
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
|
||||
BRANCH="$2"
|
||||
USE_BRANCH=true
|
||||
shift
|
||||
else
|
||||
echo "Error: --branch requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--tag)
|
||||
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
|
||||
TAG="$2"
|
||||
shift
|
||||
else
|
||||
echo "Error: --tag requires an argument"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--repo)
|
||||
if [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
|
||||
REPO_URL="$2"
|
||||
@@ -57,12 +45,9 @@ EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImage
|
||||
if [ "$LOCAL_MODE" = true ]; then
|
||||
echo "Using local PKGBUILD-git from repository..."
|
||||
PPQT_PKGBUILD=""
|
||||
elif [ -n "$TAG" ]; then
|
||||
echo "Using stable version of PortProtonQt from $TAG tag..."
|
||||
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/tag/$TAG/build-aux/PKGBUILD"
|
||||
else
|
||||
echo "Using stable version of PortProtonQt from $BRANCH branch..."
|
||||
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/$BRANCH/build-aux/PKGBUILD"
|
||||
echo "Using stable version of PortProtonQt from main branch..."
|
||||
PPQT_PKGBUILD="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/build-aux/PKGBUILD"
|
||||
fi
|
||||
|
||||
echo "Tweak makepkg..."
|
||||
@@ -83,6 +68,23 @@ echo "---------------------------------------------------------------"
|
||||
pacman-key --init
|
||||
pacman -S --noconfirm archlinux-keyring
|
||||
|
||||
# Chaotic-AUR keys
|
||||
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'
|
||||
|
||||
# Enable the multilib repository
|
||||
cat << EOM >> /etc/pacman.conf
|
||||
[multilib]
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
|
||||
[chaotic-aur]
|
||||
Include = /etc/pacman.d/chaotic-mirrorlist
|
||||
SigLevel = Never
|
||||
EOM
|
||||
|
||||
# Disable locale noextract
|
||||
sed -i -E 's@[[:space:]]*usr/share/locale/\*@@g; s@[[:space:]]+@ @g; s@[[:space:]]+$@@' /etc/pacman.conf
|
||||
|
||||
@@ -95,11 +97,7 @@ if [ "$LOCAL_MODE" = true ]; then
|
||||
else
|
||||
wget --retry-connrefused --tries=30 "$PPQT_PKGBUILD" -O ./PKGBUILD
|
||||
fi
|
||||
if [ "$USE_BRANCH" = true ]; then
|
||||
sed -i "s|^source=.*|source=(\"git+${REPO_URL}#branch=$BRANCH\")|" PKGBUILD
|
||||
elif [ -n "$TAG" ]; then
|
||||
sed -i "s|^source=.*|source=(\"git+${REPO_URL}#tag=$TAG\")|" PKGBUILD
|
||||
fi
|
||||
sed -i "s|source=(\"git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git\")|source=(\"git+${REPO_URL}#branch=$BRANCH\")|" PKGBUILD
|
||||
makepkg -si --noconfirm
|
||||
|
||||
echo "Installing debloated packages..."
|
||||
|
||||
@@ -14,7 +14,6 @@ export UPINFO="gitea-releases-zsync|git.linux-gaming.ru|Linux-Gaming|PortProtonQ
|
||||
export DEPLOY_OPENGL=1
|
||||
export DEPLOY_SDL=1
|
||||
export DEPLOY_PYTHON=1
|
||||
export MAIN_BIN=portprotonqt
|
||||
export OPTIMIZE_LAUNCH=1
|
||||
|
||||
# Adjust comp settings to bypass oom-killer
|
||||
@@ -29,65 +28,21 @@ mkdir -p ./AppDir/etc/udev/rules.d
|
||||
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
|
||||
|
||||
# Add PortProton scripts
|
||||
# Copy manual because im to lazzy for wait ./quick-sharun strace 208 .ppdb
|
||||
mkdir -p ./AppDir/share
|
||||
cp -r /usr/share/portproton ./AppDir/share
|
||||
|
||||
# QSoundEffect only needs PCM WAV and PulseAudio. Remove the optional native
|
||||
# PipeWire path and FFmpeg media backend before quick-sharun scans Qt plugins.
|
||||
pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
|
||||
if [ -d /usr/local/share/portproton ]; then
|
||||
cp -r /usr/local/share/portproton ./AppDir/share
|
||||
elif [ -d /usr/share/portproton ]; then
|
||||
cp -r /usr/share/portproton ./AppDir/share
|
||||
fi
|
||||
|
||||
# Deploy dependencies
|
||||
# Qt libs have to be passed manually due to the app being a python script
|
||||
./quick-sharun \
|
||||
/usr/bin/portprotonqt* \
|
||||
/usr/bin/update-desktop-database \
|
||||
/usr/bin/vk_gpu_info \
|
||||
/usr/lib/libQt6Core.so* \
|
||||
/usr/lib/libQt6Gui.so* \
|
||||
/usr/lib/libQt6Multimedia.so* \
|
||||
/usr/lib/libQt6Network.so* \
|
||||
/usr/lib/qt6/plugins/imageformats/libqwebp.so
|
||||
|
||||
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
|
||||
# build/development content and PySide bindings not imported by PortProtonQt.
|
||||
PYTHON_DIR=$(find ./AppDir/lib -maxdepth 1 -type d -name 'python3.*' -print -quit)
|
||||
PYTHON_SITE=$PYTHON_DIR/site-packages
|
||||
PYSIDE_DIR=$PYTHON_SITE/PySide6
|
||||
|
||||
rm -rf \
|
||||
"$PYTHON_DIR"/ensurepip \
|
||||
"$PYTHON_DIR"/idlelib \
|
||||
"$PYTHON_DIR"/pydoc_data \
|
||||
"$PYTHON_DIR"/tkinter \
|
||||
"$PYTHON_DIR"/turtledemo \
|
||||
"$PYTHON_DIR"/venv \
|
||||
"$PYTHON_SITE"/Cython \
|
||||
"$PYTHON_SITE"/cython.py \
|
||||
"$PYTHON_SITE"/mesonbuild \
|
||||
"$PYTHON_SITE"/pygments \
|
||||
"$PYTHON_SITE"/pyximport \
|
||||
"$PYTHON_SITE"/setuptools \
|
||||
"$PYTHON_SITE"/vapoursynth \
|
||||
"$PYTHON_SITE"/wheel \
|
||||
"$PYTHON_SITE"/cython-*.dist-info \
|
||||
"$PYTHON_SITE"/meson-*.dist-info \
|
||||
"$PYTHON_SITE"/pygments-*.dist-info \
|
||||
"$PYTHON_SITE"/setuptools-*.dist-info \
|
||||
"$PYTHON_SITE"/vapoursynth-*.dist-info \
|
||||
"$PYTHON_SITE"/wheel-*.dist-info
|
||||
|
||||
find "$PYTHON_DIR" -depth -type d \( -name test -o -name tests \) \
|
||||
-exec rm -rf {} +
|
||||
find "$PYTHON_SITE" -type f -name '*.pyi' -delete
|
||||
|
||||
for module in "$PYSIDE_DIR"/Qt*.so; do
|
||||
case "${module##*/}" in
|
||||
QtCore.*|QtGui.*|QtMultimedia.*|QtNetwork.*|QtSvg.*|QtWidgets.*) continue ;;
|
||||
esac
|
||||
rm -f "$module"
|
||||
done
|
||||
rm -rf "$PYSIDE_DIR"/QtAsyncio
|
||||
|
||||
# Turn AppDir into AppImage
|
||||
./quick-sharun --make-appimage
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
pkgname=('portprotonqt')
|
||||
pkgver=1.3.1
|
||||
pkgver=1.3.2
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
|
||||
arch=('any')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
|
||||
conflicts=("portprotonqt-steam-compat")
|
||||
provides=('portprotonqt-steam-compat')
|
||||
|
||||
@@ -5,8 +5,8 @@ pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (
|
||||
arch=('any')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
|
||||
conflicts=("portprotonqt-steam-compat")
|
||||
provides=('portprotonqt-steam-compat')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.3.1
|
||||
%global pypi_version 1.3.2
|
||||
%global oname PortProtonQt
|
||||
%global build_timestamp %(date +"%Y%m%d")
|
||||
%global _python_no_extras_requires 1
|
||||
@@ -33,6 +33,7 @@ Requires: python3-websocket-client
|
||||
Requires: python3-orjson
|
||||
Requires: python3-psutil
|
||||
Requires: python3-pyside6
|
||||
Requires: python3-pygame
|
||||
Requires: python3-requests
|
||||
Requires: python3-tqdm
|
||||
Requires: python3-vdf
|
||||
@@ -45,7 +46,6 @@ Requires: perl-Image-ExifTool
|
||||
Requires: SDL3
|
||||
Requires: qt6-qtsvg
|
||||
Requires: qt6-qtimageformats
|
||||
Requires: qt6-qtmultimedia
|
||||
Requires: cabextract
|
||||
Requires: gzip
|
||||
Requires: unzip
|
||||
@@ -65,7 +65,6 @@ Requires: procps-ng
|
||||
Requires: psmisc
|
||||
Requires: 7zip
|
||||
Requires: python3-dbus-fast
|
||||
Requires: desktop-file-utils
|
||||
|
||||
# System Tab
|
||||
Recommends: NetworkManager
|
||||
@@ -115,8 +114,6 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
|
||||
%{_sysusersdir}/portprotonqt.conf
|
||||
%{_datadir}/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.log.desktop
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.silent.desktop
|
||||
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
|
||||
%{_datadir}/portproton/scripts/
|
||||
%{_datadir}/portproton/conf/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.3.1
|
||||
%global pypi_version 1.3.2
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
@@ -30,6 +30,7 @@ Requires: python3-websocket-client
|
||||
Requires: python3-orjson
|
||||
Requires: python3-psutil
|
||||
Requires: python3-pyside6
|
||||
Requires: python3-pygame
|
||||
Requires: python3-requests
|
||||
Requires: python3-tqdm
|
||||
Requires: python3-vdf
|
||||
@@ -42,7 +43,6 @@ Requires: perl-Image-ExifTool
|
||||
Requires: SDL3
|
||||
Requires: qt6-qtsvg
|
||||
Requires: qt6-qtimageformats
|
||||
Requires: qt6-qtmultimedia
|
||||
Requires: cabextract
|
||||
Requires: gzip
|
||||
Requires: unzip
|
||||
@@ -62,7 +62,6 @@ Requires: procps-ng
|
||||
Requires: psmisc
|
||||
Requires: 7zip
|
||||
Requires: python3-dbus-fast
|
||||
Requires: desktop-file-utils
|
||||
|
||||
# System Tab
|
||||
Recommends: NetworkManager
|
||||
@@ -113,8 +112,6 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
|
||||
%{_sysusersdir}/portprotonqt.conf
|
||||
%{_datadir}/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.log.desktop
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.silent.desktop
|
||||
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
|
||||
%{_datadir}/portproton/scripts/
|
||||
%{_datadir}/portproton/conf/
|
||||
|
||||
@@ -11,4 +11,4 @@ Terminal=false
|
||||
Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;Utility;
|
||||
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-executable;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds-disk;application/x-nrg;
|
||||
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-executable;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds-disk;application/x-nrg;
|
||||
@@ -1,20 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=PortProtonQt (log)
|
||||
Name[ru]=PortProtonQt (режим лога)
|
||||
Name[es]=PortProtonQt (modo de registro)
|
||||
Name[pt]=PortProtonQt (modo de registro)
|
||||
Name[de]=PortProtonQt (Protokollmodus)
|
||||
Exec=portprotonqt --log %u
|
||||
Type=Application
|
||||
Comment=Launch Windows applications with logging through PortProtonQt
|
||||
Comment[ru]=Запуск Windows-приложений с записью лога через PortProtonQt
|
||||
Comment[es]=Ejecuta aplicaciones de Windows con registro mediante PortProtonQt
|
||||
Comment[pt]=Executa aplicativos do Windows com registro pelo PortProtonQt
|
||||
Comment[de]=Startet Windows-Anwendungen mit Protokollierung über PortProtonQt
|
||||
Terminal=false
|
||||
Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;
|
||||
NoDisplay=true
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -1,20 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=PortProtonQt (silent)
|
||||
Name[ru]=PortProtonQt (тихий режим)
|
||||
Name[es]=PortProtonQt (modo silencioso)
|
||||
Name[pt]=PortProtonQt (modo silencioso)
|
||||
Name[de]=PortProtonQt (stiller Modus)
|
||||
Exec=portprotonqt --silent %u
|
||||
Type=Application
|
||||
Comment=Launch Windows applications silently through PortProtonQt
|
||||
Comment[ru]=Тихий запуск Windows-приложений через PortProtonQt
|
||||
Comment[es]=Ejecuta aplicaciones de Windows silenciosamente mediante PortProtonQt
|
||||
Comment[pt]=Executa aplicativos do Windows silenciosamente pelo PortProtonQt
|
||||
Comment[de]=Startet Windows-Anwendungen lautlos über PortProtonQt
|
||||
Terminal=false
|
||||
Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;
|
||||
NoDisplay=true
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -24,9 +24,12 @@
|
||||
</developer>
|
||||
|
||||
<releases>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<release version="1.3.2" date="2026-08-10">
|
||||
<description></description>
|
||||
</release>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.3.0" date="2026-07-13">
|
||||
<description/>
|
||||
</release>
|
||||
|
||||
@@ -3014,7 +3014,7 @@ start_portproton () {
|
||||
elif [[ "$PW_USE_GAMEMODE" = "1" ]] \
|
||||
&& [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]
|
||||
then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn ; then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond ; then
|
||||
export GAMEMODERUN=0
|
||||
export PW_GAMEMODERUN_SLR=""
|
||||
local current_power_profile
|
||||
@@ -3754,10 +3754,6 @@ start_portproton () {
|
||||
*) get_and_set_reg_file --delete 'Software\Wine\Drivers' 'Audio' ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${PW_NETWORK_BLOCKLIST:-}" ]] ; then
|
||||
export PW_LD_PRELOAD="${PW_LD_PRELOAD:+${PW_LD_PRELOAD}:}libppqt-network-filter.so"
|
||||
fi
|
||||
|
||||
add_in_start_portproton
|
||||
|
||||
if [[ "$FULL_LN" == russian ]] ; then
|
||||
@@ -4217,7 +4213,7 @@ gui_proton_downloader () {
|
||||
return 0
|
||||
fi
|
||||
|
||||
METADATA_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
METADATA_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata.json"
|
||||
METADATA=$(curl -A 'PortProton' -s "$METADATA_URL")
|
||||
if [[ -z "$METADATA" ]] ; then
|
||||
fatal "failed to fetch metadata"
|
||||
|
||||
Vendored
+6
@@ -1,3 +1,9 @@
|
||||
portprotonqt (1.3.2) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Mon, 10 Aug 2026 22:02:11 +0500
|
||||
|
||||
portprotonqt (1.3.1) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
Vendored
+1
-2
@@ -20,7 +20,6 @@ Depends:
|
||||
7zip,
|
||||
cabextract,
|
||||
curl,
|
||||
desktop-file-utils,
|
||||
file,
|
||||
gawk,
|
||||
libimage-exiftool-perl,
|
||||
@@ -38,9 +37,9 @@ Depends:
|
||||
python3-pefile,
|
||||
python3-pil,
|
||||
python3-psutil,
|
||||
python3-pygame,
|
||||
python3-pyside6.qtcore,
|
||||
python3-pyside6.qtgui,
|
||||
python3-pyside6.qtmultimedia,
|
||||
python3-pyside6.qtnetwork,
|
||||
python3-pyside6.qtsvg,
|
||||
python3-pyside6.qtwidgets,
|
||||
|
||||
Vendored
-2
@@ -4,8 +4,6 @@ usr/lib/python3/dist-packages/portprotonqt/
|
||||
usr/lib/sysusers.d/portprotonqt.conf
|
||||
usr/lib/udev/rules.d/60-portprotonqt.rules
|
||||
usr/share/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
usr/share/applications/ru.linux_gaming.PortProtonQt.log.desktop
|
||||
usr/share/applications/ru.linux_gaming.PortProtonQt.silent.desktop
|
||||
usr/share/bash-completion/completions/portprotonqt
|
||||
usr/share/fish/vendor_completions.d/portprotonqt.fish
|
||||
usr/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
remote="${1:-origin}"
|
||||
main_branch="${2:-main}"
|
||||
weblate_branch="${3:-weblate}"
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Working tree is not clean."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch "$remote" "$main_branch" "$weblate_branch"
|
||||
git switch "$main_branch"
|
||||
git merge --ff-only "$remote/$main_branch"
|
||||
|
||||
if git merge-base --is-ancestor "$remote/$weblate_branch" HEAD; then
|
||||
echo "No new Weblate changes to merge."
|
||||
elif git merge-base --is-ancestor HEAD "$remote/$weblate_branch"; then
|
||||
git merge --ff-only "$remote/$weblate_branch"
|
||||
else
|
||||
git switch -C "$weblate_branch" "$remote/$weblate_branch"
|
||||
git rebase "$main_branch"
|
||||
git switch "$main_branch"
|
||||
git merge --ff-only "$weblate_branch"
|
||||
fi
|
||||
|
||||
python dev-scripts/l10n.py
|
||||
|
||||
git push "$remote" "$main_branch"
|
||||
git push --force "$remote" "$main_branch:$weblate_branch"
|
||||
|
||||
echo "Weblate translations accepted and $weblate_branch synced to $main_branch."
|
||||
+2
-11
@@ -141,23 +141,14 @@ def bump_workflow(path: Path, old: str, new: str) -> bool:
|
||||
|
||||
def bump_changelog(path: Path, old: str, new: str) -> bool:
|
||||
"""
|
||||
Create a new [Unreleased] section and release the current one
|
||||
Update [Unreleased] to [new] - YYYY-MM-DD in CHANGELOG.md
|
||||
"""
|
||||
if not path.exists():
|
||||
return False
|
||||
text = path.read_text(encoding='utf-8')
|
||||
pattern = re.compile(r"(?m)^##\s*\[Unreleased\]$")
|
||||
current_date = date.today().strftime('%Y-%m-%d')
|
||||
release_header = (
|
||||
"## [Unreleased]\n\n"
|
||||
"### Added\n\n"
|
||||
"### Changed\n\n"
|
||||
"### Fixed\n\n"
|
||||
"### Contributors\n\n"
|
||||
"---\n\n"
|
||||
f"## [{new}] - {current_date}"
|
||||
)
|
||||
new_text, count = pattern.subn(release_header, text)
|
||||
new_text, count = pattern.subn(f"## [{new}] - {current_date}", text)
|
||||
if count:
|
||||
path.write_text(new_text, encoding='utf-8')
|
||||
return bool(count)
|
||||
|
||||
@@ -18,8 +18,8 @@ _portprotonqt_completions() {
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# All available options
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --restore-prefix --create-backup --help"
|
||||
|
||||
# Values for options with arguments
|
||||
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -77,7 +77,6 @@ _portprotonqt() {
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
'--silent[Launch .exe file without GUI]'
|
||||
'--log[Enable PortProton logging]'
|
||||
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
|
||||
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
@@ -94,7 +93,7 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
|
||||
# Fish completion for portprotonqt
|
||||
|
||||
complete -c portprotonqt -f
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
|
||||
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
|
||||
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -105,7 +104,6 @@ complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
|
||||
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
|
||||
complete -c portprotonqt -l ppqtos -d "Show the system tab"
|
||||
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
|
||||
complete -c portprotonqt -l log -d "Enable PortProton logging"
|
||||
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
|
||||
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
|
||||
complete -c portprotonqt -s h -l help -d "Show help message"
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
GITHUB_REPO="Boria138/PortProtonQt"
|
||||
GITEA_REPO="Linux-Gaming/PortProtonQt"
|
||||
GITEA_URL="https://git.linux-gaming.ru"
|
||||
|
||||
jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | .download_count] | add // 0'
|
||||
|
||||
echo "=== PortProtonQt Download Stats ==="
|
||||
echo
|
||||
|
||||
echo "--- GitHub (${GITHUB_REPO}) ---"
|
||||
GH_RAW=$(curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=100")
|
||||
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
|
||||
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
|
||||
echo "Releases: $GH_RELEASES"
|
||||
echo "Total downloads: $GH_COUNT"
|
||||
echo
|
||||
echo "Per release:"
|
||||
echo "$GH_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
|
||||
echo
|
||||
|
||||
echo "--- Gitea (${GITEA_REPO}) ---"
|
||||
GIT_RAW=$(curl -sf "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
|
||||
GIT_COUNT=$(echo "$GIT_RAW" | jq "$jq_total")
|
||||
GIT_RELEASES=$(echo "$GIT_RAW" | jq 'length')
|
||||
echo "Releases: $GIT_RELEASES"
|
||||
echo "Total downloads: $GIT_COUNT"
|
||||
echo
|
||||
echo "Per release:"
|
||||
echo "$GIT_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
|
||||
echo
|
||||
|
||||
TOTAL=$((GH_COUNT + GIT_COUNT))
|
||||
echo "========================"
|
||||
echo "GitHub total: $GH_COUNT"
|
||||
echo "Gitea total: $GIT_COUNT"
|
||||
echo "Grand total: $TOTAL"
|
||||
echo
|
||||
echo "--- Top downloads (by file) ---"
|
||||
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total)\t\(.name)"'
|
||||
echo "GitHub:"
|
||||
echo "$GH_RAW" | jq -r "$jq_filter_top"
|
||||
echo
|
||||
echo "Gitea:"
|
||||
echo "$GIT_RAW" | jq -r "$jq_filter_top"
|
||||
echo
|
||||
echo "--- Top downloads (by type) ---"
|
||||
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total)"'
|
||||
echo "GitHub:"
|
||||
echo "$GH_RAW" | jq -r "$jq_type"
|
||||
echo
|
||||
echo "Gitea:"
|
||||
echo "$GIT_RAW" | jq -r "$jq_type"
|
||||
|
||||
if [[ "${CI:-}" != "true" ]]; then
|
||||
echo
|
||||
echo "Local run: README update and commit skipped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sed -i "s|-[0-9]*-green?style=flat-square|-$TOTAL-green?style=flat-square|g" README.md README.ru.md
|
||||
echo
|
||||
echo "Badge updated: $TOTAL"
|
||||
|
||||
git diff --quiet README.md README.ru.md && { echo "No changes to commit"; exit 0; }
|
||||
|
||||
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
git config http.sslVerify false
|
||||
git config --local user.email "gitea-actions@users.noreply.gitea.com"
|
||||
git config --local user.name "Gitea Actions"
|
||||
|
||||
git add README.md README.ru.md
|
||||
git commit -m "chore: download stats ${timestamp}"
|
||||
remote_repo="https://${GITEA_ACTOR}:${GITEA_TOKEN}@${GITEA_SERVER}/${GITEA_REPOSITORY}.git"
|
||||
git push "${remote_repo}" HEAD:main
|
||||
echo "Pushed to Gitea"
|
||||
@@ -18,7 +18,7 @@ DEBUG_MODE = os.environ.get('DEBUG_MODE', 'false').lower() == 'true'
|
||||
|
||||
# Конфигурация API
|
||||
STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
|
||||
PPDB_RATINGS_URL = "https://linux-gaming.ru/api/games/ratings?fields=id,name,overall_rating&has_reports=true"
|
||||
PPDB_RATINGS_URL = "https://ppdb.linux-gaming.ru/api/games/ratings?fields=id,name,overall_rating&has_reports=true"
|
||||
CATEGORY_STEAM = "games"
|
||||
|
||||
# Отключаем предупреждения об SSL в дебаг-режиме
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
- [Library Layout Mode](#library-layout-mode)
|
||||
- [Detail Page Layout Mode](#detail-page-layout-mode)
|
||||
- [Detail Page Background Mode](#detail-page-background-mode)
|
||||
- [Background Configuration](#background-configuration)
|
||||
- [Custom Gradient Stops](#custom-gradient-stops)
|
||||
- [Wave Configuration](#wave-configuration)
|
||||
- [Preloader](#preloader)
|
||||
- [Source Corner (Ribbon)](#source-corner-ribbon)
|
||||
- [Terminal Color Schemes](#terminal-color-schemes)
|
||||
@@ -25,7 +26,6 @@
|
||||
- [Recoloring SVG Icons](#recoloring-svg-icons)
|
||||
- [AutoSizeButton State Recoloring](#autosizebutton-state-recoloring)
|
||||
- [Non-Inherited Icon Colors](#non-inherited-icon-colors)
|
||||
- [Sound Effects](#sound-effects-optional)
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +78,6 @@ my_custom_theme/
|
||||
├── metainfo.ini
|
||||
├── fonts/
|
||||
├── images/
|
||||
├── sounds/
|
||||
└── styles/
|
||||
├── __init__.py # This empty file makes the directory a Python package
|
||||
├── constants.py
|
||||
@@ -143,7 +142,7 @@ THEME_INHERITS = "classic"
|
||||
|
||||
If `THEME_INHERITS` is not defined, the theme inherits styles from `standart`.
|
||||
|
||||
Fonts, icons, images, and sounds also follow the inheritance chain — the first match found walking from child to `standart` wins. If your theme has a `fonts/` directory, it takes priority over the parent's. Missing sound events fall back individually to the parent theme. Screenshots are the exception — they are loaded only from the current theme, not inherited.
|
||||
Fonts, icons, and images also follow the inheritance chain — the first match found walking from child to `standart` wins. If your theme has a `fonts/` directory, it takes priority over the parent's. If it doesn't, the parent's fonts are loaded. Screenshots are the exception — they are loaded only from the current theme, not inherited.
|
||||
|
||||
### How Inheritance Works
|
||||
|
||||
@@ -241,39 +240,6 @@ LIBRARY_LAYOUT_MODE = "grid"
|
||||
- `grid`: multi-column card grid (classic behavior).
|
||||
- `list`: horizontal row-style cards (launcher-style list).
|
||||
|
||||
Card geometry is controlled by `GAME_CARD_LIST` and `GAME_CARD_GRID`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
"extra_margin": 8,
|
||||
"spacing": 12,
|
||||
"cover_load_size": 64,
|
||||
"cover_size": 56,
|
||||
"min_cover_size": 48,
|
||||
"cover_left_margin": 10,
|
||||
"row_height": 72,
|
||||
"min_row_height": 68,
|
||||
"cover_radius": 8,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
"card_height_ratio": 1.8,
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
```
|
||||
|
||||
Values are logical pixels. `cover_radius` applies to static and animated covers;
|
||||
`border_radius` applies to the painted focus and hover border. Grid cover width
|
||||
continues to follow the library card-size setting.
|
||||
|
||||
Card shadows use the shared `shadow_blur_radius` and `shadow_offset` theme
|
||||
constants in both grid and list layouts.
|
||||
|
||||
This is a theme-level option and does not depend on app settings.
|
||||
|
||||
---
|
||||
@@ -299,158 +265,84 @@ Economy mode also forces the compact detail page layout.
|
||||
You can control the detail page background style from the theme via `styles.py`:
|
||||
|
||||
```python
|
||||
# One of the background modes listed below
|
||||
# "gradient" (default), "static_waves", or "waves"
|
||||
DETAIL_PAGE_BG_MODE = "gradient"
|
||||
```
|
||||
|
||||
- `gradient`: diagonal linear gradient extracted from the cover image palette (default).
|
||||
- `static_waves`: gradient background with static (non-animated) wave shapes overlaid.
|
||||
- `waves`: gradient background with animated wave shapes that drift over time.
|
||||
- `aurora`: animated vertical aurora ribbons.
|
||||
- `metaballs`: animated glowing metaballs.
|
||||
- `leaf`: animated falling leaves with a configurable foliage type.
|
||||
- `veins`: animated connected vein pattern.
|
||||
- `diagnostics`: animated grid, signal trace, scanner, and compatibility status.
|
||||
|
||||
Prefix an animated background mode with `static_` to render one frame without
|
||||
starting its animation timer. For example:
|
||||
### Custom Gradient Stops
|
||||
|
||||
By default, the detail page gradient is generated from the cover image palette with evenly distributed positions. Define `DETAIL_PAGE_GRADIENT` in `styles.py` to override positions, colors, or both:
|
||||
|
||||
```python
|
||||
DETAIL_PAGE_BG_MODE = "static_diagnostics"
|
||||
# Custom positions — palette colors redistributed to these positions
|
||||
DETAIL_PAGE_GRADIENT = [0, 0.3, 0.7, 1]
|
||||
|
||||
# Custom colors — position and color for each stop
|
||||
DETAIL_PAGE_GRADIENT = [
|
||||
{"position": 0, "color": "#101010"},
|
||||
{"position": 0.5, "color": "#202020"},
|
||||
{"position": 1, "color": "#303030"},
|
||||
]
|
||||
|
||||
# Raw QSS stop string
|
||||
DETAIL_PAGE_GRADIENT = "stop:0 #101010, stop:0.5 #202020, stop:1 #303030"
|
||||
```
|
||||
|
||||
The supported static modes are `static_waves`, `static_aurora`,
|
||||
`static_metaballs`, `static_leaf`, `static_veins`, and
|
||||
`static_diagnostics`.
|
||||
### Gradient Type and Number of Colors Extracted from the Cover
|
||||
|
||||
Set the number of colors extracted from the cover:
|
||||
You can set the gradient type (linear or radial), by defining `DETAIL_PAGE_GRADIENT_TYPE` in `styles.py`:
|
||||
|
||||
```python
|
||||
# Accepts `linear` or `radial`
|
||||
DETAIL_PAGE_GRADIENT_TYPE = 'linear'
|
||||
```
|
||||
|
||||
Set the gradient direction:
|
||||
|
||||
```python
|
||||
# For linear gradients
|
||||
DETAIL_PAGE_GRADIENT_X1 = 0
|
||||
DETAIL_PAGE_GRADIENT_Y1 = 0
|
||||
DETAIL_PAGE_GRADIENT_X2 = 1
|
||||
DETAIL_PAGE_GRADIENT_Y2 = 0
|
||||
|
||||
# For radial gradients
|
||||
DETAIL_PAGE_GRADIENT_CX = 0.3
|
||||
DETAIL_PAGE_GRADIENT_CY = 0.3
|
||||
DETAIL_PAGE_GRADIENT_RADIUS = 0.7
|
||||
DETAIL_PAGE_GRADIENT_FX = 0.3
|
||||
DETAIL_PAGE_GRADIENT_FY = 0.3
|
||||
```
|
||||
|
||||
Set the number of colors to extract from the cover:
|
||||
|
||||
```python
|
||||
# Must be at least 2
|
||||
DETAIL_PAGE_PALETTE_COLORS = 5
|
||||
```
|
||||
|
||||
### Background Configuration
|
||||
### Wave Configuration
|
||||
|
||||
Background modes use `DETAIL_PAGE_BACKGROUNDS`:
|
||||
When `DETAIL_PAGE_BG_MODE` is `"static_waves"` or `"waves"`, you can customize the wave appearance via the `DETAIL_PAGE_WAVES` dictionary:
|
||||
|
||||
```python
|
||||
DETAIL_PAGE_BACKGROUNDS = {
|
||||
# Common settings for aurora, diagnostics, leaf, metaballs, and veins.
|
||||
"animation_interval_ms": 30, # Milliseconds between frames.
|
||||
"animation_speed": 0.03, # Phase increment per frame.
|
||||
"gradient": {
|
||||
# None uses cover colors.
|
||||
# [0, 0.3, 0.7, 1] distributes cover colors at custom positions.
|
||||
# [{"position": 0, "color": "#101010"}, ...] sets custom colors.
|
||||
# "stop:0 #101010, stop:1 #303030" accepts raw QSS stops.
|
||||
"stops": None,
|
||||
"type": "linear", # "linear" or "radial".
|
||||
"x1": 0, # Linear gradient start X (0.0-1.0).
|
||||
"y1": 0, # Linear gradient start Y (0.0-1.0).
|
||||
"x2": 1, # Linear gradient end X (0.0-1.0).
|
||||
"y2": 1, # Linear gradient end Y (0.0-1.0).
|
||||
"cx": 0.5, # Radial gradient center X (0.0-1.0).
|
||||
"cy": 0.5, # Radial gradient center Y (0.0-1.0).
|
||||
"radius": 0.5, # Radial gradient radius relative to the page.
|
||||
"fx": 0.5, # Radial gradient focal point X (0.0-1.0).
|
||||
"fy": 0.5, # Radial gradient focal point Y (0.0-1.0).
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4, # Number of overlapping wave layers.
|
||||
"wave_amplitude_ratio": 0.06, # Wave height / page height.
|
||||
"wave_frequency": 2.0, # Oscillations across the page.
|
||||
"layer_spacing_ratio": 0.04, # Layer gap / page height.
|
||||
"base_opacity": 0.45, # First layer opacity (0.0-1.0).
|
||||
"opacity_decay": 0.85, # Opacity multiplier for later layers.
|
||||
"animation_speed": 0.03, # Wave phase increment per frame.
|
||||
"animation_interval_ms": 30, # Milliseconds between wave frames.
|
||||
},
|
||||
"aurora": {
|
||||
"line_count": 8, # Number of vertical ribbons.
|
||||
"line_width_ratio": 0.006, # Ribbon width / page width.
|
||||
"opacity": 0.35, # Ribbon opacity (0.0-1.0).
|
||||
"steps": 40, # Curve segments per ribbon.
|
||||
"frequency": 5.0, # Bends along each ribbon.
|
||||
"speed": 1.2, # Ribbon motion speed multiplier.
|
||||
"phase_step": 0.8, # Phase difference between ribbons.
|
||||
"edge_offset": 0.5, # Offset used to distribute ribbons.
|
||||
"drift_ratio": 0.06, # Horizontal drift / page width.
|
||||
},
|
||||
"diagnostics": {
|
||||
"color": color_accent, # Color of every diagnostics element.
|
||||
"font_family": font_family,# Status text font.
|
||||
"font_size": 13, # Status text size in pixels.
|
||||
"grid_spacing": 64, # Grid cell size in pixels.
|
||||
"grid_line_width": 1, # Grid line width in pixels.
|
||||
"grid_opacity": 0.08, # Grid opacity (0.0-1.0).
|
||||
"trace_baseline": 0.78, # Trace Y position / page height.
|
||||
"trace_amplitude": 0.025, # Trace height / page height.
|
||||
"trace_frequency": 28, # Oscillations across the page.
|
||||
"trace_speed": 1.4, # Trace motion speed multiplier.
|
||||
"trace_step_width": 4, # Pixels between trace samples.
|
||||
"trace_line_width": 2, # Trace line width in pixels.
|
||||
"trace_opacity": 0.45, # Trace opacity (0.0-1.0).
|
||||
"text_opacity": 0.7, # Status text opacity (0.0-1.0).
|
||||
"text_x_ratio": 0.025, # Text left margin / page width.
|
||||
"first_line_y_ratio": 0.88,# First status Y / page height.
|
||||
"second_line_y_ratio": 0.91,# Second status Y / page height.
|
||||
"status_text": "COMPATIBILITY LAYER: OPERATIONAL", # First status.
|
||||
"containment_text": "WINDOWS CONTAINMENT: ACTIVE", # Second status.
|
||||
"sweep_offset": 0.25, # Initial scanner X position (0.0-1.0).
|
||||
"sweep_speed": 0.08, # Scanner motion speed multiplier.
|
||||
"sweep_width": 2, # Scanner width in pixels.
|
||||
"sweep_opacity": 0.2, # Scanner opacity (0.0-1.0).
|
||||
},
|
||||
"metaballs": {
|
||||
"count": 18, # Number of glowing balls.
|
||||
"seed_offset": 1, # Seed offset for another layout.
|
||||
"min_radius_ratio": 0.08, # Minimum radius / page height.
|
||||
"radius_range": 0.08, # Random extra radius range.
|
||||
"min_speed": 0.004, # Minimum vertical travel speed.
|
||||
"opacity": 0.55, # Ball center opacity (0.0-1.0).
|
||||
"x_seed": 1.31, # Horizontal placement seed multiplier.
|
||||
"y_seed": 7.13, # Vertical placement seed multiplier.
|
||||
"speed_seed": 2.71, # Individual speed seed multiplier.
|
||||
},
|
||||
"leaf": {
|
||||
# "generic", "sakura", "oak", "maple", or "birch".
|
||||
"type": "generic", # Shape of the falling foliage.
|
||||
"count": 32, # Number of leaves.
|
||||
"seed_offset": 1, # Seed offset for another layout.
|
||||
"min_size_ratio": 0.009, # Minimum size / page height.
|
||||
"size_range": 0.009, # Random extra size range.
|
||||
"min_speed": 0.002, # Minimum falling speed.
|
||||
"opacity": 0.65, # Leaf opacity (0.0-1.0).
|
||||
"sway_ratio": 0.04, # Horizontal sway / page width.
|
||||
"rotation_speed": 40, # Degrees per animation phase.
|
||||
"x_seed": 1.31, # Horizontal placement seed multiplier.
|
||||
"y_seed": 7.13, # Vertical placement seed multiplier.
|
||||
"speed_seed": 2.71, # Individual speed seed multiplier.
|
||||
},
|
||||
"veins": {
|
||||
"columns": 9, # Number of point columns.
|
||||
"rows": 6, # Number of point rows.
|
||||
"seed_offset": 1, # Seed offset for another pattern.
|
||||
"cell_offset": 0.5, # Point position inside a grid cell.
|
||||
"motion_ratio": 0.12, # Horizontal movement / cell width.
|
||||
"line_width": 2, # Connection width in pixels.
|
||||
"opacity": 0.3, # Connection opacity (0.0-1.0).
|
||||
},
|
||||
DETAIL_PAGE_WAVES = {
|
||||
"layer_count": 4, # Number of wave layers
|
||||
"wave_amplitude_ratio": 0.06, # Wave height as ratio of page height
|
||||
"wave_frequency": 2.0, # Number of wave cycles across the page
|
||||
"layer_spacing_ratio": 0.04, # Vertical spacing between layers as ratio of page height
|
||||
"base_opacity": 0.45, # Opacity of the first wave layer (0.0–1.0)
|
||||
"opacity_decay": 0.85, # Opacity multiplier for each subsequent layer
|
||||
"animation_speed": 0.03, # Phase increment per tick — "waves" mode only (higher = faster)
|
||||
"animation_interval_ms": 30, # Timer interval in ms — "waves" mode only (lower = smoother)
|
||||
}
|
||||
```
|
||||
|
||||
Each named dictionary contains the visual constants for that effect. Copy the
|
||||
corresponding dictionary from the standard theme before overriding individual
|
||||
values so that all required keys remain present.
|
||||
|
||||
The `diagnostics` effect also exposes `status_text` and `containment_text`. The
|
||||
standard theme uses `COMPATIBILITY LAYER: OPERATIONAL` and
|
||||
`WINDOWS CONTAINMENT: ACTIVE`.
|
||||
|
||||
Existing themes may continue using `DETAIL_PAGE_GRADIENT`,
|
||||
`DETAIL_PAGE_GRADIENT_TYPE`, `DETAIL_PAGE_GRADIENT_X1` through
|
||||
`DETAIL_PAGE_GRADIENT_FY`, and `DETAIL_PAGE_WAVES`. Legacy values override the
|
||||
corresponding nested settings.
|
||||
Wave colors are derived from the darkened cover image palette. Each layer uses a different palette color with decreasing opacity. The `animation_speed` and `animation_interval_ms` parameters only affect the `"waves"` mode.
|
||||
|
||||
---
|
||||
|
||||
@@ -850,27 +742,3 @@ When no `ICON_COLORS` entry matches an `AutoSizeButton` state, the button falls
|
||||
`ICON_COLORS` is not inherited from parent themes. If a child theme needs SVG recoloring, define its own `ICON_COLORS` dictionary in that theme.
|
||||
|
||||
---
|
||||
|
||||
## Sound Effects (optional)
|
||||
|
||||
Place UI sound effects in the theme's `sounds/` directory. File names must match an event name; do not add numeric suffixes such as `_001`:
|
||||
|
||||
| Event | Used for |
|
||||
|---|---|
|
||||
| `navigate` | Moving focus between UI items |
|
||||
| `click` | Pressing buttons, clickable labels, and confirming actions |
|
||||
| `back` | Returning or closing with the Back action |
|
||||
| `toggle` | Changing a checkbox or opening a combo box |
|
||||
| `open` | Opening pages, dialogs, and menus |
|
||||
| `tab_switch` | Switching application tabs |
|
||||
| `game_launch` | Successful game process launch |
|
||||
| `gamepad_connect` | Connecting a gamepad |
|
||||
| `gamepad_off` | Disconnecting a gamepad |
|
||||
|
||||
Use uncompressed PCM WAV files, for example `sounds/navigate.wav`. Other audio formats are not supported.
|
||||
|
||||
Sounds are inherited per event. A child theme can override only `navigate`; missing events are resolved through its parent chain up to `standart`.
|
||||
|
||||
Theme validation checks the extension, file size, and format signature before a sound is loaded. Sound files larger than 20 MiB, symlinks, files outside the theme directory, and files whose content does not match their extension are rejected. Include the license and attribution required by every sound asset distributed with a theme.
|
||||
|
||||
---
|
||||
@@ -13,7 +13,8 @@
|
||||
- [Режим сетки библиотеки](#режим-сетки-библиотеки)
|
||||
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
|
||||
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
|
||||
- [Настройка фона](#настройка-фона)
|
||||
- [Свои стопы градиента](#свои-стопы-градиента)
|
||||
- [Настройка волн](#настройка-волн)
|
||||
- [Прелоадер](#прелоадер)
|
||||
- [Уголок источника (Лента)](#уголок-источника-лента)
|
||||
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
|
||||
@@ -25,7 +26,6 @@
|
||||
- [Перекраска SVG-иконок](#перекраска-svg-иконок)
|
||||
- [Перекраска состояний AutoSizeButton](#перекраска-состояний-autosizebutton)
|
||||
- [Ненаследуемые цвета иконок](#ненаследуемые-цвета-иконок)
|
||||
- [Звуковые эффекты](#звуковые-эффекты-опционально)
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +78,6 @@ my_custom_theme/
|
||||
├── metainfo.ini
|
||||
├── fonts/
|
||||
├── images/
|
||||
├── sounds/
|
||||
└── styles/
|
||||
├── __init__.py # Этот пустой файл делает директорию Python-пакетом
|
||||
├── constants.py
|
||||
@@ -143,7 +142,7 @@ THEME_INHERITS = "classic"
|
||||
|
||||
Если `THEME_INHERITS` не указан, тема наследует стили от `standart`.
|
||||
|
||||
Шрифты, иконки, изображения и звуки также следуют цепочке наследования — побеждает первое совпадение при обходе от дочерней темы до `standart`. Если в вашей теме есть директория `fonts/`, она имеет приоритет над родительской. Отсутствующие звуковые события по отдельности загружаются из родительской темы. Скриншоты — исключение: загружаются только из текущей темы, не наследуются.
|
||||
Шрифты, иконки и изображения также следуют цепочке наследования — побеждает первое совпадение при обходе от дочерней темы до `standart`. Если в вашей теме есть директория `fonts/`, она имеет приоритет над родительской. Если нет — загружаются шрифты родителя. Скриншоты — исключение: загружаются только из текущей темы, не наследуются.
|
||||
|
||||
### Как работает наследование
|
||||
|
||||
@@ -241,40 +240,6 @@ LIBRARY_LAYOUT_MODE = "grid"
|
||||
- `grid`: многоколоночная сетка карточек (классическое поведение).
|
||||
- `list`: горизонтальные карточки-строки (формат лаунчера).
|
||||
|
||||
Геометрия карточек управляется через `GAME_CARD_LIST` и `GAME_CARD_GRID`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
"extra_margin": 8,
|
||||
"spacing": 12,
|
||||
"cover_load_size": 64,
|
||||
"cover_size": 56,
|
||||
"min_cover_size": 48,
|
||||
"cover_left_margin": 10,
|
||||
"row_height": 72,
|
||||
"min_row_height": 68,
|
||||
"cover_radius": 8,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
"card_height_ratio": 1.8,
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
```
|
||||
|
||||
Значения задаются в логических пикселях. `cover_radius` применяется к
|
||||
статическим и анимированным обложкам, а `border_radius` — к отрисовываемой
|
||||
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
|
||||
определяется настройкой размера карточек библиотеки.
|
||||
|
||||
Тень карточек использует общие константы темы `shadow_blur_radius` и
|
||||
`shadow_offset` как в сеточном, так и в списочном режиме.
|
||||
|
||||
Это параметр уровня темы и не зависит от настроек приложения.
|
||||
|
||||
---
|
||||
@@ -300,157 +265,84 @@ DETAIL_PAGE_LAYOUT_MODE = "full"
|
||||
Вы можете управлять стилем фона детальной страницы из темы через `styles.py`:
|
||||
|
||||
```python
|
||||
# Один из перечисленных ниже режимов фона
|
||||
# "gradient" (по умолчанию), "static_waves" или "waves"
|
||||
DETAIL_PAGE_BG_MODE = "gradient"
|
||||
```
|
||||
|
||||
- `gradient`: диагональный линейный градиент, извлечённый из палитры обложки (по умолчанию).
|
||||
- `static_waves`: градиентный фон со статическими (без анимации) волнами.
|
||||
- `waves`: градиентный фон с анимированными волнами, движущимися во времени.
|
||||
- `aurora`: анимированные вертикальные ленты северного сияния.
|
||||
- `metaballs`: анимированные светящиеся метаболы.
|
||||
- `leaf`: анимированная падающая листва с настраиваемым типом.
|
||||
- `veins`: анимированный связанный узор прожилок.
|
||||
- `diagnostics`: анимированная сетка, осциллограмма, сканер и статус совместимости.
|
||||
|
||||
Добавьте префикс `static_` к анимированному режиму фона, чтобы отрисовать один
|
||||
кадр без запуска таймера анимации. Например:
|
||||
### Свои стопы градиента
|
||||
|
||||
По умолчанию градиент детальной страницы строится из палитры обложки с равномерными позициями. Задайте `DETAIL_PAGE_GRADIENT` в `styles.py`, чтобы переопределить позиции, цвета или и то, и другое:
|
||||
|
||||
```python
|
||||
DETAIL_PAGE_BG_MODE = "static_diagnostics"
|
||||
# Свои позиции — цвета обложки распределяются по этим позициям
|
||||
DETAIL_PAGE_GRADIENT = [0, 0.3, 0.7, 1]
|
||||
|
||||
# Свои цвета — позиция и цвет для каждого стопа
|
||||
DETAIL_PAGE_GRADIENT = [
|
||||
{"position": 0, "color": "#101010"},
|
||||
{"position": 0.5, "color": "#202020"},
|
||||
{"position": 1, "color": "#303030"},
|
||||
]
|
||||
|
||||
# Готовая QSS-строка
|
||||
DETAIL_PAGE_GRADIENT = "stop:0 #101010, stop:0.5 #202020, stop:1 #303030"
|
||||
```
|
||||
|
||||
Доступные статические режимы: `static_waves`, `static_aurora`,
|
||||
`static_metaballs`, `static_leaf`, `static_veins` и `static_diagnostics`.
|
||||
### Тип градиента и количество цветов извлекаемых из обложки
|
||||
|
||||
Количество цветов, извлекаемых из обложки:
|
||||
Вы можете задать тип градиента (linear/radial), задав `DETAIL_PAGE_GRADIENT_TYPE` в `styles.py`:
|
||||
|
||||
```python
|
||||
# Принимает значения `linear` или `radial`
|
||||
DETAIL_PAGE_GRADIENT_TYPE = 'linear'
|
||||
```
|
||||
|
||||
Задать направление градиента:
|
||||
|
||||
```python
|
||||
# Для линейного
|
||||
DETAIL_PAGE_GRADIENT_X1 = 0
|
||||
DETAIL_PAGE_GRADIENT_Y1 = 0
|
||||
DETAIL_PAGE_GRADIENT_X2 = 1
|
||||
DETAIL_PAGE_GRADIENT_Y2 = 0
|
||||
|
||||
# Для радиального
|
||||
DETAIL_PAGE_GRADIENT_CX = 0.3
|
||||
DETAIL_PAGE_GRADIENT_CY = 0.3
|
||||
DETAIL_PAGE_GRADIENT_RADIUS = 0.7
|
||||
DETAIL_PAGE_GRADIENT_FX = 0.3
|
||||
DETAIL_PAGE_GRADIENT_FY = 0.3
|
||||
```
|
||||
|
||||
Задать количество цветов извлекаемых из обложки:
|
||||
|
||||
```python
|
||||
# Не менее 2
|
||||
DETAIL_PAGE_PALETTE_COLORS = 5
|
||||
```
|
||||
|
||||
### Настройка фона
|
||||
### Настройка волн
|
||||
|
||||
Режимы фона используют словарь `DETAIL_PAGE_BACKGROUNDS`:
|
||||
При `DETAIL_PAGE_BG_MODE` равном `"static_waves"` или `"waves"` можно настроить внешний вид волн через словарь `DETAIL_PAGE_WAVES`:
|
||||
|
||||
```python
|
||||
DETAIL_PAGE_BACKGROUNDS = {
|
||||
# Общие настройки aurora, diagnostics, leaf, metaballs и veins.
|
||||
"animation_interval_ms": 30, # Интервал между кадрами в мс.
|
||||
"animation_speed": 0.03, # Приращение фазы за кадр.
|
||||
"gradient": {
|
||||
# None берёт цвета обложки.
|
||||
# [0, 0.3, 0.7, 1] распределяет цвета обложки по своим позициям.
|
||||
# [{"position": 0, "color": "#101010"}, ...] задаёт свои цвета.
|
||||
# "stop:0 #101010, stop:1 #303030" принимает готовые стопы QSS.
|
||||
"stops": None,
|
||||
"type": "linear", # "linear" или "radial".
|
||||
"x1": 0, # Начальная координата X (0.0-1.0).
|
||||
"y1": 0, # Начальная координата Y (0.0-1.0).
|
||||
"x2": 1, # Конечная координата X (0.0-1.0).
|
||||
"y2": 1, # Конечная координата Y (0.0-1.0).
|
||||
"cx": 0.5, # Координата X центра радиального градиента.
|
||||
"cy": 0.5, # Координата Y центра радиального градиента.
|
||||
"radius": 0.5, # Радиус градиента относительно страницы.
|
||||
"fx": 0.5, # Координата X фокусной точки.
|
||||
"fy": 0.5, # Координата Y фокусной точки.
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4, # Количество слоёв волн.
|
||||
"wave_amplitude_ratio": 0.06, # Высота волны / высота страницы.
|
||||
"wave_frequency": 2.0, # Количество колебаний по ширине.
|
||||
"layer_spacing_ratio": 0.04, # Интервал между слоями / высота.
|
||||
"base_opacity": 0.45, # Прозрачность первого слоя.
|
||||
"opacity_decay": 0.85, # Множитель прозрачности следующих.
|
||||
"animation_speed": 0.03, # Приращение фазы волны за кадр.
|
||||
"animation_interval_ms": 30, # Интервал между кадрами волн.
|
||||
},
|
||||
"aurora": {
|
||||
"line_count": 8, # Количество вертикальных лент.
|
||||
"line_width_ratio": 0.006, # Ширина ленты / ширина страницы.
|
||||
"opacity": 0.35, # Прозрачность лент.
|
||||
"steps": 40, # Сегменты кривой каждой ленты.
|
||||
"frequency": 5.0, # Количество изгибов ленты.
|
||||
"speed": 1.2, # Множитель скорости движения.
|
||||
"phase_step": 0.8, # Сдвиг фазы между лентами.
|
||||
"edge_offset": 0.5, # Смещение при распределении лент.
|
||||
"drift_ratio": 0.06, # Отклонение / ширина страницы.
|
||||
},
|
||||
"diagnostics": {
|
||||
"color": color_accent, # Цвет всех элементов диагностики.
|
||||
"font_family": font_family,# Шрифт статусного текста.
|
||||
"font_size": 13, # Размер текста в пикселях.
|
||||
"grid_spacing": 64, # Размер ячейки сетки в пикселях.
|
||||
"grid_line_width": 1, # Толщина линий сетки.
|
||||
"grid_opacity": 0.08, # Прозрачность сетки.
|
||||
"trace_baseline": 0.78, # Положение сигнала / высота страницы.
|
||||
"trace_amplitude": 0.025, # Высота сигнала / высота страницы.
|
||||
"trace_frequency": 28, # Количество колебаний по ширине.
|
||||
"trace_speed": 1.4, # Множитель скорости сигнала.
|
||||
"trace_step_width": 4, # Интервал между отсчётами сигнала.
|
||||
"trace_line_width": 2, # Толщина линии сигнала.
|
||||
"trace_opacity": 0.45, # Прозрачность сигнала.
|
||||
"text_opacity": 0.7, # Прозрачность статусного текста.
|
||||
"text_x_ratio": 0.025, # Левый отступ / ширина страницы.
|
||||
"first_line_y_ratio": 0.88,# Положение первой строки / высота.
|
||||
"second_line_y_ratio": 0.91,# Положение второй строки / высота.
|
||||
"status_text": "COMPATIBILITY LAYER: OPERATIONAL", # Первая строка.
|
||||
"containment_text": "WINDOWS CONTAINMENT: ACTIVE", # Вторая строка.
|
||||
"sweep_offset": 0.25, # Начальная позиция сканера.
|
||||
"sweep_speed": 0.08, # Множитель скорости сканера.
|
||||
"sweep_width": 2, # Толщина сканера в пикселях.
|
||||
"sweep_opacity": 0.2, # Прозрачность сканера.
|
||||
},
|
||||
"metaballs": {
|
||||
"count": 18, # Количество светящихся метаболов.
|
||||
"seed_offset": 1, # Смещение seed для другого рисунка.
|
||||
"min_radius_ratio": 0.08, # Минимальный радиус / высота.
|
||||
"radius_range": 0.08, # Случайный дополнительный радиус.
|
||||
"min_speed": 0.004, # Минимальная скорость движения.
|
||||
"opacity": 0.55, # Прозрачность центра метабола.
|
||||
"x_seed": 1.31, # Seed горизонтального положения.
|
||||
"y_seed": 7.13, # Seed вертикального положения.
|
||||
"speed_seed": 2.71, # Seed индивидуальной скорости.
|
||||
},
|
||||
"leaf": {
|
||||
# "generic", "sakura", "oak", "maple" или "birch".
|
||||
"type": "generic", # Форма падающей листвы.
|
||||
"count": 32, # Количество листьев.
|
||||
"seed_offset": 1, # Смещение seed для другого рисунка.
|
||||
"min_size_ratio": 0.009, # Минимальный размер / высота.
|
||||
"size_range": 0.009, # Случайный дополнительный размер.
|
||||
"min_speed": 0.002, # Минимальная скорость падения.
|
||||
"opacity": 0.65, # Прозрачность листьев.
|
||||
"sway_ratio": 0.04, # Покачивание / ширина страницы.
|
||||
"rotation_speed": 40, # Градусы вращения на единицу фазы.
|
||||
"x_seed": 1.31, # Seed горизонтального положения.
|
||||
"y_seed": 7.13, # Seed вертикального положения.
|
||||
"speed_seed": 2.71, # Seed индивидуальной скорости.
|
||||
},
|
||||
"veins": {
|
||||
"columns": 9, # Количество столбцов точек.
|
||||
"rows": 6, # Количество строк точек.
|
||||
"seed_offset": 1, # Смещение seed для другого узора.
|
||||
"cell_offset": 0.5, # Положение точки внутри ячейки.
|
||||
"motion_ratio": 0.12, # Движение точки / ширина ячейки.
|
||||
"line_width": 2, # Толщина соединений в пикселях.
|
||||
"opacity": 0.3, # Прозрачность соединений.
|
||||
},
|
||||
DETAIL_PAGE_WAVES = {
|
||||
"layer_count": 4, # Количество слоёв волн
|
||||
"wave_amplitude_ratio": 0.06, # Высота волны как отношение к высоте страницы
|
||||
"wave_frequency": 2.0, # Количество циклов волны на странице
|
||||
"layer_spacing_ratio": 0.04, # Вертикальный отступ между слоями как отношение к высоте страницы
|
||||
"base_opacity": 0.45, # Прозрачность первого слоя волны (0.0–1.0)
|
||||
"opacity_decay": 0.85, # Множитель прозрачности для каждого следующего слоя
|
||||
"animation_speed": 0.03, # Приращение фазы за тик — только для режима "waves" (больше = быстрее)
|
||||
"animation_interval_ms": 30, # Интервал таймера в мс — только для режима "waves" (меньше = плавнее)
|
||||
}
|
||||
```
|
||||
|
||||
Каждый именованный словарь содержит визуальные константы соответствующего
|
||||
эффекта. Перед изменением отдельных значений скопируйте нужный словарь из
|
||||
стандартной темы, чтобы сохранить все обязательные ключи.
|
||||
|
||||
Эффект `diagnostics` также предоставляет параметры `status_text` и
|
||||
`containment_text`. В стандартной теме используются строки
|
||||
`COMPATIBILITY LAYER: OPERATIONAL` и `WINDOWS CONTAINMENT: ACTIVE`.
|
||||
|
||||
Существующие темы могут продолжать использовать `DETAIL_PAGE_GRADIENT`,
|
||||
`DETAIL_PAGE_GRADIENT_TYPE`, переменные от `DETAIL_PAGE_GRADIENT_X1` до
|
||||
`DETAIL_PAGE_GRADIENT_FY` и `DETAIL_PAGE_WAVES`. Старые значения
|
||||
переопределяют соответствующие вложенные настройки.
|
||||
Цвета волн берутся из затемнённой палитры обложки. Каждый слой использует разный цвет палитры с уменьшающейся прозрачностью. Параметры `animation_speed` и `animation_interval_ms` влияют только на режим `"waves"`.
|
||||
|
||||
---
|
||||
|
||||
@@ -850,27 +742,3 @@ ICON_COLORS = {
|
||||
`ICON_COLORS` не наследуется от родительских тем. Если дочерней теме нужна перекраска SVG, задайте собственный словарь `ICON_COLORS` в этой теме.
|
||||
|
||||
---
|
||||
|
||||
## Звуковые эффекты (опционально)
|
||||
|
||||
Поместите звуковые эффекты интерфейса в директорию `sounds/` темы. Имя файла должно совпадать с именем события; не добавляйте числовые суффиксы наподобие `_001`:
|
||||
|
||||
| Событие | Назначение |
|
||||
|---|---|
|
||||
| `navigate` | Перемещение фокуса между элементами интерфейса |
|
||||
| `click` | Нажатие кнопок, кликабельных меток и подтверждение действий |
|
||||
| `back` | Возврат или закрытие действием Back |
|
||||
| `toggle` | Изменение флажка или открытие комбобокса |
|
||||
| `open` | Открытие страниц, диалогов и меню |
|
||||
| `tab_switch` | Переключение вкладок приложения |
|
||||
| `game_launch` | Успешный запуск процесса игры |
|
||||
| `gamepad_connect` | Подключение геймпада |
|
||||
| `gamepad_off` | Отключение геймпада |
|
||||
|
||||
Используйте несжатые PCM WAV-файлы, например `sounds/navigate.wav`. Другие аудиоформаты не поддерживаются.
|
||||
|
||||
Звуки наследуются отдельно для каждого события. Дочерняя тема может переопределить только `navigate`; отсутствующие события ищутся по цепочке родительских тем вплоть до `standart`.
|
||||
|
||||
При проверке темы проверяются расширение, размер и сигнатура формата каждого звука. Файлы размером более 20 МиБ, символические ссылки, файлы за пределами директории темы и файлы, содержимое которых не соответствует расширению, отклоняются. Добавляйте лицензию и требуемую атрибуцию для каждого звукового ресурса, распространяемого вместе с темой.
|
||||
|
||||
---
|
||||
+1
-7
@@ -1,5 +1,5 @@
|
||||
project('portprotonqt', 'c',
|
||||
version: '1.3.1',
|
||||
version: '1.3.2',
|
||||
meson_version: '>= 0.61.2',
|
||||
license: 'GPL-3.0',
|
||||
)
|
||||
@@ -84,12 +84,6 @@ install_data(
|
||||
install_dir: datadir / 'applications',
|
||||
)
|
||||
|
||||
install_data(
|
||||
'build-aux/share/applications/ru.linux_gaming.PortProtonQt.log.desktop',
|
||||
'build-aux/share/applications/ru.linux_gaming.PortProtonQt.silent.desktop',
|
||||
install_dir: datadir / 'applications',
|
||||
)
|
||||
|
||||
# Install MIME type
|
||||
install_data(
|
||||
'build-aux/share/mime/packages/ru.linux_gaming.PortProtonQt.xml',
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from portprotonqt.animations.detail_page import DetailPageAnimations
|
||||
from portprotonqt.animations.detail_background import DetailBackgroundAnimations
|
||||
from portprotonqt.animations.game_card import GameCardAnimations
|
||||
from portprotonqt.animations.library_controls import ExpandingSearchAnimation, LibraryControlsAnimation
|
||||
from portprotonqt.animations.virtual_keyboard import VirtualKeyboardAnimations
|
||||
|
||||
__all__ = [
|
||||
"DetailPageAnimations",
|
||||
"DetailBackgroundAnimations",
|
||||
"ExpandingSearchAnimation",
|
||||
"GameCardAnimations",
|
||||
"LibraryControlsAnimation",
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
"""Procedural detail page backgrounds adapted from omikuji."""
|
||||
|
||||
import math
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QRadialGradient,
|
||||
)
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
class DetailBackgroundAnimations:
|
||||
"""Manage animated and static omikuji detail backgrounds."""
|
||||
|
||||
effects = frozenset({"aurora", "diagnostics", "leaf", "metaballs", "veins"})
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._states: WeakKeyDictionary[QWidget, dict] = WeakKeyDictionary()
|
||||
|
||||
def setup(self, page: QWidget, palette: list, theme) -> bool:
|
||||
"""Setup an omikuji background and report whether its mode matched."""
|
||||
mode = getattr(theme, "DETAIL_PAGE_BG_MODE", "gradient")
|
||||
effect = mode.removeprefix("static_")
|
||||
if effect not in self.effects:
|
||||
self.remove(page)
|
||||
return False
|
||||
config = theme.DETAIL_PAGE_BACKGROUNDS
|
||||
self.remove(page)
|
||||
state = {
|
||||
"effect": effect,
|
||||
"palette": palette,
|
||||
"phase": 0.0,
|
||||
"config": config,
|
||||
"original_paint": page.paintEvent,
|
||||
"timer": None,
|
||||
}
|
||||
self._states[page] = state
|
||||
|
||||
def paint_event(event) -> None:
|
||||
state["original_paint"](event)
|
||||
self._paint(page, state)
|
||||
|
||||
page.paintEvent = paint_event
|
||||
if not mode.startswith("static_"):
|
||||
self._start_timer(page, state)
|
||||
return True
|
||||
|
||||
def remove(self, page: QWidget) -> None:
|
||||
"""Remove the omikuji background from a detail page."""
|
||||
state = self._states.pop(page, None)
|
||||
if state is None:
|
||||
return
|
||||
timer = state["timer"]
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
page.paintEvent = state["original_paint"]
|
||||
|
||||
def _start_timer(self, page: QWidget, state: dict) -> None:
|
||||
config = state["config"]
|
||||
timer = QTimer(page)
|
||||
timer.setInterval(config["animation_interval_ms"])
|
||||
|
||||
def tick() -> None:
|
||||
if page.isHidden() or page.parent() is None:
|
||||
timer.stop()
|
||||
return
|
||||
state["phase"] += config["animation_speed"]
|
||||
page.update()
|
||||
|
||||
timer.timeout.connect(tick)
|
||||
timer.start()
|
||||
state["timer"] = timer
|
||||
|
||||
def _paint(self, page: QWidget, state: dict) -> None:
|
||||
if not state["palette"]:
|
||||
return
|
||||
painter = QPainter(page)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(page.rect())
|
||||
painters = {
|
||||
"aurora": self._paint_aurora,
|
||||
"diagnostics": self._paint_diagnostics,
|
||||
"metaballs": self._paint_metaballs,
|
||||
"leaf": self._paint_leaf,
|
||||
"veins": self._paint_veins,
|
||||
}
|
||||
painters[state["effect"]](painter, rect, state)
|
||||
painter.end()
|
||||
|
||||
def _paint_diagnostics(
|
||||
self, painter: QPainter, rect: QRectF, state: dict,
|
||||
) -> None:
|
||||
config = state["config"]["diagnostics"]
|
||||
color = QColor(config["color"])
|
||||
grid_color = QColor(color)
|
||||
grid_color.setAlphaF(config["grid_opacity"])
|
||||
painter.setPen(QPen(grid_color, config["grid_line_width"]))
|
||||
spacing = config["grid_spacing"]
|
||||
for x in range(0, int(rect.width()), spacing):
|
||||
painter.drawLine(QPointF(x, 0), QPointF(x, rect.height()))
|
||||
for y in range(0, int(rect.height()), spacing):
|
||||
painter.drawLine(QPointF(0, y), QPointF(rect.width(), y))
|
||||
|
||||
trace_color = QColor(color)
|
||||
trace_color.setAlphaF(config["trace_opacity"])
|
||||
painter.setPen(QPen(trace_color, config["trace_line_width"]))
|
||||
trace = QPainterPath()
|
||||
steps = max(int(rect.width() / config["trace_step_width"]), 1)
|
||||
for step in range(steps + 1):
|
||||
progress = step / steps
|
||||
signal = math.sin(
|
||||
progress * config["trace_frequency"]
|
||||
+ state["phase"] * config["trace_speed"]
|
||||
)
|
||||
point = QPointF(
|
||||
rect.width() * progress,
|
||||
rect.height() * (config["trace_baseline"] + signal * config["trace_amplitude"]),
|
||||
)
|
||||
trace.moveTo(point) if step == 0 else trace.lineTo(point)
|
||||
painter.drawPath(trace)
|
||||
self._paint_diagnostic_status(painter, rect, state)
|
||||
|
||||
def _paint_diagnostic_status(
|
||||
self, painter: QPainter, rect: QRectF, state: dict,
|
||||
) -> None:
|
||||
config = state["config"]["diagnostics"]
|
||||
color = QColor(config["color"])
|
||||
color.setAlphaF(config["text_opacity"])
|
||||
painter.setPen(color)
|
||||
font = QFont(config["font_family"])
|
||||
font.setPixelSize(config["font_size"])
|
||||
painter.setFont(font)
|
||||
x = rect.width() * config["text_x_ratio"]
|
||||
first_y = rect.height() * config["first_line_y_ratio"]
|
||||
second_y = rect.height() * config["second_line_y_ratio"]
|
||||
painter.drawText(QPointF(x, first_y), config["status_text"])
|
||||
painter.drawText(QPointF(x, second_y), config["containment_text"])
|
||||
sweep = (
|
||||
config["sweep_offset"] + state["phase"] * config["sweep_speed"]
|
||||
) % 1
|
||||
sweep_color = QColor(color)
|
||||
sweep_color.setAlphaF(config["sweep_opacity"])
|
||||
painter.setPen(QPen(sweep_color, config["sweep_width"]))
|
||||
sweep_x = rect.width() * sweep
|
||||
painter.drawLine(QPointF(sweep_x, 0), QPointF(sweep_x, rect.height()))
|
||||
|
||||
def _paint_aurora(self, painter: QPainter, rect: QRectF, state: dict) -> None:
|
||||
config = state["config"]["aurora"]
|
||||
color = QColor(state["palette"][-1])
|
||||
color.setAlphaF(config["opacity"])
|
||||
painter.setPen(QPen(color, rect.width() * config["line_width_ratio"]))
|
||||
for index in range(config["line_count"]):
|
||||
path = QPainterPath()
|
||||
for step in range(config["steps"] + 1):
|
||||
progress = step / config["steps"]
|
||||
drift = math.sin(
|
||||
progress * config["frequency"]
|
||||
+ state["phase"] * config["speed"]
|
||||
+ index * config["phase_step"]
|
||||
)
|
||||
x = rect.width() * (
|
||||
(index + config["edge_offset"]) / config["line_count"]
|
||||
+ drift * config["drift_ratio"]
|
||||
)
|
||||
point = QPointF(x, rect.height() * progress)
|
||||
path.moveTo(point) if step == 0 else path.lineTo(point)
|
||||
painter.drawPath(path)
|
||||
|
||||
def _paint_metaballs(self, painter: QPainter, rect: QRectF, state: dict) -> None:
|
||||
config = state["config"]["metaballs"]
|
||||
accent = QColor(state["palette"][-1])
|
||||
for index in range(config["count"]):
|
||||
seed = index + config["seed_offset"]
|
||||
radius = rect.height() * (
|
||||
config["min_radius_ratio"] + self._hash(seed) * config["radius_range"]
|
||||
)
|
||||
x = rect.width() * self._hash(seed * config["x_seed"])
|
||||
travel = state["phase"] * (
|
||||
config["min_speed"] + self._hash(seed * config["speed_seed"])
|
||||
)
|
||||
y = rect.height() * ((self._hash(seed * config["y_seed"]) + travel) % 1)
|
||||
gradient = QRadialGradient(QPointF(x, y), radius)
|
||||
center = QColor(accent)
|
||||
center.setAlphaF(config["opacity"])
|
||||
edge = QColor(accent)
|
||||
edge.setAlpha(0)
|
||||
gradient.setColorAt(0, center)
|
||||
gradient.setColorAt(1, edge)
|
||||
painter.setBrush(gradient)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(QPointF(x, y), radius, radius)
|
||||
|
||||
def _paint_leaf(self, painter: QPainter, rect: QRectF, state: dict) -> None:
|
||||
config = state["config"]["leaf"]
|
||||
color = QColor(state["palette"][-1])
|
||||
color.setAlphaF(config["opacity"])
|
||||
painter.setBrush(color)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
leaf_type = config["type"]
|
||||
for index in range(config["count"]):
|
||||
seed = index + config["seed_offset"]
|
||||
size = rect.height() * (
|
||||
config["min_size_ratio"] + self._hash(seed) * config["size_range"]
|
||||
)
|
||||
fall = state["phase"] * (
|
||||
config["min_speed"] + self._hash(seed * config["speed_seed"])
|
||||
)
|
||||
y = rect.height() * ((self._hash(seed * config["y_seed"]) + fall) % 1)
|
||||
sway = math.sin(state["phase"] + seed) * config["sway_ratio"]
|
||||
x = rect.width() * ((self._hash(seed * config["x_seed"]) + sway) % 1)
|
||||
painter.save()
|
||||
painter.translate(x, y)
|
||||
painter.rotate(state["phase"] * config["rotation_speed"] + seed)
|
||||
painter.drawPath(self._leaf_path(leaf_type, size))
|
||||
painter.restore()
|
||||
|
||||
@staticmethod
|
||||
def _leaf_path(leaf_type: str, size: float) -> QPainterPath:
|
||||
shapes = {
|
||||
"sakura": (
|
||||
(0, -0.62), (0.2, -0.95), (0.68, -0.82), (0.92, -0.3),
|
||||
(0.72, 0.35), (0, 0.92), (-0.72, 0.35), (-0.92, -0.3),
|
||||
(-0.68, -0.82), (-0.2, -0.95),
|
||||
),
|
||||
"oak": (
|
||||
(0, -1), (0.3, -0.82), (0.18, -0.64), (0.56, -0.55),
|
||||
(0.25, -0.34), (0.64, -0.16), (0.27, 0.02),
|
||||
(0.55, 0.28), (0.2, 0.36), (0.14, 0.62), (0.08, 0.58),
|
||||
(0.08, 1), (-0.08, 1), (-0.08, 0.58), (-0.14, 0.62),
|
||||
(-0.2, 0.36), (-0.55, 0.28), (-0.27, 0.02),
|
||||
(-0.64, -0.16), (-0.25, -0.34), (-0.56, -0.55),
|
||||
(-0.18, -0.64), (-0.3, -0.82),
|
||||
),
|
||||
"maple": (
|
||||
(0, -1),
|
||||
(0.14, -0.55), (0.36, -0.72), (0.32, -0.36),
|
||||
(0.72, -0.48), (0.56, -0.12), (1, -0.05),
|
||||
(0.62, 0.2), (0.72, 0.5), (0.22, 0.38),
|
||||
(0.08, 0.55), (0.08, 1), (-0.08, 1),
|
||||
(-0.08, 0.55), (-0.22, 0.38), (-0.72, 0.5),
|
||||
(-0.62, 0.2), (-1, -0.05), (-0.56, -0.12),
|
||||
(-0.72, -0.48), (-0.32, -0.36), (-0.36, -0.72),
|
||||
(-0.14, -0.55),
|
||||
),
|
||||
"birch": (
|
||||
(0, -1), (0.18, -0.72), (0.38, -0.78), (0.34, -0.56),
|
||||
(0.58, -0.55), (0.48, -0.34), (0.68, -0.26),
|
||||
(0.52, -0.08), (0.62, 0.12), (0.35, 0.22),
|
||||
(0.12, 0.48), (0.07, 1), (-0.07, 1), (-0.12, 0.48),
|
||||
(-0.35, 0.22), (-0.62, 0.12), (-0.52, -0.08),
|
||||
(-0.68, -0.26), (-0.48, -0.34), (-0.58, -0.55),
|
||||
(-0.34, -0.56), (-0.38, -0.78), (-0.18, -0.72),
|
||||
),
|
||||
"generic": (
|
||||
(0, -1), (0.38, -0.65), (0.62, -0.15), (0.5, 0.3),
|
||||
(0.12, 0.62), (0.07, 1), (-0.07, 1), (-0.12, 0.62),
|
||||
(-0.5, 0.3), (-0.62, -0.15), (-0.38, -0.65),
|
||||
),
|
||||
}
|
||||
points = shapes.get(leaf_type, shapes["generic"])
|
||||
path = QPainterPath(QPointF(size * points[0][0], size * points[0][1]))
|
||||
for x_ratio, y_ratio in points[1:]:
|
||||
path.lineTo(size * x_ratio, size * y_ratio)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
|
||||
def _paint_veins(self, painter: QPainter, rect: QRectF, state: dict) -> None:
|
||||
config = state["config"]["veins"]
|
||||
color = QColor(state["palette"][-1])
|
||||
color.setAlphaF(config["opacity"])
|
||||
painter.setPen(QPen(color, config["line_width"]))
|
||||
columns = config["columns"]
|
||||
rows = config["rows"]
|
||||
points = []
|
||||
for row in range(rows):
|
||||
row_points = []
|
||||
for column in range(columns):
|
||||
seed = row * columns + column + config["seed_offset"]
|
||||
motion = math.sin(state["phase"] + seed) * config["motion_ratio"]
|
||||
x = rect.width() * ((column + config["cell_offset"] + motion) / columns)
|
||||
y = rect.height() * (
|
||||
(row + config["cell_offset"] + self._hash(seed)) / rows
|
||||
)
|
||||
row_points.append(QPointF(x, y))
|
||||
points.append(row_points)
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
if column + 1 < columns:
|
||||
painter.drawLine(points[row][column], points[row][column + 1])
|
||||
if row + 1 < rows:
|
||||
painter.drawLine(points[row][column], points[row + 1][column])
|
||||
|
||||
@staticmethod
|
||||
def _hash(value: float) -> float:
|
||||
return math.sin(value * 12.9898) * 43758.5453 % 1
|
||||
@@ -245,8 +245,7 @@ class GameCardAnimations:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(fill_brush)
|
||||
radius = self.game_card.card_layout_cfg.get("border_radius", 18)
|
||||
radius *= self.game_card._scale
|
||||
radius = 18 * self.game_card._scale
|
||||
bw = round(self.game_card._borderWidth / 2)
|
||||
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
|
||||
if rect.isEmpty():
|
||||
|
||||
+8
-29
@@ -18,7 +18,7 @@ try:
|
||||
APP_VERSION = version_module.APP_VERSION
|
||||
except ImportError:
|
||||
APP_COMMIT = ""
|
||||
APP_VERSION = "1.3.1"
|
||||
APP_VERSION = "1.3.2"
|
||||
|
||||
__app_version__ = os.environ.get("PORTPROTONQT_VERSION", APP_VERSION)
|
||||
|
||||
@@ -290,7 +290,6 @@ def main():
|
||||
restore_prefix_path = None
|
||||
autoinstall_path = None
|
||||
theme_store_id = None
|
||||
gog_launch_uri = None
|
||||
resolution_from_args = None
|
||||
if args.resolution:
|
||||
resolution_from_args = parse_resolution(args.resolution)
|
||||
@@ -304,23 +303,17 @@ def main():
|
||||
prefix_name, backup_dir = args.create_backup
|
||||
backup_request = (prefix_name, os.path.abspath(os.path.expanduser(backup_dir)))
|
||||
ipc_message = "backup:{}:{}".format(quote(prefix_name, safe=""), quote(backup_request[1], safe=""))
|
||||
elif is_silent_launch and not args.log and args.file_or_url and is_launch_file(args.file_or_url):
|
||||
elif is_silent_launch and args.file_or_url and is_launch_file(args.file_or_url):
|
||||
run_silent_tray(app, start_sh, normalize_launch_path(args.file_or_url))
|
||||
sys.exit(app.exec())
|
||||
elif is_restore_prefix_request(args):
|
||||
restore_prefix_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
|
||||
elif args.file_or_url and is_launch_file(args.file_or_url):
|
||||
launch_action = "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
ipc_message = f"open:{normalize_launch_path(args.file_or_url)}"
|
||||
elif args.file_or_url and is_autoinstall_file(args.file_or_url):
|
||||
autoinstall_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"autoinstall:{quote(autoinstall_path, safe='')}"
|
||||
elif args.file_or_url and args.file_or_url.startswith("gog://launch/"):
|
||||
app_id = args.file_or_url.removeprefix("gog://launch/")
|
||||
if app_id.isdigit():
|
||||
gog_launch_uri = args.file_or_url
|
||||
ipc_message = f"gog:{app_id}"
|
||||
elif args.file_or_url:
|
||||
theme_store_id = parse_portprotonqt_theme_url(args.file_or_url)
|
||||
if theme_store_id is not None:
|
||||
@@ -387,7 +380,7 @@ def main():
|
||||
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
|
||||
|
||||
# Check if we have a portproton:// URL or launch file to handle
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path:
|
||||
if is_portproton_url(args.file_or_url):
|
||||
# Parse the portproton:// URL to get the full download URL
|
||||
download_url = parse_portproton_url(args.file_or_url)
|
||||
@@ -477,7 +470,7 @@ def main():
|
||||
if exe_path:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe():
|
||||
window.handle_launch_exe(exe_path, log_mode=args.log)
|
||||
window.handle_launch_exe(exe_path)
|
||||
QTimer.singleShot(0, handle_launch_exe)
|
||||
elif autoinstall_path:
|
||||
def handle_autoinstall():
|
||||
@@ -491,10 +484,6 @@ def main():
|
||||
def handle_restore_prefix():
|
||||
window._perform_restore(restore_prefix_path)
|
||||
QTimer.singleShot(0, handle_restore_prefix)
|
||||
elif gog_launch_uri:
|
||||
def handle_gog_launch():
|
||||
window.toggleGame(gog_launch_uri)
|
||||
QTimer.singleShot(0, handle_gog_launch)
|
||||
|
||||
# --- Handle incoming connections ---
|
||||
def handle_new_connection():
|
||||
@@ -512,12 +501,10 @@ def main():
|
||||
if (
|
||||
msg.startswith("show")
|
||||
or msg.startswith("open:")
|
||||
or msg.startswith("log:")
|
||||
or msg.startswith("restore:")
|
||||
or msg.startswith("backup:")
|
||||
or msg.startswith("theme:")
|
||||
or msg.startswith("autoinstall:")
|
||||
or msg.startswith("gog:")
|
||||
):
|
||||
# Ensure the window is visible and not minimized
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
@@ -538,12 +525,11 @@ def main():
|
||||
display_config.set_fullscreen(False)
|
||||
window.showNormal()
|
||||
|
||||
if msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = msg.split(":", 1)[1].strip()
|
||||
if msg.startswith("open:"):
|
||||
launch_path = msg[5:].strip()
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
window.handle_launch_exe(launch_path, log_mode=log_mode)
|
||||
window.handle_launch_exe(launch_path)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -553,12 +539,6 @@ def main():
|
||||
window._perform_restore(backup_path)
|
||||
else:
|
||||
logger.warning("Invalid prefix backup via IPC: %s", backup_path)
|
||||
elif msg.startswith("gog:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id.isdigit():
|
||||
window.toggleGame(f"gog://launch/{app_id}")
|
||||
else:
|
||||
logger.warning("Invalid GOG app id via IPC: %s", app_id)
|
||||
elif msg.startswith("backup:"):
|
||||
parts = msg.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
@@ -610,7 +590,6 @@ def main():
|
||||
and restore_prefix_path is None
|
||||
and autoinstall_path is None
|
||||
and theme_store_id is None
|
||||
and gog_launch_uri is None
|
||||
)
|
||||
if launch_minimized:
|
||||
logger.info("Launching in tray")
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
"""User-level AppImage desktop integration."""
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
APP_ID = "ru.linux_gaming.PortProtonQt"
|
||||
DESKTOP_DATABASE_TIMEOUT = 10
|
||||
EXECUTABLE_MODE = 0o755
|
||||
WINDOWS_MIME_TYPES = (
|
||||
"application/x-ms-dos-executable;application/x-msdos-program;"
|
||||
"application/x-ms-dos-exec;application/x-executable;"
|
||||
"application/x-dosexec;application/vnd.microsoft.portable-executable;"
|
||||
)
|
||||
INTEGRATION_MODES = ("log", "silent")
|
||||
|
||||
|
||||
def _find_appimage_asset(appdir: Path, names: tuple[str, ...]) -> Path | None:
|
||||
for name in names:
|
||||
path = appdir / name
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _copy_appimage(source: Path, destination: Path) -> None:
|
||||
if source.resolve() == destination.resolve():
|
||||
return
|
||||
temporary = destination.with_suffix(".appimage.part")
|
||||
shutil.copy2(source, temporary)
|
||||
temporary.chmod(EXECUTABLE_MODE)
|
||||
temporary.replace(destination)
|
||||
|
||||
|
||||
def _remove_existing_integration(applications_dir: Path, icon_dir: Path) -> None:
|
||||
desktop_names = (
|
||||
f"{APP_ID}.desktop",
|
||||
f"{APP_ID}.log.desktop",
|
||||
f"{APP_ID}.silent.desktop",
|
||||
)
|
||||
for name in desktop_names:
|
||||
(applications_dir / name).unlink(missing_ok=True)
|
||||
for suffix in (".svg", ".png"):
|
||||
(icon_dir / f"{APP_ID}{suffix}").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _rewrite_main_desktop(content: str, appimage: Path, icon: Path) -> str:
|
||||
exec_line = f"Exec={shlex.join([str(appimage), '%u'])}"
|
||||
content = re.sub(r"^Exec=.*$", exec_line, content, count=1, flags=re.MULTILINE)
|
||||
content = re.sub(r"^Icon=.*$", f"Icon={icon}", content, flags=re.MULTILINE)
|
||||
if re.search(r"^TryExec=", content, flags=re.MULTILINE):
|
||||
return re.sub(
|
||||
r"^TryExec=.*$",
|
||||
f"TryExec={appimage}",
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
return content.replace("Type=Application\n", f"Type=Application\nTryExec={appimage}\n", 1)
|
||||
|
||||
|
||||
def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
|
||||
action = _("Run in logging mode") if mode == "log" else _("Run in silent mode")
|
||||
exec_line = shlex.join([str(appimage), f"--{mode}", "%u"])
|
||||
return (
|
||||
"[Desktop Entry]\n"
|
||||
f"Name=PortProtonQt — {action}\n"
|
||||
f"Exec={exec_line}\n"
|
||||
f"TryExec={appimage}\n"
|
||||
"Type=Application\n"
|
||||
f"Comment={action}\n"
|
||||
"Terminal=false\n"
|
||||
f"Icon={icon}\n"
|
||||
f"StartupWMClass={APP_ID}\n"
|
||||
"Categories=Game;\n"
|
||||
"NoDisplay=true\n"
|
||||
f"MimeType={WINDOWS_MIME_TYPES}\n"
|
||||
"StartupNotify=true\n"
|
||||
)
|
||||
|
||||
|
||||
def _install_desktop_files(
|
||||
source: Path,
|
||||
applications_dir: Path,
|
||||
appimage: Path,
|
||||
icon: Path,
|
||||
) -> None:
|
||||
content = source.read_text(encoding="utf-8")
|
||||
main_path = applications_dir / f"{APP_ID}.desktop"
|
||||
main_path.write_text(
|
||||
_rewrite_main_desktop(content, appimage, icon),
|
||||
encoding="utf-8",
|
||||
)
|
||||
for mode in INTEGRATION_MODES:
|
||||
path = applications_dir / f"{APP_ID}.{mode}.desktop"
|
||||
path.write_text(_mode_desktop(mode, appimage, icon), encoding="utf-8")
|
||||
|
||||
|
||||
def _set_default_mime_handlers() -> None:
|
||||
desktop_id = f"{APP_ID}.desktop"
|
||||
for mime_type in WINDOWS_MIME_TYPES.rstrip(";").split(";"):
|
||||
try:
|
||||
subprocess.run(
|
||||
["xdg-mime", "default", desktop_id, mime_type],
|
||||
check=True,
|
||||
timeout=DESKTOP_DATABASE_TIMEOUT,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
logger.warning("Failed to set default MIME handler: %s", error)
|
||||
return
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.warning("Failed to set default handler for %s: %s", mime_type, error)
|
||||
|
||||
|
||||
def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
|
||||
desktop = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
f"{APP_ID}.desktop",
|
||||
f"usr/share/applications/{APP_ID}.desktop",
|
||||
f"share/applications/{APP_ID}.desktop",
|
||||
),
|
||||
)
|
||||
icon = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
".DirIcon",
|
||||
f"{APP_ID}.svg",
|
||||
f"{APP_ID}.png",
|
||||
f"usr/share/icons/hicolor/scalable/apps/{APP_ID}.svg",
|
||||
f"share/icons/hicolor/scalable/apps/{APP_ID}.svg",
|
||||
),
|
||||
)
|
||||
if not desktop or not icon:
|
||||
raise FileNotFoundError("AppImage desktop metadata is incomplete")
|
||||
return desktop, icon
|
||||
|
||||
|
||||
def integrate_appimage() -> Path:
|
||||
"""Install the running AppImage and its desktop handlers for this user."""
|
||||
source = Path(os.environ["APPIMAGE"]).expanduser()
|
||||
appdir = Path(os.environ["APPDIR"]).expanduser()
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError("AppImage desktop metadata is incomplete")
|
||||
desktop_source, icon_source = _get_appimage_metadata(appdir)
|
||||
|
||||
appimages_dir = Path.home() / "AppImages"
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
applications_dir = data_home / "applications"
|
||||
icon_dir = appimages_dir / ".icons"
|
||||
appimages_dir.mkdir(parents=True, exist_ok=True)
|
||||
applications_dir.mkdir(parents=True, exist_ok=True)
|
||||
icon_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
destination = appimages_dir / "portprotonqt.appimage"
|
||||
resolved_icon = icon_source.resolve()
|
||||
icon = icon_dir / f"{APP_ID}{resolved_icon.suffix or '.svg'}"
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
_copy_appimage(source, destination)
|
||||
shutil.copy2(resolved_icon, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination, icon)
|
||||
_set_default_mime_handlers()
|
||||
try:
|
||||
subprocess.run(
|
||||
["update-desktop-database", str(applications_dir)],
|
||||
check=False,
|
||||
timeout=DESKTOP_DATABASE_TIMEOUT,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError) as error:
|
||||
logger.warning("Failed to update desktop database: %s", error)
|
||||
return destination
|
||||
|
||||
|
||||
class AppImageIntegrationWorker(QThread):
|
||||
"""Install AppImage integration outside the UI thread."""
|
||||
|
||||
completed = Signal(bool, str)
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
destination = integrate_appimage()
|
||||
self.completed.emit(True, str(destination))
|
||||
except (OSError, subprocess.SubprocessError, KeyError) as error:
|
||||
logger.error("Failed to integrate AppImage: %s", error)
|
||||
self.completed.emit(False, str(error))
|
||||
+1
-8
@@ -69,11 +69,6 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Launch supported Windows file in tray without showing the main window"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log",
|
||||
action="store_true",
|
||||
help="Enable PortProton logging for the launched Windows file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore-prefix",
|
||||
action="store_true",
|
||||
@@ -327,10 +322,8 @@ def remove_steam_compat_tool() -> bool:
|
||||
def clear_cache() -> bool:
|
||||
"""Clear PortProtonQt cache."""
|
||||
from portprotonqt.config import cache_config
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
|
||||
cache_config.clear_cache()
|
||||
GOGAPI().clear_library_cache()
|
||||
print("PortProtonQt cache cleared")
|
||||
return True
|
||||
|
||||
@@ -435,7 +428,7 @@ def is_autoinstall_file(path: str) -> bool:
|
||||
def parse_portproton_url(url: str) -> str | None:
|
||||
"""Parse a portproton:// URL to extract the full download URL.
|
||||
|
||||
Expected format: portproton://https//linux-gaming.ru/api/games/130127/ppdb/download
|
||||
Expected format: portproton://https//ppdb.linux-gaming.ru/api/games/130127/ppdb/download
|
||||
|
||||
Args:
|
||||
url: The portproton:// URL to parse
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
# Copyright 2026 mirkobrombin <brombin94@gmail.com>
|
||||
# Adapted from the Bottles compatibility analyser under GPL-3.0.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import pefile
|
||||
|
||||
from portprotonqt.debug_utils import (
|
||||
get_portproton_env,
|
||||
get_prefix_name,
|
||||
get_selectable_gpu_entries,
|
||||
get_vulkan_use_info,
|
||||
get_wine_version,
|
||||
get_winetricks_log,
|
||||
)
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.scripts_utils.graphics_detector import analyze_executable
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CRASH_THRESHOLD_SECONDS = 5
|
||||
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
|
||||
PE_MACHINE_AMD64 = 0x8664
|
||||
COMPATIBILITY_SCAN_BYTES = 4_194_304
|
||||
COMPATIBILITY_NEIGHBOR_SCAN_LIMIT = 20
|
||||
COMPATIBILITY_RULES_PATH = Path(__file__).with_name("compatibility_rules.json")
|
||||
I586_ELF_LOADER = "/lib/ld-linux.so.2"
|
||||
DXVK_VULKAN_REQUIREMENTS = {
|
||||
"6": ("Newest", "DXVK_NEW_VER", (1, 4)),
|
||||
"2": ("Stable", "DXVK_OLD_VER", (1, 3)),
|
||||
"1": ("Sarek", "DXVK_SAREK_VER", (1, 1)),
|
||||
}
|
||||
DXVK_DRIVER_REQUIREMENTS = {
|
||||
"0x1002": ("AMD RADV", (25, 0), "driver_version"),
|
||||
"0x10de": ("NVIDIA", (550, 54, 14), "driver_info"),
|
||||
"0x8086": ("Intel ANV", (25, 1), "driver_version"),
|
||||
}
|
||||
DXVK_NVK_REQUIREMENT = ("NVIDIA NVK", (25, 1), "driver_version")
|
||||
DXVK_NEWEST_NVIDIA_REQUIREMENT = ("NVIDIA", (575, 51, 2), "driver_info")
|
||||
VCRUN_COMPONENTS = {
|
||||
"Visual C++ 2010": ("vcrun2010",),
|
||||
"Visual C++ 2012": ("vcrun2012",),
|
||||
"Visual C++ 2013": ("vcrun2013",),
|
||||
"Visual C++ 2015-2022": ("vcrun2015", "vcrun2017", "vcrun2019", "vcrun2022"),
|
||||
}
|
||||
|
||||
DLL_MAPPINGS = {
|
||||
"mscoree.dll": (".NET Framework", "Runtimes"),
|
||||
"hostfxr.dll": (".NET Core/5+", "Runtimes"),
|
||||
"coreclr.dll": (".NET Core/5+", "Runtimes"),
|
||||
"mono-2.0-bdwgc.dll": ("Mono", "Runtimes"),
|
||||
"jvm.dll": ("Java", "Runtimes"),
|
||||
"python3.dll": ("Python", "Runtimes"),
|
||||
"msvcp100.dll": ("Visual C++ 2010", "Runtimes"),
|
||||
"msvcp110.dll": ("Visual C++ 2012", "Runtimes"),
|
||||
"msvcp120.dll": ("Visual C++ 2013", "Runtimes"),
|
||||
"msvcp140.dll": ("Visual C++ 2015-2022", "Runtimes"),
|
||||
"vcruntime140.dll": ("Visual C++ 2015-2022", "Runtimes"),
|
||||
"unityplayer.dll": ("Unity", "Engines"),
|
||||
"gameassembly.dll": ("Unity IL2CPP", "Engines"),
|
||||
"cryrenderd3d11.dll": ("CryEngine", "Engines"),
|
||||
"tier0.dll": ("Source", "Engines"),
|
||||
"libcef.dll": ("Chromium Embedded Framework", "Frameworks"),
|
||||
"electron.dll": ("Electron", "Frameworks"),
|
||||
"qt5core.dll": ("Qt 5", "Frameworks"),
|
||||
"qt6core.dll": ("Qt 6", "Frameworks"),
|
||||
"xinput1_3.dll": ("XInput 1.3", "Input"),
|
||||
"dinput8.dll": ("DirectInput", "Input"),
|
||||
"sdl2.dll": ("SDL2", "Input"),
|
||||
"xaudio2_7.dll": ("XAudio 2.7", "Audio"),
|
||||
"fmod.dll": ("FMOD", "Audio"),
|
||||
"fmod64.dll": ("FMOD", "Audio"),
|
||||
"binkw64.dll": ("Bink", "Audio"),
|
||||
"openal32.dll": ("OpenAL", "Audio"),
|
||||
"steam_api64.dll": ("Steamworks", "Social/DRM"),
|
||||
"galaxy64.dll": ("GOG Galaxy", "Social/DRM"),
|
||||
"eossdk-win64-shipping.dll": ("Epic Online Services", "Social/DRM"),
|
||||
"easyanticheat_x64.dll": ("Easy Anti-Cheat", "Protection"),
|
||||
"beclient_x64.dll": ("BattlEye", "Protection"),
|
||||
"vmprotectsdk64.dll": ("VMProtect", "Protection"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompatibilityLaunch:
|
||||
executable: str
|
||||
exit_code: int | None
|
||||
duration: float
|
||||
|
||||
|
||||
def is_suspected_crash(duration: float, stopped_by_user: bool, executable: str) -> bool:
|
||||
"""Return whether an executable closed too soon after launch."""
|
||||
always_report = os.getenv(COMPATIBILITY_ALWAYS_REPORT_ENV) == "1"
|
||||
return (
|
||||
(always_report or (not stopped_by_user and duration < CRASH_THRESHOLD_SECONDS))
|
||||
and executable.lower().endswith((".exe", ".msi"))
|
||||
and os.path.isfile(executable)
|
||||
)
|
||||
|
||||
|
||||
def _read_imports(pe: pefile.PE) -> set[str]:
|
||||
imports = set()
|
||||
for directory_name in ("DIRECTORY_ENTRY_IMPORT", "DIRECTORY_ENTRY_DELAY_IMPORT"):
|
||||
for entry in getattr(pe, directory_name, []):
|
||||
try:
|
||||
imports.add(entry.dll.decode("utf-8", errors="ignore").lower())
|
||||
except AttributeError:
|
||||
continue
|
||||
return imports
|
||||
|
||||
|
||||
def _load_rules() -> list[dict[str, Any]]:
|
||||
try:
|
||||
data = orjson.loads(COMPATIBILITY_RULES_PATH.read_bytes())
|
||||
except (OSError, orjson.JSONDecodeError) as error:
|
||||
logger.error("Compatibility report failed to load signature rules: %s", error)
|
||||
return []
|
||||
rules = data.get("rules", [])
|
||||
return rules if isinstance(rules, list) else []
|
||||
|
||||
|
||||
def _matched_pattern_ids(
|
||||
data: bytes,
|
||||
lowered: bytes,
|
||||
flattened_wide: bytes,
|
||||
patterns: list[dict[str, str]],
|
||||
) -> set[str]:
|
||||
matched = set()
|
||||
for pattern in patterns:
|
||||
identifier = pattern.get("id", "")
|
||||
text = pattern.get("text")
|
||||
if text is not None:
|
||||
needle = text.encode("utf-8", errors="ignore").lower()
|
||||
if needle in lowered or needle in flattened_wide:
|
||||
matched.add(identifier)
|
||||
continue
|
||||
expression = pattern.get("regex")
|
||||
if expression and re.search(expression.encode(), data, re.IGNORECASE):
|
||||
matched.add(identifier)
|
||||
return matched
|
||||
|
||||
|
||||
def _scan_file(
|
||||
file_path: str, rules: list[dict[str, Any]], source: str
|
||||
) -> list[dict[str, str]]:
|
||||
try:
|
||||
with open(file_path, "rb") as source_file:
|
||||
data = source_file.read(COMPATIBILITY_SCAN_BYTES)
|
||||
except OSError as error:
|
||||
logger.warning("Compatibility report failed to scan %s: %s", file_path, error)
|
||||
return []
|
||||
data = os.path.basename(file_path).encode(errors="ignore") + b"\n" + data
|
||||
lowered = data.lower()
|
||||
flattened_wide = lowered.replace(b"\x00", b"")
|
||||
findings = []
|
||||
for rule in rules:
|
||||
matched = _matched_pattern_ids(
|
||||
data,
|
||||
lowered,
|
||||
flattened_wide,
|
||||
rule.get("patterns", []),
|
||||
)
|
||||
required_sets = rule.get("required_sets", [])
|
||||
if not any(set(required).issubset(matched) for required in required_sets):
|
||||
continue
|
||||
findings.append({
|
||||
"category": str(rule.get("category", "Unknown")),
|
||||
"name": str(rule.get("name", rule.get("id", "Unknown"))),
|
||||
"description": str(rule.get("description", "")),
|
||||
"severity": str(rule.get("severity", "info")),
|
||||
"source": source,
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _scan_installer(
|
||||
installer_path: str, rules: list[dict[str, Any]]
|
||||
) -> list[dict[str, str]]:
|
||||
seven_zip = shutil.which("7z") or shutil.which("7za") or shutil.which("7zr")
|
||||
if seven_zip is None:
|
||||
return []
|
||||
findings = []
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="portprotonqt-compatibility-") as extract_dir:
|
||||
result = subprocess.run(
|
||||
[seven_zip, "x", "-y", f"-o{extract_dir}", installer_path],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("Compatibility report could not extract installer %s", installer_path)
|
||||
return []
|
||||
extracted = []
|
||||
for root, _directories, files in os.walk(extract_dir):
|
||||
extracted.extend(
|
||||
os.path.join(root, name)
|
||||
for name in files
|
||||
if name.lower().endswith((".exe", ".dll", ".msi", ".js", ".json", ".node"))
|
||||
)
|
||||
for file_path in extracted[:COMPATIBILITY_NEIGHBOR_SCAN_LIMIT]:
|
||||
findings.extend(
|
||||
_scan_file(file_path, rules, f"Installer: {os.path.basename(file_path)}")
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
logger.warning("Compatibility report installer scan failed for %s: %s", installer_path, error)
|
||||
return findings
|
||||
|
||||
|
||||
def _neighbor_files(executable: str) -> list[str]:
|
||||
executable_dir = os.path.realpath(os.path.dirname(executable))
|
||||
unsafe_dirs = {
|
||||
os.path.realpath(str(Path.home())),
|
||||
*(os.path.realpath(str(Path.home() / name))
|
||||
for name in ("Downloads", "Desktop", "Documents", "Templates")),
|
||||
}
|
||||
if executable_dir in unsafe_dirs:
|
||||
return []
|
||||
try:
|
||||
neighbors = [
|
||||
os.path.join(executable_dir, name)
|
||||
for name in os.listdir(executable_dir)
|
||||
if name.lower().endswith(".dll")
|
||||
]
|
||||
except OSError:
|
||||
return []
|
||||
executable_base = os.path.splitext(os.path.basename(executable))[0]
|
||||
plugins_dir = os.path.join(executable_dir, f"{executable_base}_Data", "Plugins")
|
||||
if os.path.isdir(plugins_dir):
|
||||
for root, _directories, files in os.walk(plugins_dir):
|
||||
neighbors.extend(
|
||||
os.path.join(root, name) for name in files if name.lower().endswith(".dll")
|
||||
)
|
||||
return neighbors[:COMPATIBILITY_NEIGHBOR_SCAN_LIMIT]
|
||||
|
||||
|
||||
def _add_directory_findings(executable: str, findings: dict[str, list[str]]) -> None:
|
||||
executable_dir = os.path.dirname(executable)
|
||||
executable_base = os.path.splitext(os.path.basename(executable))[0]
|
||||
markers = (
|
||||
("Engine", "Unreal", "Engines"),
|
||||
(f"{executable_base}_Data", "Unity", "Engines"),
|
||||
("renpy", "Ren'Py", "Engines"),
|
||||
("www", "RPG Maker", "Engines"),
|
||||
("resources/app.asar", "Electron", "Frameworks"),
|
||||
(f"{executable_base}.pck", "Godot", "Engines"),
|
||||
(f"{executable_base}.runtimeconfig.json", ".NET Core/5+", "Runtimes"),
|
||||
(f"{executable_base}.deps.json", ".NET Core/5+", "Runtimes"),
|
||||
)
|
||||
for relative_path, name, category in markers:
|
||||
if os.path.exists(os.path.join(executable_dir, relative_path)):
|
||||
values = findings.setdefault(category, [])
|
||||
if name not in values:
|
||||
values.append(name)
|
||||
|
||||
|
||||
def _analyze_pe(executable: str) -> tuple[str, dict[str, list[str]]]:
|
||||
findings: dict[str, list[str]] = {}
|
||||
try:
|
||||
pe = pefile.PE(executable, fast_load=True)
|
||||
pe.parse_data_directories(
|
||||
directories=[
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"],
|
||||
]
|
||||
)
|
||||
except (OSError, pefile.PEFormatError) as error:
|
||||
logger.warning("Compatibility report could not parse %s: %s", executable, error)
|
||||
return "Unknown", findings
|
||||
|
||||
machine = getattr(pe.FILE_HEADER, "Machine", None)
|
||||
architecture = "64-bit" if machine == PE_MACHINE_AMD64 else "32-bit"
|
||||
imports = _read_imports(pe)
|
||||
for dll_name, (name, category) in DLL_MAPPINGS.items():
|
||||
if dll_name in imports and name not in findings.setdefault(category, []):
|
||||
findings[category].append(name)
|
||||
try:
|
||||
optional_header = getattr(pe, "OPTIONAL_HEADER", None)
|
||||
data_directories = getattr(optional_header, "DATA_DIRECTORY", [])
|
||||
clr = data_directories[
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"]
|
||||
]
|
||||
if clr.VirtualAddress and ".NET Framework" not in findings.setdefault("Runtimes", []):
|
||||
findings["Runtimes"].append(".NET Framework")
|
||||
except (AttributeError, IndexError):
|
||||
pass
|
||||
_add_directory_findings(executable, findings)
|
||||
return architecture, findings
|
||||
|
||||
|
||||
def _apply_signature_findings(
|
||||
findings: dict[str, list[str]], signature_findings: list[dict[str, str]]
|
||||
) -> None:
|
||||
for item in signature_findings:
|
||||
values = findings.setdefault(item["category"], [])
|
||||
if item["name"] not in values:
|
||||
values.append(item["name"])
|
||||
|
||||
|
||||
def _uses_integrated_gpu(environment: dict[str, str]) -> bool:
|
||||
selected_gpu = environment.get("PW_GPU_USE", "")
|
||||
if not selected_gpu or selected_gpu == "disabled":
|
||||
return False
|
||||
return any(
|
||||
entry["device_name"] == selected_gpu
|
||||
and entry.get("device_type") == "INTEGRATED_GPU"
|
||||
for entry in get_selectable_gpu_entries()
|
||||
)
|
||||
|
||||
|
||||
def _version_at_least(version: str, required: tuple[int, ...]) -> bool:
|
||||
parts = tuple(int(part) for part in re.findall(r"\d+", version)[:len(required)])
|
||||
normalized = parts + (0,) * (len(required) - len(parts))
|
||||
return bool(parts) and normalized >= required
|
||||
|
||||
|
||||
def _selected_gpu(environment: dict[str, str]) -> dict[str, str] | None:
|
||||
entries = get_selectable_gpu_entries()
|
||||
selected_name = environment.get("PW_GPU_USE", "")
|
||||
if selected_name and selected_name != "disabled":
|
||||
return next(
|
||||
(entry for entry in entries if entry["device_name"] == selected_name),
|
||||
None,
|
||||
)
|
||||
return entries[0] if entries else None
|
||||
|
||||
|
||||
def _dxvk_driver_requirement(
|
||||
gpu: dict[str, str], backend: str,
|
||||
) -> tuple[str, tuple[int, ...], str] | None:
|
||||
vendor_id = gpu.get("vendor_id", "").lower()
|
||||
driver_name = gpu.get("driver_name", "").lower()
|
||||
if vendor_id == "0x10de" and "nvk" in driver_name:
|
||||
return DXVK_NVK_REQUIREMENT
|
||||
if vendor_id == "0x10de" and backend == "6":
|
||||
return DXVK_NEWEST_NVIDIA_REQUIREMENT
|
||||
return DXVK_DRIVER_REQUIREMENTS.get(vendor_id)
|
||||
|
||||
|
||||
def _dxvk_vulkan_compatibility(
|
||||
environment: dict[str, str],
|
||||
) -> tuple[str | None, list[str]]:
|
||||
backend = environment.get("PW_VULKAN_USE", "")
|
||||
requirement = DXVK_VULKAN_REQUIREMENTS.get(backend)
|
||||
if requirement is None:
|
||||
return None, []
|
||||
backend_name, version_key, required_vulkan = requirement
|
||||
dxvk_version = environment.get(version_key, "unknown")
|
||||
gpu = _selected_gpu(environment)
|
||||
if gpu is None:
|
||||
return f"{backend_name} DXVK {dxvk_version}: Vulkan version unknown", []
|
||||
|
||||
api_version = gpu.get("api_version", "unknown")
|
||||
required_api = ".".join(map(str, required_vulkan))
|
||||
details = f"{backend_name} DXVK {dxvk_version}: Vulkan {api_version} (requires {required_api}+)"
|
||||
suggestions = []
|
||||
if not _version_at_least(api_version, required_vulkan):
|
||||
suggestions.append(
|
||||
f"Update the graphics driver: {backend_name} DXVK requires Vulkan {required_api}+."
|
||||
)
|
||||
if backend != "1" and _version_at_least(api_version, (1, 1)):
|
||||
suggestions.append("Switch to Sarek for Vulkan 1.1 drivers.")
|
||||
|
||||
driver_requirement = _dxvk_driver_requirement(gpu, backend)
|
||||
if backend not in {"6", "2"} or driver_requirement is None:
|
||||
return details, suggestions
|
||||
driver_name, required_driver, version_field = driver_requirement
|
||||
driver_version = gpu.get(version_field, "unknown")
|
||||
required_version = ".".join(map(str, required_driver))
|
||||
details += f"; {driver_name} {driver_version} (requires {required_version}+)"
|
||||
if not _version_at_least(driver_version, required_driver):
|
||||
fallback = "Stable DXVK" if backend == "6" else "Sarek"
|
||||
suggestions.append(
|
||||
f"Update {driver_name} to {required_version}+ or switch to {fallback}."
|
||||
)
|
||||
return details, suggestions
|
||||
|
||||
|
||||
def has_dxvk_vulkan_incompatibility(executable: str) -> bool:
|
||||
"""Return whether configured DXVK cannot run on the selected GPU."""
|
||||
environment = get_portproton_env(executable)
|
||||
_details, suggestions = _dxvk_vulkan_compatibility(environment)
|
||||
return bool(suggestions)
|
||||
|
||||
|
||||
def _glibc_32_compatibility() -> tuple[str, list[str]]:
|
||||
if os.path.isfile(I586_ELF_LOADER):
|
||||
return "available", []
|
||||
return (
|
||||
f"unavailable (missing {I586_ELF_LOADER})",
|
||||
[
|
||||
"Install or enable 32-bit glibc with its ELF loader."
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _compatibility_suggestions(
|
||||
findings: dict[str, list[str]],
|
||||
graphics: str,
|
||||
shipping_executable: str,
|
||||
environment: dict[str, str],
|
||||
) -> list[str]:
|
||||
suggestions = []
|
||||
if shipping_executable:
|
||||
suggestions.append(f"Try launching the game binary instead: {shipping_executable}")
|
||||
uses_directx_8_11 = any(
|
||||
version in graphics
|
||||
for version in ("DirectX 8", "DirectX 9", "DirectX 10", "DirectX 11")
|
||||
)
|
||||
if uses_directx_8_11 and environment.get("PW_VULKAN_USE") == "0":
|
||||
suggestions.append("Switch from WineD3D to DXVK for DirectX 8-11.")
|
||||
if _uses_integrated_gpu(environment):
|
||||
suggestions.append("Use the discrete GPU.")
|
||||
runtimes = findings.get("Runtimes", [])
|
||||
prefix_name = environment.get("PW_PREFIX_NAME", "DEFAULT")
|
||||
needs_dotnet = any(".NET" in runtime for runtime in runtimes)
|
||||
needs_dotnet = needs_dotnet or "WPF" in findings.get("Frameworks", [])
|
||||
if needs_dotnet and prefix_name != "DOTNET":
|
||||
suggestions.append("Use the DOTNET prefix.")
|
||||
winetricks_log = environment.get("PW_WINETRICKS_LOG", "").lower()
|
||||
missing_vcrun = [
|
||||
components[-1]
|
||||
for runtime, components in VCRUN_COMPONENTS.items()
|
||||
if runtime in runtimes
|
||||
and not any(component in winetricks_log for component in components)
|
||||
]
|
||||
if missing_vcrun:
|
||||
suggestions.append(
|
||||
f"Install {', '.join(missing_vcrun)} through Winetricks."
|
||||
)
|
||||
if "PhysX" in findings.get("Physics", []) and "physx" not in winetricks_log:
|
||||
suggestions.append("Install physx through Winetricks.")
|
||||
return list(dict.fromkeys(suggestions))
|
||||
|
||||
|
||||
def _format_signature_details(items: list[dict[str, str]]) -> list[str]:
|
||||
lines = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
key = (item["category"], item["name"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
severity = item["severity"].upper()
|
||||
lines.append(
|
||||
f"- [{severity}] {item['category']}: {item['name']} "
|
||||
f"({item['source']}) — {item['description']}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def analyze_launch(launch: CompatibilityLaunch, portproton_path: str) -> str:
|
||||
"""Build a local compatibility report for a failed launch."""
|
||||
architecture, findings = _analyze_pe(launch.executable)
|
||||
rules = _load_rules()
|
||||
signature_findings = _scan_file(launch.executable, rules, "Main executable")
|
||||
for neighbor in _neighbor_files(launch.executable):
|
||||
signature_findings.extend(_scan_file(neighbor, rules, f"Neighbor: {os.path.basename(neighbor)}"))
|
||||
has_installer = launch.executable.lower().endswith(".msi") or any(
|
||||
item["category"] == "Installer" for item in signature_findings
|
||||
)
|
||||
if has_installer:
|
||||
signature_findings.extend(_scan_installer(launch.executable, rules))
|
||||
_apply_signature_findings(findings, signature_findings)
|
||||
graphics = analyze_executable(launch.executable)
|
||||
graphics_executable = str(graphics.get("source", launch.executable))
|
||||
graphics_name = os.path.basename(graphics_executable).lower()
|
||||
shipping_executable = (
|
||||
graphics_executable if graphics_name.endswith(("-win64-shipping.exe", "-win32-shipping.exe"))
|
||||
else ""
|
||||
)
|
||||
detected_graphics = str(graphics["highest_directx"])
|
||||
if graphics["uses_opengl"]:
|
||||
detected_graphics = f"{detected_graphics}, OpenGL"
|
||||
environment = get_portproton_env(launch.executable)
|
||||
prefix_name = get_prefix_name(launch.executable)
|
||||
environment.setdefault("PW_PREFIX_NAME", prefix_name)
|
||||
environment["PW_WINETRICKS_LOG"] = get_winetricks_log(
|
||||
portproton_path, environment["PW_PREFIX_NAME"]
|
||||
)
|
||||
suggestions = _compatibility_suggestions(
|
||||
findings,
|
||||
detected_graphics,
|
||||
shipping_executable,
|
||||
environment,
|
||||
)
|
||||
dxvk_compatibility, version_suggestions = _dxvk_vulkan_compatibility(environment)
|
||||
suggestions.extend(version_suggestions)
|
||||
glibc_32_compatibility, glibc_32_suggestions = _glibc_32_compatibility()
|
||||
suggestions.extend(glibc_32_suggestions)
|
||||
lines = [
|
||||
"Compatibility report",
|
||||
"",
|
||||
f"Executable: {launch.executable}",
|
||||
f"Exit code: {launch.exit_code if launch.exit_code is not None else 'unknown'}",
|
||||
f"Runtime before exit: {launch.duration:.1f} s",
|
||||
f"Architecture: {architecture}",
|
||||
f"Prefix: {prefix_name}",
|
||||
f"Wine/Proton: {get_wine_version(portproton_path, launch.executable)}",
|
||||
f"Configured 3D API: {get_vulkan_use_info(portproton_path, launch.executable)}",
|
||||
f"Detected 3D API: {detected_graphics}",
|
||||
f"32-bit glibc: {glibc_32_compatibility}",
|
||||
]
|
||||
if dxvk_compatibility:
|
||||
lines.append(f"DXVK/Vulkan compatibility: {dxvk_compatibility}")
|
||||
if graphics_executable != launch.executable:
|
||||
lines.append(f"3D API source: {graphics_executable}")
|
||||
if environment.get("PW_WINDOWS_VER"):
|
||||
lines.append(f"Windows version: {environment['PW_WINDOWS_VER']}")
|
||||
for category, values in findings.items():
|
||||
if values:
|
||||
lines.append(f"Detected {category}: {', '.join(values)}")
|
||||
detail_lines = _format_signature_details(signature_findings)
|
||||
if detail_lines:
|
||||
lines.extend(("", "Detection details:", *detail_lines))
|
||||
if suggestions:
|
||||
lines.extend(("", "Possible fixes:", *(f"- {item}" for item in suggestions)))
|
||||
return "\n".join(lines)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,14 +24,12 @@ from portprotonqt.config.portproton import (
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
find_game_by_exe,
|
||||
get_custom_data_dir_name,
|
||||
get_portproton_location,
|
||||
get_portproton_scripts_path,
|
||||
get_portproton_start_command,
|
||||
migrate_legacy_shortcut,
|
||||
parse_desktop_entry,
|
||||
read_portdata_path_from_config,
|
||||
resolve_custom_data_dir,
|
||||
save_portdata_path_to_config,
|
||||
)
|
||||
from portprotonqt.config.proxy import ProxyConfig
|
||||
@@ -97,8 +95,6 @@ __all__ = [
|
||||
"parse_desktop_entry",
|
||||
"extract_exec_target_path",
|
||||
"find_game_by_exe",
|
||||
"get_custom_data_dir_name",
|
||||
"resolve_custom_data_dir",
|
||||
"create_desktop_file",
|
||||
"consume_download_counter_skip",
|
||||
"reset_main_config",
|
||||
|
||||
@@ -25,12 +25,3 @@ class GameConfig(BaseConfig):
|
||||
"""Set game display filter."""
|
||||
validate_string(filter_value, "display_filter", min_len=1, max_len=50)
|
||||
self._save_value("display_filter", filter_value, "str")
|
||||
|
||||
def get_steam_account_id(self) -> str:
|
||||
"""Get selected Steam account ID or automatic selection."""
|
||||
return self._get_str("steam_account_id", "auto")
|
||||
|
||||
def set_steam_account_id(self, account_id: str) -> None:
|
||||
"""Set selected Steam account ID or automatic selection."""
|
||||
validate_string(account_id, "steam_account_id", min_len=1, max_len=20)
|
||||
self._save_value("steam_account_id", account_id, "str")
|
||||
@@ -1,6 +1,5 @@
|
||||
"""PortProton configuration and launch helpers."""
|
||||
import configparser
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
@@ -505,35 +504,13 @@ def find_game_by_exe(exe_path: str) -> configparser.SectionProxy | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_custom_data_dir_name(exe_path: str) -> str:
|
||||
"""Return a collision-safe custom data directory name for an executable."""
|
||||
normalized_path = os.path.normpath(os.path.abspath(os.path.expanduser(exe_path)))
|
||||
path_hash = hashlib.md5(
|
||||
normalized_path.encode("utf-8"), usedforsecurity=False
|
||||
).hexdigest()
|
||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
return f"{exe_name}_{path_hash}"
|
||||
|
||||
|
||||
def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
|
||||
"""Return hashed custom data directory, falling back to the legacy name."""
|
||||
hashed_dir = os.path.join(custom_data_root, get_custom_data_dir_name(exe_path))
|
||||
legacy_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
legacy_dir = os.path.join(custom_data_root, legacy_name)
|
||||
if not os.path.isdir(hashed_dir) and os.path.isdir(legacy_dir):
|
||||
return legacy_dir
|
||||
return hashed_dir
|
||||
|
||||
|
||||
def create_desktop_file(
|
||||
exe_path: str,
|
||||
game_name: str | None = None,
|
||||
icon_source: str | None = None,
|
||||
) -> tuple[str, str, str] | None:
|
||||
"""Create desktop entry content, destination path and icon path for a game."""
|
||||
portproton_path = get_portproton_location()
|
||||
is_gog_uri = re.fullmatch(r"gog://launch/\d+", exe_path) is not None
|
||||
if not is_gog_uri and not os.path.isfile(exe_path):
|
||||
if not os.path.isfile(exe_path):
|
||||
logger.error("Executable not found: %s", exe_path)
|
||||
return None
|
||||
if not portproton_path:
|
||||
@@ -544,30 +521,20 @@ def create_desktop_file(
|
||||
game_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
base_path = os.path.join(portproton_path, "data")
|
||||
icon_name = _sanitize_icon_name(game_name)
|
||||
has_icon_source = bool(icon_source and os.path.isfile(icon_source))
|
||||
icon_path = (
|
||||
"applications-games" if is_gog_uri and not has_icon_source
|
||||
else os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
)
|
||||
icon_path = os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
|
||||
if icon_path != "applications-games":
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
|
||||
flatpak_id = os.getenv("FLATPAK_ID")
|
||||
appimage_path = os.getenv("APPIMAGE", "").strip()
|
||||
if flatpak_id:
|
||||
exec_str = f'flatpak run {flatpak_id} --silent "{exe_path}"'
|
||||
log_exec = f'flatpak run {flatpak_id} --log "{exe_path}"'
|
||||
elif appimage_path and os.path.isfile(appimage_path):
|
||||
exec_str = shlex.join([appimage_path, "--silent", exe_path])
|
||||
log_exec = shlex.join([appimage_path, "--log", exe_path])
|
||||
else:
|
||||
exec_str = f'portprotonqt --silent "{exe_path}"'
|
||||
log_exec = f'portprotonqt --log "{exe_path}"'
|
||||
|
||||
comment = _('Launch "{name}" with PortProton').format(name=game_name)
|
||||
silent_name = _("Run in silent mode")
|
||||
log_name = _("Run in logging mode")
|
||||
desktop_entry = (
|
||||
"[Desktop Entry]\n"
|
||||
f"Name={game_name}\n"
|
||||
@@ -578,14 +545,5 @@ def create_desktop_file(
|
||||
"Categories=Game;\n"
|
||||
"StartupNotify=true\n"
|
||||
f"Icon={icon_path}\n"
|
||||
"Actions=RunSilent;RunLog;\n"
|
||||
"\n[Desktop Action RunSilent]\n"
|
||||
f"Name={silent_name}\n"
|
||||
f"Exec={exec_str}\n"
|
||||
f"Icon={icon_path}\n"
|
||||
"\n[Desktop Action RunLog]\n"
|
||||
f"Name={log_name}\n"
|
||||
f"Exec={log_exec}\n"
|
||||
f"Icon={icon_path}\n"
|
||||
)
|
||||
return desktop_entry, desktop_path, icon_path
|
||||
@@ -446,24 +446,6 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "enable_theme_store")
|
||||
self._save_value("enable_theme_store", enabled, "bool")
|
||||
|
||||
def get_sounds_enabled(self) -> bool:
|
||||
"""Get UI sounds enabled setting."""
|
||||
return self._get_bool("sounds_enabled", True)
|
||||
|
||||
def set_sounds_enabled(self, enabled: bool):
|
||||
"""Set UI sounds enabled setting."""
|
||||
validate_bool(enabled, "sounds_enabled")
|
||||
self._save_value("sounds_enabled", enabled, "bool")
|
||||
|
||||
def get_crash_reports_enabled(self) -> bool:
|
||||
"""Get compatibility reports after game crashes setting."""
|
||||
return self._get_bool("crash_reports_enabled", True)
|
||||
|
||||
def set_crash_reports_enabled(self, enabled: bool) -> None:
|
||||
"""Set compatibility reports after game crashes setting."""
|
||||
validate_bool(enabled, "crash_reports_enabled")
|
||||
self._save_value("crash_reports_enabled", enabled, "bool")
|
||||
|
||||
|
||||
def load_theme_metainfo(theme_name: str) -> dict:
|
||||
"""Load theme metadata from metainfo.ini."""
|
||||
|
||||
@@ -3,17 +3,14 @@ import glob
|
||||
import shutil
|
||||
import tempfile
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
favorites_config,
|
||||
favorites_folders_config,
|
||||
get_custom_data_dir_name,
|
||||
parse_desktop_entry,
|
||||
resolve_custom_data_dir,
|
||||
)
|
||||
from portprotonqt.steam_api import (
|
||||
add_to_steam,
|
||||
@@ -459,7 +456,7 @@ class ContextMenuManager:
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
# For non-Steam games, check if exe exists
|
||||
if game_card.game_source == "portproton":
|
||||
if game_card.game_source != "steam":
|
||||
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:
|
||||
@@ -470,18 +467,13 @@ class ContextMenuManager:
|
||||
return
|
||||
|
||||
# Normal menu for games with valid exe or from Steam
|
||||
is_uninstalled_gog = (
|
||||
game_card.game_source == "gog"
|
||||
and game_card.exec_line.startswith("gog://install/")
|
||||
)
|
||||
is_running = self._is_game_running(game_card)
|
||||
if not is_uninstalled_gog:
|
||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||
action_icon = "stop" if is_running else "play"
|
||||
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
|
||||
launch_action.triggered.connect(
|
||||
lambda: self._launch_game(game_card)
|
||||
)
|
||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||
action_icon = "stop" if is_running else "play"
|
||||
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
|
||||
launch_action.triggered.connect(
|
||||
lambda: self._launch_game(game_card)
|
||||
)
|
||||
|
||||
favorites = favorites_config.get_games()
|
||||
is_favorite = game_card.name in favorites
|
||||
@@ -490,60 +482,6 @@ class ContextMenuManager:
|
||||
favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
|
||||
|
||||
if game_card.game_source == "gog":
|
||||
game = {
|
||||
"app_id": str(game_card.appid),
|
||||
"title": game_card.name,
|
||||
"cover": game_card.cover_path,
|
||||
}
|
||||
if game_card.exec_line.startswith("gog://launch/"):
|
||||
repair_action = menu.addAction(
|
||||
self._get_safe_icon("update"), _("Repair")
|
||||
)
|
||||
repair_action.triggered.connect(
|
||||
lambda: self.parent._repair_gog_game(game)
|
||||
)
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
)
|
||||
desktop_path = self._get_shortcut_path(
|
||||
game_card.name, desktop_dir
|
||||
)
|
||||
desktop_exists = os.path.exists(desktop_path)
|
||||
desktop_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if desktop_exists else "desktop"),
|
||||
_("Remove from Desktop") if desktop_exists else _("Add to Desktop"),
|
||||
)
|
||||
desktop_action.triggered.connect(
|
||||
lambda: self.remove_from_desktop(game_card.name)
|
||||
if desktop_exists
|
||||
else self.add_to_desktop(game_card.name, game_card.exec_line)
|
||||
)
|
||||
menu_path = self._get_menu_shortcut_path(game_card.name)
|
||||
menu_exists = os.path.exists(menu_path)
|
||||
menu_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if menu_exists else "menu"),
|
||||
_("Remove from Menu") if menu_exists else _("Add to Menu"),
|
||||
)
|
||||
menu_action.triggered.connect(
|
||||
lambda: self.remove_from_menu(game_card.name)
|
||||
if menu_exists
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
delete_action = menu.addAction(
|
||||
self._get_safe_icon("delete"), _("Delete")
|
||||
)
|
||||
delete_action.triggered.connect(
|
||||
lambda: self.parent._delete_gog_game(game)
|
||||
)
|
||||
else:
|
||||
import_action = menu.addAction(
|
||||
self._get_safe_icon("folder"), _("Import")
|
||||
)
|
||||
import_action.triggered.connect(
|
||||
lambda: self.parent._import_gog_game(game)
|
||||
)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
@@ -573,17 +511,8 @@ class ContextMenuManager:
|
||||
lambda: self.open_steam_game_folder(game_card.appid)
|
||||
)
|
||||
)
|
||||
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action.triggered.connect(
|
||||
lambda: self.edit_game_shortcut(
|
||||
game_card.name,
|
||||
game_card.exec_line,
|
||||
game_card.cover_path,
|
||||
game_card.appid,
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "portproton":
|
||||
if game_card.game_source != "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
desktop_path = self._get_shortcut_path(game_card.name, desktop_dir)
|
||||
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
||||
@@ -776,19 +705,6 @@ class ContextMenuManager:
|
||||
)
|
||||
return os.path.join(data_home, "applications")
|
||||
|
||||
def _update_desktop_database(self) -> None:
|
||||
"""Update the desktop entry database asynchronously."""
|
||||
applications_dir = self._get_applications_dir()
|
||||
result = QProcess.startDetached(
|
||||
"update-desktop-database", [applications_dir]
|
||||
)
|
||||
started = result[0] if isinstance(result, tuple) else result
|
||||
if not started:
|
||||
logger.warning(
|
||||
"Failed to start desktop database update for %s",
|
||||
applications_dir,
|
||||
)
|
||||
|
||||
def _get_menu_shortcut_path(self, game_name: str) -> str:
|
||||
"""Return PortProton shortcut path in the applications directory."""
|
||||
return self._get_shortcut_path(game_name, self._get_applications_dir())
|
||||
@@ -877,20 +793,14 @@ class ContextMenuManager:
|
||||
def _remove_installed_shortcuts(self, game_name: str, shortcut_paths: list[str]) -> None:
|
||||
"""Remove existing menu and desktop shortcuts."""
|
||||
removed_paths = set()
|
||||
menu_shortcut_removed = False
|
||||
applications_dir = os.path.normpath(self._get_applications_dir())
|
||||
for shortcut_path in shortcut_paths:
|
||||
if shortcut_path in removed_paths or not os.path.exists(shortcut_path):
|
||||
continue
|
||||
removed_paths.add(shortcut_path)
|
||||
try:
|
||||
os.remove(shortcut_path)
|
||||
if os.path.dirname(os.path.normpath(shortcut_path)) == applications_dir:
|
||||
menu_shortcut_removed = True
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove shortcut for '%s': %s", game_name, e)
|
||||
if menu_shortcut_removed:
|
||||
self._update_desktop_database()
|
||||
|
||||
def _remove_statistics_entry(self, exe_path, game_name):
|
||||
"""Remove statistics entry for exact executable path."""
|
||||
@@ -942,6 +852,7 @@ class ContextMenuManager:
|
||||
return
|
||||
shortcut_paths = self._get_installed_shortcut_paths(game_name)
|
||||
exe_path = self._parse_exe_path(resolved_exec_line, game_name)
|
||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None
|
||||
if not self._remove_file(
|
||||
desktop_path,
|
||||
_("Failed to delete .desktop file: {error}"),
|
||||
@@ -951,13 +862,12 @@ class ContextMenuManager:
|
||||
return
|
||||
self._remove_installed_shortcuts(game_name, shortcut_paths)
|
||||
self._remove_statistics_entry(exe_path, game_name)
|
||||
if exe_path:
|
||||
if exe_name:
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data")
|
||||
custom_folder = resolve_custom_data_dir(custom_root, exe_path)
|
||||
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
|
||||
if os.path.exists(custom_folder):
|
||||
try:
|
||||
shutil.rmtree(custom_folder)
|
||||
@@ -991,44 +901,22 @@ class ContextMenuManager:
|
||||
self.game_library_manager.add_game_incremental(game_data)
|
||||
|
||||
def add_to_menu(self, game_name, exec_line):
|
||||
"""Add the game desktop entry to the XDG applications directory."""
|
||||
"""Copy the .desktop file to the XDG applications directory."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_gog_uri and not os.path.exists(desktop_path):
|
||||
if not os.path.exists(desktop_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
if not is_gog_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exec_line = self._parse_exe_path(exec_line, game_name)
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
desktop_entry = shortcut[0]
|
||||
icon_path = shortcut[2]
|
||||
if icon_source and not os.path.exists(icon_path):
|
||||
if not generate_thumbnail(icon_source, icon_path, size=128):
|
||||
logger.error("Failed to generate thumbnail for game: %s", icon_source)
|
||||
applications_dir = self._get_applications_dir()
|
||||
os.makedirs(applications_dir, exist_ok=True)
|
||||
dest_path = self._get_menu_shortcut_path(game_name)
|
||||
try:
|
||||
with open(dest_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
shutil.copyfile(desktop_path, dest_path)
|
||||
os.chmod(dest_path, 0o755)
|
||||
self._update_desktop_database()
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1045,18 +933,17 @@ class ContextMenuManager:
|
||||
game_name: The display name of the game.
|
||||
"""
|
||||
menu_path = self._get_menu_shortcut_path(game_name)
|
||||
if self._remove_file(
|
||||
self._remove_file(
|
||||
menu_path,
|
||||
_("Failed to remove '{game_name}' from {location}: {error}"),
|
||||
_("Removed '{game_name}' from {location}"),
|
||||
game_name,
|
||||
location=_("Menu")
|
||||
):
|
||||
self._update_desktop_database()
|
||||
)
|
||||
|
||||
def add_to_desktop(self, game_name, exec_line):
|
||||
"""
|
||||
Add the game desktop entry to the user's Desktop folder.
|
||||
Copies the .desktop file to the user's Desktop folder.
|
||||
|
||||
Args:
|
||||
game_name: The display name of the game.
|
||||
@@ -1065,42 +952,30 @@ class ContextMenuManager:
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_gog_uri and not os.path.exists(desktop_path):
|
||||
if not os.path.exists(desktop_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
# Ensure icon exists
|
||||
if not is_gog_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exec_line = self._parse_exe_path(exec_line, game_name)
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
desktop_entry = shortcut[0]
|
||||
icon_path = shortcut[2]
|
||||
thumbnail_source = icon_source or exec_line
|
||||
if icon_path != "applications-games" and not os.path.exists(icon_path):
|
||||
if not generate_thumbnail(thumbnail_source, icon_path, size=128):
|
||||
logger.error("Failed to generate thumbnail for game: %s", thumbnail_source)
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
|
||||
if not os.path.exists(icon_path):
|
||||
if not generate_thumbnail(exe_path, icon_path, size=128):
|
||||
logger.error("Failed to generate thumbnail for game: %s", exe_path)
|
||||
|
||||
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
os.makedirs(desktop_dir, exist_ok=True)
|
||||
dest_path = self._get_shortcut_path(game_name, desktop_dir)
|
||||
try:
|
||||
with open(dest_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
shutil.copyfile(desktop_path, dest_path)
|
||||
os.chmod(dest_path, 0o755)
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
@@ -1161,10 +1036,6 @@ class ContextMenuManager:
|
||||
with open(dest_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(dest_path, 0o755)
|
||||
if os.path.normpath(target_dir) == os.path.normpath(
|
||||
self._get_applications_dir()
|
||||
):
|
||||
self._update_desktop_database()
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1175,11 +1046,6 @@ class ContextMenuManager:
|
||||
|
||||
fetch_client_icon_async(appid_str, write_shortcut)
|
||||
|
||||
def remove_gog_shortcuts(self, game_name: str) -> None:
|
||||
"""Remove desktop and application menu shortcuts for a GOG game."""
|
||||
shortcut_paths = self._get_installed_shortcut_paths(game_name)
|
||||
self._remove_installed_shortcuts(game_name, shortcut_paths)
|
||||
|
||||
def add_steam_to_desktop(self, game_name: str, appid: int | str) -> None:
|
||||
"""Create a desktop shortcut for an installed Steam game."""
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
@@ -1209,66 +1075,15 @@ class ContextMenuManager:
|
||||
def remove_steam_from_menu(self, game_name: str) -> None:
|
||||
"""Remove a Steam game application menu shortcut."""
|
||||
menu_path = self._get_steam_menu_shortcut_path(game_name)
|
||||
if self._remove_file(
|
||||
self._remove_file(
|
||||
menu_path,
|
||||
_("Failed to remove '{game_name}' from {location}: {error}"),
|
||||
_("Removed '{game_name}' from {location}"),
|
||||
game_name,
|
||||
location=_("Menu")
|
||||
):
|
||||
self._update_desktop_database()
|
||||
|
||||
def _edit_steam_shortcut(self, game_name: str, appid: int | str, cover_path: str) -> None:
|
||||
appid_str = str(appid).strip()
|
||||
if not appid_str.isdigit():
|
||||
logger.warning("Invalid Steam appid for shortcut editing: %s", appid)
|
||||
return
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=appid_str)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
if not new_name:
|
||||
return
|
||||
selected_cover = dialog.coverEdit.text().strip()
|
||||
if not os.path.isfile(selected_cover) and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path):
|
||||
selected_cover = dialog.last_cover_path
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", appid_str)
|
||||
try:
|
||||
os.makedirs(game_dir, exist_ok=True)
|
||||
clean_name = new_name.replace("\r", " ").replace("\n", " ").strip()
|
||||
with open(os.path.join(game_dir, "metadata.txt"), "w", encoding="utf-8") as metadata_file:
|
||||
metadata_file.write(f"name={clean_name}\n")
|
||||
if selected_cover and os.path.isfile(selected_cover):
|
||||
extension = os.path.splitext(selected_cover)[1].lower()
|
||||
if extension not in COVER_IMAGE_EXTENSIONS:
|
||||
return
|
||||
target_cover = os.path.join(game_dir, f"cover{extension}")
|
||||
if os.path.abspath(selected_cover) != os.path.abspath(target_cover):
|
||||
self._remove_old_cover_files(game_dir)
|
||||
shutil.copyfile(selected_cover, target_cover)
|
||||
cover_path = target_cover
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"), _("Failed to save custom data: {error}").format(error=str(e))
|
||||
)
|
||||
return
|
||||
for game in self.game_library_manager.games:
|
||||
if str(game[3]) != appid_str or game[12] != "steam":
|
||||
continue
|
||||
updated_game = list(game)
|
||||
updated_game[0] = new_name
|
||||
updated_game[2] = cover_path
|
||||
self.game_library_manager.replace_game_incremental(
|
||||
game[0], game[5], tuple(updated_game)
|
||||
)
|
||||
QTimer.singleShot(0, self.game_library_manager.load_visible_images)
|
||||
return
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path):
|
||||
"""
|
||||
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
|
||||
|
||||
@@ -1277,9 +1092,6 @@ class ContextMenuManager:
|
||||
exec_line: The executable command line of the game.
|
||||
cover_path: The path to the game's cover image.
|
||||
"""
|
||||
if appid is not None:
|
||||
self._edit_steam_shortcut(game_name, appid, cover_path)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
@@ -1350,16 +1162,12 @@ class ContextMenuManager:
|
||||
|
||||
# Use the downloaded file path if we have a URL and the file was downloaded, otherwise use the local file
|
||||
if os.path.isfile(new_cover_path) or (is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path)):
|
||||
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
get_custom_data_dir_name(new_exe_path),
|
||||
)
|
||||
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
|
||||
# Use the actual cover file path (either from URL download or local file)
|
||||
|
||||
@@ -174,13 +174,8 @@ def get_selectable_gpu_entries() -> list[dict[str, str]]:
|
||||
device_id = gpu_info.get("device_id", "").strip()
|
||||
entry = {
|
||||
"device_name": device_name,
|
||||
"device_type": device_type,
|
||||
"vendor_id": vendor_id,
|
||||
"device_id": device_id,
|
||||
"driver_name": gpu_info.get("driver_name", "").strip(),
|
||||
"driver_info": gpu_info.get("driver_info", "").strip(),
|
||||
"driver_version": gpu_info.get("driver_version", "").strip(),
|
||||
"api_version": gpu_info.get("api_version", "").strip(),
|
||||
}
|
||||
|
||||
if device_type == "DISCRETE_GPU":
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Detail pages for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import weakref
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from shiboken6 import isValid
|
||||
from collections.abc import Callable
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QLabel,
|
||||
@@ -16,7 +13,7 @@ from PySide6.QtWidgets import (
|
||||
QBoxLayout,
|
||||
QSizePolicy,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from portprotonqt.detail_pages.widgets import (
|
||||
@@ -55,14 +52,7 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.animations import DetailPageAnimations
|
||||
from portprotonqt.debug_utils import DebugLogManager
|
||||
from portprotonqt.image_utils import cleanup_widget_animated_covers
|
||||
from portprotonqt.icon_extractor import get_exe_icon_cache_path
|
||||
from portprotonqt.steam_api import (
|
||||
get_local_steam_cover,
|
||||
get_steam_compat_tool,
|
||||
get_steam_home,
|
||||
get_steam_libs,
|
||||
safe_vdf_load,
|
||||
)
|
||||
from portprotonqt.steam_api import get_steam_compat_tool, get_steam_home, get_steam_libs, safe_vdf_load
|
||||
from portprotonqt.time_utils import format_playtime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -83,15 +73,11 @@ class DetailPageManager:
|
||||
self._autoinstall_status_controls = None
|
||||
self._current_detail_source: tuple[str, dict] | None = None
|
||||
self._resize_rebuild_pending = False
|
||||
self._gog_size_requests: dict[
|
||||
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
|
||||
] = {}
|
||||
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open detailed game information page."""
|
||||
self._current_detail_source = ("game", dict(game_data))
|
||||
detail_page = QWidget()
|
||||
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
|
||||
detail_page.setProperty("fallbackExe", fallback_exe)
|
||||
detail_page.setProperty("fallbackIconPath", fallback_icon_path)
|
||||
@@ -141,7 +127,19 @@ class DetailPageManager:
|
||||
return "", ""
|
||||
if not exe_path.lower().endswith(".exe"):
|
||||
return "", ""
|
||||
return exe_path, get_exe_icon_cache_path(exe_path)
|
||||
xdg_cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache"),
|
||||
)
|
||||
game_name = os.path.basename(game_data.get("name", ""))
|
||||
icon_name = game_name or os.path.splitext(os.path.basename(exe_path))[0]
|
||||
icon_path = os.path.join(
|
||||
xdg_cache_home,
|
||||
"PortProtonQt",
|
||||
"images",
|
||||
f"{icon_name}.png",
|
||||
)
|
||||
return exe_path, icon_path
|
||||
|
||||
def _get_favorite_icon_name(self, name: str) -> str:
|
||||
return "star_fav_full" if name in favorites_config.get_games() else "star_fav"
|
||||
@@ -176,15 +174,6 @@ class DetailPageManager:
|
||||
buttons_layout: FlowLayout,
|
||||
) -> dict:
|
||||
cover_frame, image_label = cover_widgets
|
||||
cover_path = game_data.get("cover_path")
|
||||
if str(game_data.get("game_source", "")).lower() == "steam":
|
||||
appid = game_data.get("appid", "")
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", str(appid))
|
||||
if os.path.dirname(os.path.abspath(cover_path or "")) != custom_dir:
|
||||
cover_path = get_local_steam_cover(appid) or cover_path
|
||||
return {
|
||||
"cover_frame": cover_frame,
|
||||
"image_label": image_label,
|
||||
@@ -193,7 +182,7 @@ class DetailPageManager:
|
||||
"buttons_layout": buttons_layout,
|
||||
"name": game_data.get("name", ""),
|
||||
"exec_line": game_data.get("exec_line", ""),
|
||||
"cover_path": cover_path,
|
||||
"cover_path": game_data.get("cover_path"),
|
||||
}
|
||||
|
||||
def _is_compact_detail_layout(self) -> bool:
|
||||
@@ -265,8 +254,7 @@ class DetailPageManager:
|
||||
|
||||
def _create_protondb_badge(self, parent: QWidget, game_data: dict) -> dict | None:
|
||||
badge, visible = create_protondb_badge(
|
||||
parent, game_data.get("protondb_tier", ""),
|
||||
game_data.get("protondb_appid", game_data.get("appid", "")),
|
||||
parent, game_data.get("protondb_tier", ""), game_data.get("appid", ""),
|
||||
self.main_window
|
||||
)
|
||||
return {"label": badge, "visible": True} if badge and visible else None
|
||||
@@ -309,97 +297,12 @@ class DetailPageManager:
|
||||
)
|
||||
game_info_layout.addLayout(first_row)
|
||||
|
||||
self._setup_gog_size_data(game_data, game_info_layout)
|
||||
|
||||
hltb_layout = QHBoxLayout()
|
||||
hltb_layout.setSpacing(10)
|
||||
self._setup_hltb_data(game_data.get("name", ""), hltb_layout, game_info_layout)
|
||||
|
||||
return game_info_layout
|
||||
|
||||
def _setup_gog_size_data(
|
||||
self, game_data: dict, game_info_layout: QVBoxLayout
|
||||
) -> None:
|
||||
exec_line = str(game_data.get("exec_line", ""))
|
||||
if str(game_data.get("game_source", "")).lower() != "gog":
|
||||
return
|
||||
if not exec_line.startswith("gog://install/"):
|
||||
return
|
||||
row = QHBoxLayout()
|
||||
values = []
|
||||
for title_text in (_("Download"), _("Size")):
|
||||
title = QLabel(title_text.upper())
|
||||
title.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||
value = QLabel("—")
|
||||
value.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||
row.addWidget(title)
|
||||
row.addWidget(value)
|
||||
row.addSpacing(30)
|
||||
values.append(weakref.ref(value))
|
||||
game_info_layout.addLayout(row)
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
cached = self.main_window.gog_api.get_cached_download_sizes(app_id)
|
||||
if cached is not None:
|
||||
self._set_gog_size_values(values, cached)
|
||||
return
|
||||
request = self._gog_size_requests.get(app_id)
|
||||
if request is not None:
|
||||
request[1].append(values)
|
||||
return
|
||||
try:
|
||||
command = self.main_window.gog_api.build_command(
|
||||
["info", app_id, "--platform", "windows", "--skip-dlcs"]
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
logger.warning("Failed to get GOG download size: %s", error)
|
||||
return
|
||||
process = QProcess(self.main_window)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
environment.insert("GOGDL_CONFIG_PATH", str(self.main_window.gog_api.config_dir))
|
||||
process.setProcessEnvironment(environment)
|
||||
self._gog_size_requests[app_id] = (process, [values])
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._finish_gog_size_request(app_id, code)
|
||||
)
|
||||
process.start()
|
||||
|
||||
def _finish_gog_size_request(self, app_id: str, code: int) -> None:
|
||||
request = self._gog_size_requests.pop(app_id, None)
|
||||
if request is None:
|
||||
return
|
||||
process, value_groups = request
|
||||
if code != 0:
|
||||
logger.warning("gogdl info exited with code %s", code)
|
||||
process.deleteLater()
|
||||
return
|
||||
try:
|
||||
sizes = self.main_window.gog_api.parse_download_sizes(
|
||||
bytes(process.readAllStandardOutput().data())
|
||||
)
|
||||
except (ValueError, TypeError) as error:
|
||||
logger.warning("Failed to parse GOG download size: %s", error)
|
||||
process.deleteLater()
|
||||
return
|
||||
self.main_window.gog_api.save_download_sizes(app_id, sizes)
|
||||
for value_refs in value_groups:
|
||||
self._set_gog_size_values(value_refs, sizes)
|
||||
process.deleteLater()
|
||||
|
||||
@staticmethod
|
||||
def _set_gog_size_values(
|
||||
value_refs: list[Callable[[], QLabel | None]], sizes: tuple[int, int]
|
||||
) -> None:
|
||||
for value_ref, size in zip(value_refs, sizes, strict=True):
|
||||
label = value_ref()
|
||||
if label is None or not isValid(label):
|
||||
continue
|
||||
divisor, unit = (
|
||||
(1024 ** 3, "GiB") if size >= 1024 ** 3 else (1024 ** 2, "MiB")
|
||||
)
|
||||
label.setText(f"{size / divisor:.1f} {unit}")
|
||||
|
||||
def _create_playtime_row(self, last_launch: str, formatted_playtime: str) -> QHBoxLayout:
|
||||
"""Create first row with last launch and playtime."""
|
||||
first_row = QHBoxLayout()
|
||||
@@ -531,25 +434,9 @@ class DetailPageManager:
|
||||
|
||||
current_exe = self._get_current_exe(exec_line)
|
||||
play_button = self._create_play_button(exec_line, current_exe)
|
||||
if str(game_source).lower() == "gog" and exec_line.startswith("gog://install/"):
|
||||
play_button.setText(_("Install"))
|
||||
play_button.setIcon(
|
||||
self.main_window.theme_manager.get_icon("update", as_path=True)
|
||||
)
|
||||
buttons_layout.addWidget(play_button)
|
||||
|
||||
if str(game_source).lower() == "steam" and appid:
|
||||
edit_button = self._make_action_button(
|
||||
_("Edit Shortcut"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
)
|
||||
edit_button.clicked.connect(
|
||||
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
|
||||
game_name, exec_line, cover_path, appid
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(edit_button)
|
||||
elif self._has_game_shortcut(game_name):
|
||||
if self._has_game_shortcut(game_name):
|
||||
edit_button = self._make_action_button(
|
||||
_("Edit Shortcut"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
@@ -560,7 +447,7 @@ class DetailPageManager:
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(edit_button)
|
||||
if str(game_source).lower() == "steam" and appid:
|
||||
elif str(game_source).lower() == "steam" and appid:
|
||||
open_folder_button = self._make_action_button(
|
||||
_("Open Folder"),
|
||||
self.main_window.theme_manager.get_icon("search", as_path=True),
|
||||
@@ -569,10 +456,7 @@ class DetailPageManager:
|
||||
lambda: self._open_steam_game_folder(str(appid))
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() != "gog"
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
else:
|
||||
add_button = self._make_action_button(
|
||||
_("Add a shortcut"),
|
||||
self.main_window.theme_manager.get_icon("addgame", as_path=True),
|
||||
@@ -587,40 +471,6 @@ class DetailPageManager:
|
||||
# Show settings button for PortProton games or Steam games using PortProtonQt
|
||||
if str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
str(game_source).lower() == "gog"
|
||||
and appid
|
||||
and exec_line.startswith("gog://launch/")
|
||||
):
|
||||
gog_exe = self.main_window.gog_api.get_launch_target(str(appid))
|
||||
if not gog_exe:
|
||||
return buttons_layout
|
||||
self.main_window.gog_api.ensure_launch_parameters(str(appid))
|
||||
settings_button = self._make_action_button(
|
||||
_("Settings"),
|
||||
self.main_window.theme_manager.get_icon("settings", as_path=True),
|
||||
)
|
||||
settings_button.clicked.connect(
|
||||
lambda: self.main_window.open_exe_settings(
|
||||
gog_exe, game_source="gog",
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(settings_button)
|
||||
log_button = self._make_action_button(
|
||||
_("Create Log"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
)
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(gog_exe, log_button))
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
folder_button = self._make_action_button(
|
||||
_("Open Folder"),
|
||||
self.main_window.theme_manager.get_icon("search", as_path=True),
|
||||
)
|
||||
folder_button.clicked.connect(
|
||||
lambda: self._open_executable_folder(gog_exe)
|
||||
)
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "steam" and appid:
|
||||
try:
|
||||
compat_tool = get_steam_compat_tool(int(appid))
|
||||
@@ -646,7 +496,6 @@ class DetailPageManager:
|
||||
icon = self.main_window.theme_manager.get_icon("play", as_path=True)
|
||||
|
||||
play_button = self._make_action_button(text, icon)
|
||||
play_button.setProperty("sound_event", False)
|
||||
play_button.clicked.connect(lambda: self.main_window.toggleGame(exec_line, play_button))
|
||||
return play_button
|
||||
|
||||
@@ -726,13 +575,9 @@ class DetailPageManager:
|
||||
def _open_portproton_game_folder(self, exec_line: str) -> None:
|
||||
"""Open folder containing the PortProton game executable."""
|
||||
file_to_check = self._get_file_from_exec(exec_line)
|
||||
self._open_executable_folder(file_to_check)
|
||||
|
||||
def _open_executable_folder(self, exe_path: str | None) -> None:
|
||||
"""Open the folder containing an executable."""
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
if not file_to_check or not os.path.exists(file_to_check):
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
folder_path = os.path.dirname(os.path.abspath(file_to_check))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
|
||||
def _has_game_shortcut(self, game_name: str) -> bool:
|
||||
@@ -831,24 +676,12 @@ class DetailPageManager:
|
||||
|
||||
source_kind, source_data = source
|
||||
return_tab_index = getattr(self, "_return_to_tab_index", 0)
|
||||
current_page = self._current_detail_page
|
||||
running_button = self.main_window.current_running_button
|
||||
replace_running_button = (
|
||||
current_page is not None
|
||||
and running_button is not None
|
||||
and current_page.isAncestorOf(running_button)
|
||||
)
|
||||
self._remove_current_detail_page()
|
||||
if source_kind == "autoinstall":
|
||||
self.openAutoInstallDetailPage(source_data)
|
||||
else:
|
||||
self.openGameDetailPage(source_data)
|
||||
self._return_to_tab_index = return_tab_index
|
||||
if replace_running_button and self._current_detail_page is not None:
|
||||
play_button = self._find_play_button(self._current_detail_page)
|
||||
if play_button is not None:
|
||||
self.main_window.current_running_button = play_button
|
||||
self.main_window._set_running_button_stop()
|
||||
|
||||
def _finalize_detail_page(
|
||||
self,
|
||||
@@ -883,16 +716,11 @@ class DetailPageManager:
|
||||
main_layout.addStretch()
|
||||
|
||||
self.main_window.current_exec_line = page_data["exec_line"]
|
||||
cover_path = (
|
||||
None
|
||||
if detail_page.property("fallbackExe")
|
||||
else page_data["cover_path"]
|
||||
)
|
||||
self._setup_detail_page_animation(
|
||||
detail_page,
|
||||
page_data["image_label"],
|
||||
detail_page,
|
||||
cover_path,
|
||||
page_data["cover_path"],
|
||||
)
|
||||
|
||||
def _populate_compact_game_layout(
|
||||
@@ -993,8 +821,8 @@ class DetailPageManager:
|
||||
return None
|
||||
|
||||
def _is_action_button_text(self, text: str) -> bool:
|
||||
"""Check if button text is a primary game action."""
|
||||
return text in (_("Start"), _("Stop"), _("Install"))
|
||||
"""Check if button text is Play or Stop."""
|
||||
return text in (_("Start"), _("Stop"))
|
||||
|
||||
def _on_favorite_click(self, name: str) -> str:
|
||||
"""Handle favorite toggle click."""
|
||||
@@ -1221,7 +1049,7 @@ class DetailPageManager:
|
||||
self.main_window.currentDetailPage = None
|
||||
self._current_detail_page = None
|
||||
|
||||
def _game_tuple_to_data(self, game_tuple: Sequence[object]) -> dict:
|
||||
def _game_tuple_to_data(self, game_tuple: tuple) -> dict:
|
||||
return {
|
||||
"name": game_tuple[0],
|
||||
"description": game_tuple[1],
|
||||
@@ -1238,7 +1066,6 @@ class DetailPageManager:
|
||||
"anticheat_slug": game_tuple[13] if len(game_tuple) > 13 else "",
|
||||
"ppdb_id": game_tuple[14] if len(game_tuple) > 14 else "",
|
||||
"ppdb_rating": game_tuple[15] if len(game_tuple) > 15 else "",
|
||||
"protondb_appid": game_tuple[16] if len(game_tuple) > 16 else game_tuple[3],
|
||||
}
|
||||
|
||||
def _finalize_autoinstall_page(
|
||||
@@ -1508,7 +1335,7 @@ class DetailPageManager:
|
||||
if card is None or not card.isVisible():
|
||||
return
|
||||
self._clear_current_card_state(card)
|
||||
card.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
|
||||
card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
self.main_window.current_focused_card = card
|
||||
card.update()
|
||||
|
||||
@@ -1533,7 +1360,7 @@ class DetailPageManager:
|
||||
if not cards:
|
||||
return
|
||||
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
|
||||
cards[0].setFocus(Qt.FocusReason.ActiveWindowFocusReason)
|
||||
cards[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
scroll_area = container.parentWidget()
|
||||
while scroll_area and not isinstance(scroll_area, QScrollArea):
|
||||
scroll_area = scroll_area.parentWidget()
|
||||
|
||||
@@ -16,12 +16,10 @@ from portprotonqt.config import (
|
||||
parse_desktop_entry,
|
||||
)
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.animations.detail_background import DetailBackgroundAnimations
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_pixmap_relays: WeakKeyDictionary[QWidget, "_PixmapReadyRelay"] = WeakKeyDictionary()
|
||||
_cover_reveal_animations: WeakKeyDictionary[QLabel, QPropertyAnimation] = WeakKeyDictionary()
|
||||
_detail_backgrounds = DetailBackgroundAnimations()
|
||||
|
||||
|
||||
class _PixmapReadyRelay(QObject):
|
||||
@@ -66,7 +64,6 @@ def setup_image_loading(
|
||||
cover_width,
|
||||
cover_height,
|
||||
lambda pixmap: relay.pixmap_ready.emit(pixmap),
|
||||
app_name=str(detail_page.property("coverCacheKey") or ""),
|
||||
fallback_exe=fallback_exe,
|
||||
fallback_icon_path=fallback_icon_path,
|
||||
)
|
||||
@@ -172,7 +169,7 @@ def _resolve_gradient_stops(theme, dark_palette: list) -> str:
|
||||
- list of float: palette colors at specified positions
|
||||
- list of dict/tuple/str: custom stops with position+color
|
||||
"""
|
||||
gradient_stops = _resolve_gradient_config(theme).get("stops")
|
||||
gradient_stops = getattr(theme, "DETAIL_PAGE_GRADIENT", None)
|
||||
if gradient_stops is None:
|
||||
return _build_palette_stops(dark_palette)
|
||||
if isinstance(gradient_stops, str):
|
||||
@@ -196,45 +193,21 @@ def _resolve_gradient_stops(theme, dark_palette: list) -> str:
|
||||
return ",\n".join(stop_parts)
|
||||
|
||||
|
||||
def _resolve_gradient_config(theme: object) -> dict:
|
||||
"""Resolve nested gradient settings with legacy theme compatibility."""
|
||||
backgrounds = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
|
||||
config = dict(backgrounds.get("gradient", {})) if isinstance(backgrounds, dict) else {}
|
||||
legacy_names = {
|
||||
"stops": "DETAIL_PAGE_GRADIENT",
|
||||
"type": "DETAIL_PAGE_GRADIENT_TYPE",
|
||||
"x1": "DETAIL_PAGE_GRADIENT_X1",
|
||||
"y1": "DETAIL_PAGE_GRADIENT_Y1",
|
||||
"x2": "DETAIL_PAGE_GRADIENT_X2",
|
||||
"y2": "DETAIL_PAGE_GRADIENT_Y2",
|
||||
"cx": "DETAIL_PAGE_GRADIENT_CX",
|
||||
"cy": "DETAIL_PAGE_GRADIENT_CY",
|
||||
"radius": "DETAIL_PAGE_GRADIENT_RADIUS",
|
||||
"fx": "DETAIL_PAGE_GRADIENT_FX",
|
||||
"fy": "DETAIL_PAGE_GRADIENT_FY",
|
||||
}
|
||||
for key, legacy_name in legacy_names.items():
|
||||
if hasattr(theme, legacy_name):
|
||||
config[key] = getattr(theme, legacy_name)
|
||||
return config
|
||||
|
||||
|
||||
def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window) -> None:
|
||||
"""Apply palette-based stylesheet to detail page."""
|
||||
try:
|
||||
dark_palette = [
|
||||
main_window.darkenColor(color, factor=200) for color in palette
|
||||
]
|
||||
gradient_config = _resolve_gradient_config(main_window.theme)
|
||||
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
|
||||
gradient_type = gradient_config.get("type", "linear")
|
||||
gradient_type = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_TYPE', 'linear')
|
||||
|
||||
if gradient_type == 'radial':
|
||||
cx = gradient_config.get("cx", 0.5)
|
||||
cy = gradient_config.get("cy", 0.5)
|
||||
radius = gradient_config.get("radius", 0.5)
|
||||
fx = gradient_config.get("fx", 0.5)
|
||||
fy = gradient_config.get("fy", 0.5)
|
||||
cx = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_CX', 0.5)
|
||||
cy = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_CY', 0.5)
|
||||
radius = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_RADIUS', 0.5)
|
||||
fx = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_FX', 0.5)
|
||||
fy = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_FY', 0.5)
|
||||
|
||||
stylesheet = f"""
|
||||
QWidget {{
|
||||
@@ -245,10 +218,10 @@ def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window)
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
x1 = gradient_config.get("x1", 0)
|
||||
y1 = gradient_config.get("y1", 0)
|
||||
x2 = gradient_config.get("x2", 1)
|
||||
y2 = gradient_config.get("y2", 1)
|
||||
x1 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_X1', 0)
|
||||
y1 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_Y1', 0)
|
||||
x2 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_X2', 1)
|
||||
y2 = getattr(main_window.theme, 'DETAIL_PAGE_GRADIENT_Y2', 1)
|
||||
|
||||
stylesheet = f"""
|
||||
QWidget {{
|
||||
@@ -273,19 +246,11 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
|
||||
"""Setup wave background overlay based on theme DETAIL_PAGE_BG_MODE."""
|
||||
bg_mode = getattr(theme, "DETAIL_PAGE_BG_MODE", "gradient")
|
||||
|
||||
if _detail_backgrounds.setup(detail_page, dark_palette, theme):
|
||||
_remove_wave_background(detail_page)
|
||||
return
|
||||
_detail_backgrounds.remove(detail_page)
|
||||
|
||||
if bg_mode == "gradient":
|
||||
_remove_wave_background(detail_page)
|
||||
return
|
||||
|
||||
backgrounds = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
|
||||
wave_config = backgrounds.get("waves", {}) if isinstance(backgrounds, dict) else {}
|
||||
if hasattr(theme, "DETAIL_PAGE_WAVES"):
|
||||
wave_config = theme.DETAIL_PAGE_WAVES
|
||||
wave_config = getattr(theme, "DETAIL_PAGE_WAVES", {})
|
||||
state = _wave_states.get(detail_page)
|
||||
if state is None:
|
||||
state = {
|
||||
@@ -399,7 +364,6 @@ def _apply_no_cover_style(detail_page: QWidget, theme) -> None:
|
||||
try:
|
||||
detail_page.setStyleSheet(theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||
_remove_wave_background(detail_page)
|
||||
_detail_backgrounds.remove(detail_page)
|
||||
detail_page.update()
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
|
||||
|
||||
@@ -72,7 +72,6 @@ def create_back_button(
|
||||
back_button = AutoSizeButton(_("Back"), icon=theme_manager.get_icon("back", as_path=True))
|
||||
back_button.setFixedWidth(100)
|
||||
back_button.setStyleSheet(theme.ADDGAME_BACK_BUTTON_STYLE)
|
||||
back_button.setProperty("sound_event", "back")
|
||||
back_button.clicked.connect(on_click)
|
||||
parent_layout.addWidget(back_button, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
return back_button
|
||||
@@ -139,10 +138,10 @@ def _add_source_corner(
|
||||
) -> None:
|
||||
"""Add source corner badge to cover frame."""
|
||||
source = game_source.lower()
|
||||
if source not in ("steam", "gog", "portproton") or theme_manager is None:
|
||||
if source not in ("steam", "portproton") or theme_manager is None:
|
||||
return
|
||||
|
||||
icon_name = f"badge_{source}"
|
||||
icon_name = "badge_steam" if source == "steam" else "badge_portproton"
|
||||
icon = theme_manager.get_icon(icon_name, as_path=True)
|
||||
if not icon:
|
||||
return
|
||||
@@ -366,7 +365,7 @@ def create_portproton_badge(
|
||||
badge.setStyleSheet(main_window.theme.STEAM_BADGE_STYLE)
|
||||
_apply_badge_view_mode(badge, main_window.theme)
|
||||
badge.clicked.connect(
|
||||
lambda: QDesktopServices.openUrl(QUrl(f"https://linux-gaming.ru/game/{ppdb_id}"))
|
||||
lambda: QDesktopServices.openUrl(QUrl(f"https://ppdb.linux-gaming.ru/game/{ppdb_id}"))
|
||||
)
|
||||
return badge
|
||||
|
||||
@@ -441,11 +440,7 @@ def create_details_widget(
|
||||
_add_details_description(details_layout, main_window.theme, description)
|
||||
|
||||
if game_info_layout:
|
||||
details_layout.addWidget(
|
||||
create_compact_layout_panel(
|
||||
parent, main_window.theme, game_info_layout
|
||||
)
|
||||
)
|
||||
details_layout.addLayout(game_info_layout)
|
||||
|
||||
if controller_support:
|
||||
_add_controller_support(details_layout, main_window.theme, controller_support)
|
||||
|
||||
@@ -128,7 +128,7 @@ class GameLaunchDialog(DraggableDialog):
|
||||
class AddGameDialog(DraggableDialog):
|
||||
"""Dialog for adding or editing a game."""
|
||||
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None, steam_appid=None):
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
|
||||
super().__init__(parent)
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
self.theme = theme if theme else theme_manager.apply_theme(ui_config.get_theme())
|
||||
@@ -156,9 +156,11 @@ class AddGameDialog(DraggableDialog):
|
||||
name_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
layout.addRow(name_label, self.nameEdit)
|
||||
|
||||
exe_label = QLabel(_("Path to Executable:"))
|
||||
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
|
||||
self.exeEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.exeEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.exeEdit.setVisible(steam_appid is None)
|
||||
if exe_path:
|
||||
self.exeEdit.setText(exe_path)
|
||||
|
||||
@@ -166,15 +168,12 @@ class AddGameDialog(DraggableDialog):
|
||||
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
exeBrowseButton.clicked.connect(self.browseExe)
|
||||
exeBrowseButton.setObjectName("exeBrowseButton")
|
||||
exeBrowseButton.setVisible(steam_appid is None)
|
||||
if steam_appid is None:
|
||||
exe_label = QLabel(_("Path to Executable:"))
|
||||
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
layout.addRow(exe_label, self.exeEdit)
|
||||
|
||||
empty_label = QLabel("")
|
||||
empty_label.setFixedWidth(exe_label.sizeHint().width())
|
||||
layout.addRow(empty_label, exeBrowseButton)
|
||||
layout.addRow(exe_label, self.exeEdit)
|
||||
|
||||
empty_label = QLabel("")
|
||||
empty_label.setFixedWidth(exe_label.sizeHint().width())
|
||||
layout.addRow(empty_label, exeBrowseButton)
|
||||
|
||||
cover_label = QLabel(_("Custom Cover:"))
|
||||
cover_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
@@ -191,9 +190,7 @@ class AddGameDialog(DraggableDialog):
|
||||
coverBrowseButton.setObjectName("coverBrowseButton")
|
||||
|
||||
layout.addRow(cover_label, self.coverEdit)
|
||||
cover_button_label = QLabel("")
|
||||
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
|
||||
layout.addRow(cover_button_label, coverBrowseButton)
|
||||
layout.addRow(empty_label, coverBrowseButton)
|
||||
|
||||
self.add_to_steam_checkbox = QCheckBox()
|
||||
self.add_to_menu_checkbox = QCheckBox()
|
||||
@@ -213,7 +210,6 @@ class AddGameDialog(DraggableDialog):
|
||||
self.add_to_steam_checkbox.setIconSize(QSize(20, 20))
|
||||
self.add_to_menu_checkbox.setIconSize(QSize(20, 20))
|
||||
self.add_to_desktop_checkbox.setIconSize(QSize(20, 20))
|
||||
self.add_to_steam_checkbox.setVisible(steam_appid is None)
|
||||
options_layout = QHBoxLayout()
|
||||
options_layout.setSpacing(12)
|
||||
options_layout.addWidget(self.add_to_steam_checkbox)
|
||||
@@ -222,12 +218,9 @@ class AddGameDialog(DraggableDialog):
|
||||
options_layout.addStretch()
|
||||
options_widget = QWidget(self)
|
||||
options_widget.setLayout(options_layout)
|
||||
options_widget.setVisible(steam_appid is None)
|
||||
options_label = QLabel(_("Add shortcut to:"))
|
||||
options_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
options_label.setVisible(steam_appid is None)
|
||||
if steam_appid is None:
|
||||
layout.addRow(options_label, options_widget)
|
||||
layout.addRow(options_label, options_widget)
|
||||
|
||||
current_name = game_name.strip() if game_name else self.nameEdit.text().strip()
|
||||
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Compatibility report dialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.localization import _
|
||||
|
||||
|
||||
class CompatibilityReportDialog(DraggableDialog):
|
||||
"""Display a readable compatibility report."""
|
||||
|
||||
def __init__(self, parent, theme, report: str) -> None:
|
||||
super().__init__(parent)
|
||||
metrics = theme.COMPATIBILITY_REPORT_DIALOG
|
||||
self.setWindowTitle(_("Compatibility report"))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(metrics["minimum_width"], metrics["minimum_height"])
|
||||
self.setStyleSheet(
|
||||
theme.MAIN_WINDOW_STYLE + theme.COMPATIBILITY_REPORT_DIALOG_STYLE
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(*([metrics["margin"]] * 4))
|
||||
layout.setSpacing(metrics["spacing"])
|
||||
layout.addWidget(self._create_header())
|
||||
|
||||
report_view = QPlainTextEdit()
|
||||
report_view.setObjectName("compatibilityReport")
|
||||
report_view.setReadOnly(True)
|
||||
report_view.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
|
||||
report_view.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
report_view.setStyleSheet(theme.SCROLL_STYLE)
|
||||
report_view.setPlainText(report)
|
||||
report_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
report_view.customContextMenuRequested.connect(
|
||||
lambda position: self._show_context_menu(report_view, theme, position)
|
||||
)
|
||||
layout.addWidget(report_view, stretch=1)
|
||||
layout.addLayout(self._create_buttons(theme))
|
||||
|
||||
def _create_header(self) -> QFrame:
|
||||
header = QFrame()
|
||||
header.setObjectName("compatibilityHeader")
|
||||
layout = QVBoxLayout(header)
|
||||
title = QLabel(_("Compatibility report"))
|
||||
title.setObjectName("compatibilityTitle")
|
||||
summary = QLabel(
|
||||
_("The program stopped. Compatibility details were collected automatically.")
|
||||
)
|
||||
summary.setObjectName("compatibilitySummary")
|
||||
summary.setWordWrap(True)
|
||||
layout.addWidget(title)
|
||||
layout.addWidget(summary)
|
||||
return header
|
||||
|
||||
def _create_buttons(self, theme) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
layout.addStretch()
|
||||
close_button = AutoSizeButton(_("Close"))
|
||||
close_button.setStyleSheet(theme.ACTION_BUTTON_ACTIVE_STYLE)
|
||||
close_button.clicked.connect(self.accept)
|
||||
close_button.setDefault(True)
|
||||
layout.addWidget(close_button)
|
||||
return layout
|
||||
|
||||
def _show_context_menu(
|
||||
self, report_view: QPlainTextEdit, theme: Any, position: QPoint
|
||||
) -> None:
|
||||
menu = report_view.createStandardContextMenu()
|
||||
menu.setWindowFlags(
|
||||
menu.windowFlags()
|
||||
| Qt.WindowType.Popup
|
||||
| Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
menu.setStyleSheet(theme.CONTEXT_MENU_STYLE)
|
||||
menu.exec(report_view.viewport().mapToGlobal(position))
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Proton Manager dialog for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
@@ -284,15 +283,6 @@ class ProtonManager(DraggableDialog):
|
||||
return True
|
||||
|
||||
def filter_entries_by_cpu_level(self, entries, source_name):
|
||||
is_arm = platform.machine().lower().startswith(('arm', 'aarch64'))
|
||||
if not is_arm:
|
||||
entries = [
|
||||
entry for entry in entries
|
||||
if 'aarch64' not in entry.get('name', '').lower()
|
||||
and '-arm' not in entry.get('name', '').lower()
|
||||
and 'aarch64' not in entry.get('url', '').lower()
|
||||
and '-arm' not in entry.get('url', '').lower()
|
||||
]
|
||||
if source_name.lower() != 'proton_cachyos':
|
||||
return entries
|
||||
if self.cpu_level >= 4:
|
||||
|
||||
@@ -795,7 +795,7 @@ class ExeSettingsDialog(
|
||||
line_edit.setEnabled(False)
|
||||
line_edit.setStyleSheet(self.theme.SETTINGS_DISABLED_INPUT_STYLE)
|
||||
|
||||
if setting['key'] in ('PW_RUN_AFTER_EXE', 'PW_NETWORK_BLOCKLIST'):
|
||||
if setting['key'] == 'PW_RUN_AFTER_EXE':
|
||||
text_container = QWidget()
|
||||
text_container.setProperty("ppqt_run_after_exe_widget", True)
|
||||
text_layout = QHBoxLayout(text_container)
|
||||
@@ -810,7 +810,7 @@ class ExeSettingsDialog(
|
||||
if is_blocked:
|
||||
browse_button.setEnabled(False)
|
||||
|
||||
def open_setting_file_selector(
|
||||
def open_run_after_exe_selector(
|
||||
_checked: bool = False,
|
||||
target_line_edit=line_edit,
|
||||
file_filter="",
|
||||
@@ -832,7 +832,7 @@ class ExeSettingsDialog(
|
||||
)
|
||||
file_explorer.exec()
|
||||
|
||||
browse_button.clicked.connect(open_setting_file_selector)
|
||||
browse_button.clicked.connect(open_run_after_exe_selector)
|
||||
text_layout.addWidget(browse_button)
|
||||
self.advanced_table.setCellWidget(row, 1, text_container)
|
||||
else:
|
||||
|
||||
@@ -64,7 +64,7 @@ class WineLoadingThread(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata.json"
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as tmp:
|
||||
temp_path = tmp.name
|
||||
|
||||
+34
-83
@@ -15,7 +15,6 @@ from PySide6.QtGui import (
|
||||
)
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QGraphicsDropShadowEffect,
|
||||
QGraphicsOpacityEffect,
|
||||
@@ -43,7 +42,6 @@ from portprotonqt.config import (
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import ClickableLabel
|
||||
from portprotonqt.animations import GameCardAnimations
|
||||
from portprotonqt.icon_extractor import get_exe_icon_cache_path
|
||||
|
||||
PROTONDB_TIERS = ("platinum", "gold", "silver", "bronze", "borked", "pending")
|
||||
|
||||
@@ -220,7 +218,7 @@ class GameCard(QFrame):
|
||||
self, name, description, cover_path, appid, controller_support, exec_line,
|
||||
last_launch, formatted_playtime, protondb_tier, anticheat_status,
|
||||
last_launch_ts, playtime_seconds, game_source, anticheat_slug="",
|
||||
ppdb_id="", ppdb_rating="", protondb_appid="", *, select_callback, theme=None,
|
||||
ppdb_id="", ppdb_rating="", *, select_callback, theme=None,
|
||||
card_width=250, parent=None, context_menu_manager=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
@@ -237,7 +235,6 @@ class GameCard(QFrame):
|
||||
self.anticheat_slug = anticheat_slug or ""
|
||||
self.ppdb_id = ppdb_id or ""
|
||||
self.ppdb_rating = ppdb_rating or ""
|
||||
self.protondb_appid = protondb_appid or appid
|
||||
self.game_source = game_source
|
||||
self.autoinstall_exe_name = ""
|
||||
self.last_launch_ts = last_launch_ts
|
||||
@@ -263,14 +260,10 @@ class GameCard(QFrame):
|
||||
self.missing_executable_path = self._get_missing_executable_path()
|
||||
|
||||
self.steam_visible = (str(game_source).lower() == "steam" and not self.economy_mode)
|
||||
self.gog_visible = (str(game_source).lower() == "gog" and not self.economy_mode)
|
||||
self.portproton_visible = (str(game_source).lower() == "portproton" and not self.economy_mode)
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
config_name = "GAME_CARD_LIST" if self.list_layout else "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = getattr(self.theme, config_name, {})
|
||||
default_margin = 8 if self.list_layout else 20
|
||||
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
|
||||
self.base_extra_margin = 8 if self.list_layout else 20
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
||||
|
||||
@@ -279,8 +272,6 @@ class GameCard(QFrame):
|
||||
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
self._hover_sound_pending = False
|
||||
self.setMouseTracking(True)
|
||||
|
||||
self.animations = GameCardAnimations(self, self.theme)
|
||||
self.animations.setup_animations()
|
||||
@@ -293,10 +284,10 @@ class GameCard(QFrame):
|
||||
|
||||
if self.list_layout:
|
||||
self.layout_ = QHBoxLayout(self)
|
||||
self.layout_.setSpacing(self.card_layout_cfg.get("spacing", 12))
|
||||
self.layout_.setSpacing(12)
|
||||
else:
|
||||
self.layout_ = QVBoxLayout(self)
|
||||
self.layout_.setSpacing(self.card_layout_cfg.get("spacing", 5))
|
||||
self.layout_.setSpacing(5)
|
||||
self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
|
||||
|
||||
self.coverWidget = QWidget()
|
||||
@@ -309,10 +300,7 @@ class GameCard(QFrame):
|
||||
self.coverLabel = QLabel()
|
||||
self.coverLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
|
||||
if self.missing_executable_path or (
|
||||
str(self.game_source).lower() == "gog"
|
||||
and self.exec_line.startswith("gog://install/")
|
||||
):
|
||||
if self.missing_executable_path:
|
||||
self.coverOpacity = QGraphicsOpacityEffect(self.coverLabel)
|
||||
self.coverOpacity.setOpacity(self.theme.missing_exe_cover_opacity)
|
||||
self.coverLabel.setGraphicsEffect(self.coverOpacity)
|
||||
@@ -353,16 +341,6 @@ class GameCard(QFrame):
|
||||
if self.economy_mode:
|
||||
self.steamLabel.setVisible(False)
|
||||
|
||||
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
|
||||
self.gogLabel = SourceCorner(
|
||||
icon=gog_icon,
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
if self.economy_mode:
|
||||
self.gogLabel.setVisible(False)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
icon=portproton_icon,
|
||||
@@ -443,9 +421,8 @@ class GameCard(QFrame):
|
||||
|
||||
def _load_cover_image(self, cover_path: str) -> None:
|
||||
if self.list_layout:
|
||||
cover_size = self.card_layout_cfg.get("cover_load_size", 64)
|
||||
width = cover_size
|
||||
height = cover_size
|
||||
width = 64
|
||||
height = 64
|
||||
else:
|
||||
width = self.base_card_width
|
||||
height = int(self.base_card_width * 1.5)
|
||||
@@ -457,7 +434,6 @@ class GameCard(QFrame):
|
||||
width,
|
||||
height,
|
||||
self.on_cover_loaded,
|
||||
app_name=str(self.appid or ""),
|
||||
fallback_exe=fallback_exe,
|
||||
fallback_icon_path=fallback_icon_path,
|
||||
)
|
||||
@@ -468,12 +444,23 @@ class GameCard(QFrame):
|
||||
return "", ""
|
||||
if not exe_path.lower().endswith(".exe"):
|
||||
return "", ""
|
||||
return exe_path, get_exe_icon_cache_path(exe_path)
|
||||
xdg_cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache"),
|
||||
)
|
||||
icon_name = os.path.basename(self.name) or os.path.splitext(os.path.basename(exe_path))[0]
|
||||
icon_path = os.path.join(
|
||||
xdg_cache_home,
|
||||
"PortProtonQt",
|
||||
"images",
|
||||
f"{icon_name}.png",
|
||||
)
|
||||
return exe_path, icon_path
|
||||
|
||||
def _set_animated_cover(self, cover_path: str, width: int, height: int) -> bool:
|
||||
if not cover_path or not os.path.isfile(cover_path):
|
||||
return False
|
||||
radius = self.card_layout_cfg.get("cover_radius", 8 if self.list_layout else 15)
|
||||
radius = 8 if self.list_layout else 15
|
||||
if not set_animated_cover(self.coverLabel, cover_path, width, height, radius):
|
||||
return False
|
||||
self.animated_cover_path = cover_path
|
||||
@@ -481,8 +468,7 @@ class GameCard(QFrame):
|
||||
return True
|
||||
|
||||
def _update_animated_cover_size(self) -> None:
|
||||
default_radius = 8 if self.list_layout else 15
|
||||
radius = int(self.card_layout_cfg.get("cover_radius", default_radius) * self._scale)
|
||||
radius = max(8, int(10 * self._scale)) if self.list_layout else int(15 * self._scale)
|
||||
update_animated_cover_size(
|
||||
self.coverLabel,
|
||||
self.coverLabel.width(),
|
||||
@@ -517,13 +503,12 @@ class GameCard(QFrame):
|
||||
if self.list_layout:
|
||||
target_width = self.coverLabel.width() if self.coverLabel.width() > 0 else 56
|
||||
target_height = self.coverLabel.height() if self.coverLabel.height() > 0 else 56
|
||||
radius = int(self.card_layout_cfg.get("cover_radius", 8) * self._scale)
|
||||
radius = max(8, int(10 * self._scale))
|
||||
aspect_mode = Qt.AspectRatioMode.KeepAspectRatio
|
||||
else:
|
||||
target_width = int(self.base_card_width * self._scale)
|
||||
cover_ratio = self.card_layout_cfg.get("cover_aspect_ratio", 1.5)
|
||||
target_height = int(target_width * cover_ratio)
|
||||
radius = int(self.card_layout_cfg.get("cover_radius", 15) * self._scale)
|
||||
target_height = int(target_width * 1.5)
|
||||
radius = int(15 * self._scale)
|
||||
aspect_mode = Qt.AspectRatioMode.KeepAspectRatioByExpanding
|
||||
scaled_pixmap = self.base_pixmap.scaled(
|
||||
target_width,
|
||||
@@ -592,7 +577,6 @@ class GameCard(QFrame):
|
||||
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
|
||||
source_ribbons = [
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
@@ -614,7 +598,6 @@ class GameCard(QFrame):
|
||||
self.ppdbLabel.raise_()
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -628,31 +611,20 @@ class GameCard(QFrame):
|
||||
scaled_extra = int(self.base_extra_margin * self._scale)
|
||||
self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
|
||||
if self.list_layout:
|
||||
row_height = max(
|
||||
self.card_layout_cfg.get("min_row_height", 68),
|
||||
int(self.card_layout_cfg.get("row_height", 72) * self._scale),
|
||||
)
|
||||
icon_size = max(
|
||||
self.card_layout_cfg.get("min_cover_size", 48),
|
||||
int(self.card_layout_cfg.get("cover_size", 56) * self._scale),
|
||||
)
|
||||
margin_left = self.card_layout_cfg.get("cover_left_margin", 10)
|
||||
row_height = max(68, int(72 * self._scale))
|
||||
icon_size = max(48, int(56 * self._scale))
|
||||
margin_left = 10
|
||||
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
|
||||
self.coverWidget.setFixedSize(icon_size + margin_left, icon_size)
|
||||
self.coverWidget.setContentsMargins(margin_left, 0, 0, 0)
|
||||
self.coverWidget.setContentsMargins(10, 0, 0, 0)
|
||||
self.coverLabel.setFixedSize(icon_size, icon_size)
|
||||
else:
|
||||
small_card_mode = self.base_card_width < self.theme.COMPACT_CARD["width_threshold"]
|
||||
height_ratio = (
|
||||
self.theme.COMPACT_CARD["height_ratio"]
|
||||
if small_card_mode else self.card_layout_cfg.get("card_height_ratio", 1.8)
|
||||
)
|
||||
height_ratio = self.theme.COMPACT_CARD["height_ratio"] if small_card_mode else 1.8
|
||||
scaled_height = int(self.base_card_width * height_ratio * self._scale)
|
||||
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
|
||||
cover_ratio = self.card_layout_cfg.get("cover_aspect_ratio", 1.5)
|
||||
cover_height = int(scaled_width * cover_ratio)
|
||||
self.coverWidget.setFixedSize(scaled_width, cover_height)
|
||||
self.coverLabel.setFixedSize(scaled_width, cover_height)
|
||||
self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
|
||||
self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
|
||||
|
||||
self.update_cover_pixmap()
|
||||
|
||||
@@ -713,7 +685,7 @@ class GameCard(QFrame):
|
||||
pass
|
||||
|
||||
try:
|
||||
self.shadow.setBlurRadius(int(self.theme.shadow_blur_radius * self._scale))
|
||||
self.shadow.setBlurRadius(int(20 * self._scale))
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
@@ -752,7 +724,6 @@ class GameCard(QFrame):
|
||||
self.display_filter = display_filter
|
||||
self.economy_mode = ui_config.get_economy_mode()
|
||||
self.steam_visible = (str(self.game_source).lower() == "steam" and not self.economy_mode)
|
||||
self.gog_visible = (str(self.game_source).lower() == "gog" and not self.economy_mode)
|
||||
self.portproton_visible = (str(self.game_source).lower() == "portproton" and not self.economy_mode)
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
|
||||
@@ -762,7 +733,6 @@ class GameCard(QFrame):
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible and not hidden_badges)
|
||||
self.gogLabel.setVisible(self.gog_visible and not hidden_badges)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible and not hidden_badges)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
@@ -896,10 +866,10 @@ class GameCard(QFrame):
|
||||
|
||||
def open_ppdb_page(self):
|
||||
if self.ppdb_id:
|
||||
QDesktopServices.openUrl(QUrl(f"https://linux-gaming.ru/game/{self.ppdb_id}"))
|
||||
QDesktopServices.openUrl(QUrl(f"https://ppdb.linux-gaming.ru/game/{self.ppdb_id}"))
|
||||
|
||||
def open_protondb_report(self):
|
||||
url = QUrl(f"https://www.protondb.com/app/{self.protondb_appid}")
|
||||
url = QUrl(f"https://www.protondb.com/app/{self.appid}")
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
def open_weanticheatyet_page(self):
|
||||
@@ -990,32 +960,14 @@ class GameCard(QFrame):
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hover_sound_pending = True
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hover_sound_pending = False
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._hover_sound_pending:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self._hover_sound_pending = False
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
if (
|
||||
QApplication.activeWindow() is not None
|
||||
and event.reason() not in (
|
||||
Qt.FocusReason.ActiveWindowFocusReason,
|
||||
Qt.FocusReason.MouseFocusReason,
|
||||
)
|
||||
):
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
@@ -1049,7 +1001,6 @@ class GameCard(QFrame):
|
||||
"anticheat_slug": self.anticheat_slug,
|
||||
"ppdb_id": self.ppdb_id,
|
||||
"ppdb_rating": self.ppdb_rating,
|
||||
"protondb_appid": self.protondb_appid,
|
||||
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
|
||||
}
|
||||
self.select_callback(game_data)
|
||||
|
||||
@@ -540,7 +540,7 @@ class GameLibraryManager:
|
||||
for card in self.gamesListWidget.findChildren(GameCard):
|
||||
if card.isVisible() and card.isEnabled():
|
||||
self._focus_first_card_after_update = False
|
||||
card.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
|
||||
card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
if self.gamesScrollArea is not None:
|
||||
self.gamesScrollArea.ensureWidgetVisible(card, 50, 50)
|
||||
return
|
||||
@@ -710,6 +710,7 @@ class GameLibraryManager:
|
||||
build_search_items(
|
||||
games,
|
||||
lambda game: game[0],
|
||||
lambda game: game[1],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SDL_INIT_GAMEPAD = 0x00002000
|
||||
SDL_GAMEPAD_TYPE_STANDARD = 1
|
||||
SDL_GAMEPAD_TYPE_XBOX360 = 2
|
||||
SDL_GAMEPAD_TYPE_XBOXONE = 3
|
||||
SDL_GAMEPAD_TYPE_PS3 = 4
|
||||
SDL_GAMEPAD_TYPE_PS4 = 5
|
||||
SDL_GAMEPAD_TYPE_PS5 = 6
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
|
||||
SDL_GAMEPAD_TYPE_GAMECUBE = 11
|
||||
SDL_GAMEPAD_TYPE_STEAM = 12
|
||||
SDL3_XBOX_LIKE_TYPES = {
|
||||
SDL_GAMEPAD_TYPE_STANDARD,
|
||||
SDL_GAMEPAD_TYPE_XBOX360,
|
||||
SDL_GAMEPAD_TYPE_XBOXONE,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
|
||||
SDL_GAMEPAD_TYPE_GAMECUBE,
|
||||
SDL_GAMEPAD_TYPE_STEAM,
|
||||
}
|
||||
SDL3_PLAYSTATION_TYPES = {
|
||||
SDL_GAMEPAD_TYPE_PS3,
|
||||
SDL_GAMEPAD_TYPE_PS4,
|
||||
SDL_GAMEPAD_TYPE_PS5,
|
||||
}
|
||||
|
||||
SDL_GAMEPAD_BUTTON_SOUTH = 0
|
||||
SDL_GAMEPAD_BUTTON_EAST = 1
|
||||
SDL_GAMEPAD_BUTTON_WEST = 2
|
||||
SDL_GAMEPAD_BUTTON_NORTH = 3
|
||||
SDL_GAMEPAD_BUTTON_BACK = 4
|
||||
SDL_GAMEPAD_BUTTON_GUIDE = 5
|
||||
SDL_GAMEPAD_BUTTON_START = 6
|
||||
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
|
||||
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
|
||||
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
|
||||
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
|
||||
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
|
||||
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
|
||||
SDL_GAMEPAD_AXIS_LEFTX = 0
|
||||
SDL_GAMEPAD_AXIS_LEFTY = 1
|
||||
SDL_GAMEPAD_AXIS_RIGHTX = 2
|
||||
SDL_GAMEPAD_AXIS_RIGHTY = 3
|
||||
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
|
||||
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
|
||||
|
||||
|
||||
class GamepadType(Enum):
|
||||
XBOX = "Xbox"
|
||||
PLAYSTATION = "PlayStation"
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
|
||||
def _get_sdl3_error(sdl: ctypes.CDLL) -> str:
|
||||
error = sdl.SDL_GetError()
|
||||
if not error:
|
||||
return ""
|
||||
return error.decode(errors="replace")
|
||||
|
||||
|
||||
def _configure_sdl3_gamepad_api(sdl: ctypes.CDLL) -> None:
|
||||
sdl.SDL_InitSubSystem.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_InitSubSystem.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepads.argtypes = [ctypes.POINTER(ctypes.c_int)]
|
||||
sdl.SDL_GetGamepads.restype = ctypes.POINTER(ctypes.c_uint32)
|
||||
sdl.SDL_GetGamepadTypeForID.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_GetGamepadTypeForID.restype = ctypes.c_int
|
||||
sdl.SDL_GetGamepadNameForID.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_GetGamepadNameForID.restype = ctypes.c_char_p
|
||||
sdl.SDL_OpenGamepad.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_OpenGamepad.restype = ctypes.c_void_p
|
||||
sdl.SDL_CloseGamepad.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_CloseGamepad.restype = None
|
||||
sdl.SDL_GamepadConnected.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_GamepadConnected.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepadName.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_GetGamepadName.restype = ctypes.c_char_p
|
||||
sdl.SDL_GetGamepadButton.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
sdl.SDL_GetGamepadButton.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepadAxis.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
sdl.SDL_GetGamepadAxis.restype = ctypes.c_int16
|
||||
sdl.SDL_UpdateGamepads.argtypes = []
|
||||
sdl.SDL_UpdateGamepads.restype = None
|
||||
sdl.SDL_PumpEvents.argtypes = []
|
||||
sdl.SDL_PumpEvents.restype = None
|
||||
sdl.SDL_QuitSubSystem.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_QuitSubSystem.restype = None
|
||||
sdl.SDL_GetError.argtypes = []
|
||||
sdl.SDL_GetError.restype = ctypes.c_char_p
|
||||
sdl.SDL_free.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_free.restype = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_sdl3() -> ctypes.CDLL | None:
|
||||
library_names = (
|
||||
ctypes.util.find_library("SDL3"),
|
||||
"libSDL3.so.0",
|
||||
"libSDL3.so",
|
||||
)
|
||||
for library_name in library_names:
|
||||
if not library_name:
|
||||
continue
|
||||
try:
|
||||
sdl = ctypes.CDLL(library_name)
|
||||
_configure_sdl3_gamepad_api(sdl)
|
||||
except (AttributeError, OSError) as e:
|
||||
logger.debug("Failed to load SDL3 from %s: %s", library_name, e)
|
||||
continue
|
||||
if sdl.SDL_InitSubSystem(SDL_INIT_GAMEPAD):
|
||||
return sdl
|
||||
logger.debug("Failed to initialize SDL3 gamepad subsystem: %s", _get_sdl3_error(sdl))
|
||||
return None
|
||||
|
||||
|
||||
def _decode_sdl3_name(name: bytes | None) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
return name.decode(errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SDLGamepad:
|
||||
sdl: ctypes.CDLL
|
||||
controller: ctypes.c_void_p
|
||||
name: str
|
||||
path: str
|
||||
instance_id: int
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self.sdl.SDL_CloseGamepad(self.controller)
|
||||
except (AttributeError, OSError) as e:
|
||||
logger.debug("Failed to close SDL gamepad: %s", e)
|
||||
|
||||
def connected(self) -> bool:
|
||||
return bool(self.sdl.SDL_GamepadConnected(self.controller))
|
||||
|
||||
def update(self) -> None:
|
||||
self.sdl.SDL_UpdateGamepads()
|
||||
|
||||
def get_button(self, button: int) -> int:
|
||||
return int(self.sdl.SDL_GetGamepadButton(self.controller, button))
|
||||
|
||||
def get_axis(self, axis: int) -> int:
|
||||
return int(self.sdl.SDL_GetGamepadAxis(self.controller, axis))
|
||||
|
||||
|
||||
def find_gamepad() -> SDLGamepad | None:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is None:
|
||||
return None
|
||||
sdl.SDL_PumpEvents()
|
||||
sdl.SDL_UpdateGamepads()
|
||||
count = ctypes.c_int()
|
||||
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
|
||||
if not gamepads:
|
||||
return None
|
||||
try:
|
||||
return _open_first_sdl3_gamepad(sdl, gamepads, count.value)
|
||||
finally:
|
||||
sdl.SDL_free(gamepads)
|
||||
|
||||
|
||||
def _open_first_sdl3_gamepad(
|
||||
sdl: ctypes.CDLL,
|
||||
gamepads: Any,
|
||||
count: int,
|
||||
) -> SDLGamepad | None:
|
||||
for index in range(count):
|
||||
instance_id = int(gamepads[index])
|
||||
gamepad = sdl.SDL_OpenGamepad(instance_id)
|
||||
if not gamepad:
|
||||
logger.debug("Skipping SDL3 gamepad %s: %s", instance_id, _get_sdl3_error(sdl))
|
||||
continue
|
||||
return SDLGamepad(
|
||||
sdl=sdl,
|
||||
controller=gamepad,
|
||||
name=_decode_sdl3_name(sdl.SDL_GetGamepadName(gamepad)),
|
||||
path=f"sdl3-gamepad:{instance_id}",
|
||||
instance_id=instance_id,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def detect_gamepad_type(gamepad: SDLGamepad) -> GamepadType:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is None:
|
||||
return GamepadType.XBOX
|
||||
count = ctypes.c_int()
|
||||
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
|
||||
if not gamepads:
|
||||
return GamepadType.XBOX
|
||||
try:
|
||||
gamepad_type = _find_sdl3_gamepad_type(sdl, gamepads, count.value, gamepad.name)
|
||||
finally:
|
||||
sdl.SDL_free(gamepads)
|
||||
return gamepad_type or GamepadType.XBOX
|
||||
|
||||
|
||||
def _find_sdl3_gamepad_type(
|
||||
sdl: ctypes.CDLL,
|
||||
gamepads: Any,
|
||||
count: int,
|
||||
target_name: str,
|
||||
) -> GamepadType | None:
|
||||
for index in range(count):
|
||||
gamepad_id = gamepads[index]
|
||||
sdl_name = _decode_sdl3_name(sdl.SDL_GetGamepadNameForID(gamepad_id))
|
||||
if count > 1 and sdl_name.casefold() != target_name.casefold():
|
||||
continue
|
||||
gamepad_type = _gamepad_type_from_sdl3_value(sdl.SDL_GetGamepadTypeForID(gamepad_id))
|
||||
if gamepad_type is not None:
|
||||
return gamepad_type
|
||||
return None
|
||||
|
||||
|
||||
def _gamepad_type_from_sdl3_value(sdl_type: int) -> GamepadType | None:
|
||||
if sdl_type in SDL3_PLAYSTATION_TYPES:
|
||||
return GamepadType.PLAYSTATION
|
||||
if sdl_type in SDL3_XBOX_LIKE_TYPES:
|
||||
return GamepadType.XBOX
|
||||
return None
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is not None:
|
||||
sdl.SDL_QuitSubSystem(SDL_INIT_GAMEPAD)
|
||||
@@ -1,675 +0,0 @@
|
||||
"""GOG authentication, library and gogdl process helpers."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
|
||||
from portprotonqt.localization import get_metadata_language
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
GOG_LOGIN_URL = (
|
||||
"https://auth.gog.com/auth?client_id=46899977096215655&"
|
||||
"redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&"
|
||||
"response_type=code&layout=galaxy"
|
||||
)
|
||||
GOGDL_RELEASE_URL = "https://api.github.com/repos/Heroic-Games-Launcher/heroic-gogdl/releases/latest"
|
||||
GOGDL_AUTH_TIMEOUT = 60
|
||||
GOG_USER_TIMEOUT = 15
|
||||
GOG_METADATA_WORKERS = 4
|
||||
GOG_SETUP_MARKER_VERSION = 1
|
||||
GOG_SETUP_TIMEOUT = 600
|
||||
GOG_PRODUCT_LOCALES = {
|
||||
"bg": "bg-BG", "cs": "cs-CZ", "da": "da-DK", "de": "de-DE",
|
||||
"el": "el-GR", "en": "en-US", "es": "es-ES", "fi": "fi-FI",
|
||||
"fr": "fr-FR", "hu": "hu-HU", "it": "it-IT", "ja": "ja-JP",
|
||||
"ko": "ko-KR", "nl": "nl-NL", "no": "no-NO", "pl": "pl-PL",
|
||||
"pt": "pt-BR", "ro": "ro-RO", "ru": "ru-RU", "sv": "sv-SE",
|
||||
"th": "th-TH", "tr": "tr-TR", "uk": "uk-UA", "zh": "zh-Hans",
|
||||
"zh_hant": "zh-Hant",
|
||||
}
|
||||
|
||||
|
||||
class GOGAPI:
|
||||
"""Provide the local GOG backend used by the Qt UI."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
self.data_dir = data_home / "PortProtonQt" / "gog"
|
||||
self.auth_path = self.data_dir / "auth.json"
|
||||
self.account_path = self.data_dir / "account.json"
|
||||
self.config_dir = self.data_dir / "gogdl"
|
||||
self.library_path = self.data_dir / "library.json"
|
||||
self.installed_path = self.data_dir / "installed.json"
|
||||
self.sizes_path = self.data_dir / "sizes.json"
|
||||
self.games_dir = Path.home() / "Games"
|
||||
|
||||
def get_gogdl_path(self) -> str | None:
|
||||
"""Return an available gogdl executable."""
|
||||
bundled = self.data_dir / "bin/gogdl"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
return str(bundled)
|
||||
return shutil.which("gogdl")
|
||||
|
||||
def build_command(self, arguments: list[str]) -> list[str]:
|
||||
"""Build a gogdl command with private configuration paths."""
|
||||
gogdl = self.get_gogdl_path()
|
||||
if not gogdl:
|
||||
raise FileNotFoundError("gogdl executable not found")
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.data_dir.chmod(0o700)
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return [gogdl, "--auth-config-path", str(self.auth_path), *arguments]
|
||||
|
||||
def ensure_gogdl(self) -> str:
|
||||
"""Download and verify the latest official Heroic gogdl binary."""
|
||||
existing = self.get_gogdl_path()
|
||||
if existing:
|
||||
return existing
|
||||
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(platform.machine())
|
||||
if not architecture:
|
||||
raise OSError(f"Unsupported gogdl architecture: {platform.machine()}")
|
||||
response = requests.get(GOGDL_RELEASE_URL, timeout=15)
|
||||
response.raise_for_status()
|
||||
asset_name = f"gogdl_linux_{architecture}"
|
||||
asset = next(
|
||||
(item for item in response.json().get("assets", []) if item.get("name") == asset_name),
|
||||
None,
|
||||
)
|
||||
if not asset:
|
||||
raise OSError(f"gogdl release asset not found: {asset_name}")
|
||||
return self._download_gogdl_asset(asset)
|
||||
|
||||
def _download_gogdl_asset(self, asset: dict) -> str:
|
||||
target = self.data_dir / "bin/gogdl"
|
||||
temporary = target.with_suffix(".part")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with requests.get(asset["browser_download_url"], stream=True, timeout=30) as response:
|
||||
response.raise_for_status()
|
||||
with open(temporary, "wb") as output:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
expected = str(asset.get("digest", "")).removeprefix("sha256:")
|
||||
if not expected or digest.hexdigest() != expected:
|
||||
raise OSError("gogdl checksum verification failed")
|
||||
temporary.chmod(0o755)
|
||||
temporary.replace(target)
|
||||
return str(target)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def get_environment(self) -> dict[str, str]:
|
||||
"""Return the environment required by gogdl."""
|
||||
environment = os.environ.copy()
|
||||
environment["GOGDL_CONFIG_PATH"] = str(self.config_dir)
|
||||
return environment
|
||||
|
||||
def authenticate(self, code: str) -> tuple[bool, str]:
|
||||
"""Exchange an OAuth code through gogdl."""
|
||||
self.ensure_gogdl()
|
||||
result = subprocess.run(
|
||||
self.build_command(["auth", "--code", code]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=GOGDL_AUTH_TIMEOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.decode(errors="replace").strip()
|
||||
logger.error("gogdl authentication failed: %s", error)
|
||||
return False, error or f"gogdl exited with code {result.returncode}"
|
||||
try:
|
||||
data = orjson.loads(result.stdout)
|
||||
except orjson.JSONDecodeError:
|
||||
return False, "gogdl returned an invalid authentication response"
|
||||
if not isinstance(data, dict) or data.get("error"):
|
||||
return False, "GOG rejected the authorization code"
|
||||
self._cache_account(data)
|
||||
if self.auth_path.is_file():
|
||||
self.auth_path.chmod(0o600)
|
||||
return True, ""
|
||||
|
||||
def get_credentials(self) -> dict:
|
||||
"""Return refreshed GOG credentials through gogdl."""
|
||||
try:
|
||||
self.ensure_gogdl()
|
||||
result = subprocess.run(
|
||||
self.build_command(["auth"]), env=self.get_environment(),
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
data = orjson.loads(result.stdout) if result.returncode == 0 else {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (FileNotFoundError, orjson.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Return whether gogdl provides usable account credentials."""
|
||||
credentials = self.get_credentials()
|
||||
return bool(credentials.get("access_token") and credentials.get("user_id"))
|
||||
|
||||
def get_account_name(self) -> str:
|
||||
"""Return cached GOG account name or user ID."""
|
||||
account = self._load_json(self.account_path, {})
|
||||
if isinstance(account, dict) and account.get("username"):
|
||||
return str(account["username"])
|
||||
credentials = self._load_json(self.auth_path, {})
|
||||
if isinstance(credentials, dict) and credentials.get("user_id"):
|
||||
return str(credentials["user_id"])
|
||||
return ""
|
||||
|
||||
def refresh_account_name(self) -> str:
|
||||
"""Fetch and cache the current GOG account name."""
|
||||
return self._cache_account(self.get_credentials())
|
||||
|
||||
def _cache_account(self, credentials: dict) -> str:
|
||||
"""Fetch GOG account data for credentials and return its username."""
|
||||
user_id = credentials.get("user_id")
|
||||
access_token = credentials.get("access_token")
|
||||
if not user_id or not access_token:
|
||||
return ""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"https://users.gog.com/users/{user_id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=GOG_USER_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
account = response.json()
|
||||
except requests.RequestException as error:
|
||||
logger.warning("Failed to load GOG account name: %s", error)
|
||||
return ""
|
||||
if not isinstance(account, dict) or not account.get("username"):
|
||||
return ""
|
||||
self._save_json(self.account_path, account)
|
||||
return str(account["username"])
|
||||
|
||||
def logout(self) -> bool:
|
||||
"""Remove local GOG credentials and account library cache."""
|
||||
try:
|
||||
self.auth_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.error("Failed to remove GOG credentials: %s", error)
|
||||
return False
|
||||
try:
|
||||
self.account_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to remove cached GOG account name: %s", error)
|
||||
self.clear_library_cache()
|
||||
logger.info("GOG account disconnected")
|
||||
return True
|
||||
|
||||
def refresh_library(
|
||||
self, progress_callback: Callable[[int, int], None] | None = None
|
||||
) -> list[dict]:
|
||||
"""Fetch and cache the user's GOG library."""
|
||||
credentials = self.get_credentials()
|
||||
token = credentials.get("access_token")
|
||||
user_id = credentials.get("user_id")
|
||||
if not token or not user_id:
|
||||
return []
|
||||
entries = self._get_library_entries(str(user_id), str(token))
|
||||
gog_entries = [entry for entry in entries if entry.get("platform_id") == "gog"]
|
||||
games = self._get_games_parallel(gog_entries, str(token), progress_callback)
|
||||
self._save_json(self.library_path, games)
|
||||
return games
|
||||
|
||||
def _get_games_parallel(
|
||||
self, entries: list[dict], token: str,
|
||||
progress_callback: Callable[[int, int], None] | None,
|
||||
) -> list[dict]:
|
||||
cached = {str(game.get("app_id")): game for game in self.load_library()}
|
||||
games = []
|
||||
worker_count = min(GOG_METADATA_WORKERS, os.cpu_count() or 1)
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||
futures = {executor.submit(self._get_game, entry, token): entry for entry in entries}
|
||||
for completed, future in enumerate(as_completed(futures), 1):
|
||||
entry = futures[future]
|
||||
app_id = str(entry.get("external_id", ""))
|
||||
try:
|
||||
game = future.result()
|
||||
except requests.RequestException as error:
|
||||
logger.warning("Failed to load GOG metadata for %s: %s", app_id, error)
|
||||
game = cached.get(app_id, {})
|
||||
if game:
|
||||
game.setdefault("steam_appid", "")
|
||||
games.append(game)
|
||||
if progress_callback:
|
||||
progress_callback(completed, len(entries))
|
||||
return games
|
||||
|
||||
def load_library(self) -> list[dict]:
|
||||
"""Load the cached GOG library."""
|
||||
data = self._load_json(self.library_path, [])
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def clear_library_cache(self) -> None:
|
||||
"""Remove cached GOG covers and descriptions."""
|
||||
try:
|
||||
self.library_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to clear GOG library cache: %s", error)
|
||||
|
||||
def load_installed(self) -> dict[str, dict]:
|
||||
"""Load installed GOG game records."""
|
||||
data = self._load_json(self.installed_path, {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def save_installed_game(self, app_id: str, game: dict) -> None:
|
||||
"""Persist one installed GOG game record."""
|
||||
installed = self.load_installed()
|
||||
installed[app_id] = game
|
||||
self._save_json(self.installed_path, installed)
|
||||
|
||||
def remove_installed_game(self, app_id: str) -> None:
|
||||
"""Remove one installed GOG game record."""
|
||||
installed = self.load_installed()
|
||||
installed.pop(app_id, None)
|
||||
self._save_json(self.installed_path, installed)
|
||||
|
||||
def get_cached_download_sizes(self, app_id: str) -> tuple[int, int] | None:
|
||||
"""Return cached download and disk sizes for a game."""
|
||||
cache = self._load_json(self.sizes_path, {})
|
||||
if not isinstance(cache, dict):
|
||||
return None
|
||||
cached = cache.get(app_id)
|
||||
if not isinstance(cached, list) or len(cached) != 2:
|
||||
return None
|
||||
return int(cached[0]), int(cached[1])
|
||||
|
||||
def save_download_sizes(self, app_id: str, sizes: tuple[int, int]) -> None:
|
||||
"""Cache download and disk sizes for a game."""
|
||||
cached = self._load_json(self.sizes_path, {})
|
||||
if not isinstance(cached, dict):
|
||||
cached = {}
|
||||
cached[app_id] = list(sizes)
|
||||
self._save_json(self.sizes_path, cached)
|
||||
|
||||
def is_game_installed(
|
||||
self, app_id: str, installed_games: dict[str, dict] | None = None
|
||||
) -> bool:
|
||||
"""Return whether a complete gogdl installation is available."""
|
||||
return self.get_installed_path(app_id, installed_games) is not None
|
||||
|
||||
def get_installed_path(
|
||||
self, app_id: str, installed_games: dict[str, dict] | None = None
|
||||
) -> Path | None:
|
||||
"""Return a recorded or discoverable gogdl installation path."""
|
||||
records = installed_games if installed_games is not None else self.load_installed()
|
||||
installed = records.get(app_id, {})
|
||||
install_path = Path(str(installed.get("install_path", "")))
|
||||
if (install_path / f"goggame-{app_id}.info").is_file():
|
||||
return install_path
|
||||
return self.find_install_path(app_id, self.games_dir)
|
||||
|
||||
def get_install_path(self, app_id: str, title: str) -> Path:
|
||||
"""Return the default parent directory for GOG installations."""
|
||||
return self.games_dir
|
||||
|
||||
def find_install_path(self, app_id: str, parent: Path) -> Path | None:
|
||||
"""Find the directory created by gogdl for an installed game."""
|
||||
metadata_name = f"goggame-{app_id}.info"
|
||||
if (parent / metadata_name).is_file():
|
||||
return parent
|
||||
try:
|
||||
return next(
|
||||
(path.parent for path in parent.glob(f"*/{metadata_name}")),
|
||||
None,
|
||||
)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to inspect GOG installation path %s: %s", parent, error)
|
||||
return None
|
||||
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
"""Return the primary executable from gogdl installation metadata."""
|
||||
primary, install_path = self._get_primary_task(app_id)
|
||||
if primary is None or install_path is None:
|
||||
return None
|
||||
target = install_path
|
||||
parts = str(primary.get("path", "")).replace("\\", "/").split("/")
|
||||
for part in parts:
|
||||
exact_path = target / part
|
||||
if exact_path.exists():
|
||||
target = exact_path
|
||||
continue
|
||||
try:
|
||||
target = next(
|
||||
child for child in target.iterdir()
|
||||
if child.name.casefold() == part.casefold()
|
||||
)
|
||||
except (OSError, StopIteration):
|
||||
return None
|
||||
return str(target) if target.is_file() else None
|
||||
|
||||
def ensure_launch_parameters(self, app_id: str) -> None:
|
||||
"""Add GOG task arguments to the executable PPDB when absent."""
|
||||
primary, install_path = self._get_primary_task(app_id)
|
||||
target = self.get_launch_target(app_id)
|
||||
if primary is None or install_path is None or not target:
|
||||
return
|
||||
arguments = str(primary.get("arguments", "")).replace("\r", " ").replace("\n", " ").strip()
|
||||
if not arguments:
|
||||
return
|
||||
ppdb_path = Path(f"{target}.ppdb")
|
||||
try:
|
||||
current = ppdb_path.read_text(encoding="utf-8") if ppdb_path.exists() else ""
|
||||
base_value = arguments.replace('"', "").replace("\\", "/")
|
||||
safe_value = base_value.replace("$", "\\$").replace("`", "\\`")
|
||||
launch_line = f'export LAUNCH_PARAMETERS="{safe_value}"'
|
||||
lines = current.splitlines()
|
||||
existing = next(
|
||||
(line for line in lines if line.startswith("export LAUNCH_PARAMETERS=")),
|
||||
"",
|
||||
)
|
||||
if existing:
|
||||
value = existing.removeprefix('export LAUNCH_PARAMETERS="').removesuffix('"')
|
||||
comparable = re.sub(r"\\+", "/", value.replace('\\"', "").replace('"', ""))
|
||||
if comparable != base_value:
|
||||
return
|
||||
lines[lines.index(existing)] = launch_line
|
||||
ppdb_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return
|
||||
separator = "" if not current or current.endswith("\n") else "\n"
|
||||
ppdb_path.write_text(
|
||||
f"{current}{separator}{launch_line}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to add GOG launch parameters to %s: %s", ppdb_path, error)
|
||||
|
||||
def install_support(
|
||||
self, app_id: str, start_command: list[str],
|
||||
process_callback: Callable[[subprocess.Popen], None] | None = None,
|
||||
) -> None:
|
||||
"""Install GOG support instructions through the common ISI redist."""
|
||||
manifest = self._load_json(
|
||||
self.config_dir / "heroic_gogdl" / "manifests" / app_id, {}
|
||||
)
|
||||
install_path = self.get_installed_path(app_id)
|
||||
if not isinstance(manifest, dict) or not manifest.get("scriptInterpreter"):
|
||||
return
|
||||
if install_path is None:
|
||||
return
|
||||
setup_marker = install_path / (
|
||||
f".gogsetup-v{GOG_SETUP_MARKER_VERSION}-{app_id}"
|
||||
)
|
||||
if setup_marker.is_file():
|
||||
return
|
||||
redist_path = self.data_dir / "redist" / "gog"
|
||||
interpreter = redist_path / "__redist" / "ISI" / "scriptinterpreter.exe"
|
||||
if not interpreter.is_file():
|
||||
result = subprocess.run(
|
||||
self.build_command([
|
||||
"redist", "--ids", "ISI", "--path", str(redist_path),
|
||||
]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=GOG_SETUP_TIMEOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.decode(errors="replace").strip()
|
||||
raise OSError(error or "Failed to download GOG ISI redist")
|
||||
if not interpreter.is_file():
|
||||
raise FileNotFoundError(f"GOG script interpreter not found: {interpreter}")
|
||||
support_path = self.config_dir / "heroic_gogdl" / "gog-support" / app_id
|
||||
setup_path = self.data_dir / "setup" / app_id
|
||||
setup_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if setup_path.is_symlink() and setup_path.resolve() != install_path.resolve():
|
||||
setup_path.unlink()
|
||||
if not setup_path.exists():
|
||||
setup_path.symlink_to(install_path, target_is_directory=True)
|
||||
if not setup_path.is_symlink():
|
||||
raise OSError(f"GOG setup path is not a symbolic link: {setup_path}")
|
||||
arguments = self._get_support_arguments(
|
||||
app_id, manifest, setup_path, support_path
|
||||
)
|
||||
target = self.get_launch_target(app_id)
|
||||
if not target:
|
||||
raise FileNotFoundError(f"GOG launch target not found: {app_id}")
|
||||
launch_parameters = " ".join(arguments).replace("$", "\\$").replace("`", "\\`")
|
||||
self._write_support_ppdb(interpreter, target, launch_parameters)
|
||||
self._run_support_process(
|
||||
[*start_command, str(interpreter)], install_path, process_callback
|
||||
)
|
||||
setup_marker.touch()
|
||||
|
||||
@staticmethod
|
||||
def _run_support_process(
|
||||
command: list[str], install_path: Path,
|
||||
process_callback: Callable[[subprocess.Popen], None] | None,
|
||||
) -> None:
|
||||
"""Run GOG setup and expose its process for UI cancellation."""
|
||||
process = subprocess.Popen(
|
||||
command, cwd=install_path, start_new_session=True,
|
||||
)
|
||||
if process_callback:
|
||||
process_callback(process)
|
||||
try:
|
||||
return_code = process.wait(timeout=GOG_SETUP_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise
|
||||
if return_code != 0:
|
||||
raise subprocess.CalledProcessError(return_code, command)
|
||||
|
||||
@staticmethod
|
||||
def _write_support_ppdb(
|
||||
interpreter: Path, target: str, launch_parameters: str,
|
||||
) -> None:
|
||||
"""Inherit game settings while replacing support launch commands."""
|
||||
target_ppdb = Path(f"{target}.ppdb")
|
||||
ppdb_lines = (
|
||||
target_ppdb.read_text(encoding="utf-8").splitlines()
|
||||
if target_ppdb.is_file() else []
|
||||
)
|
||||
replaced_keys = (
|
||||
"export LAUNCH_PARAMETERS=", "export PW_RUN_AFTER_EXE=",
|
||||
"export FILE_SHA256SUM=",
|
||||
)
|
||||
inherited = [
|
||||
line for line in ppdb_lines
|
||||
if not line.startswith(replaced_keys)
|
||||
]
|
||||
inherited.extend([
|
||||
f'export LAUNCH_PARAMETERS="{launch_parameters}"',
|
||||
])
|
||||
Path(f"{interpreter}.ppdb").write_text(
|
||||
"\n".join(inherited) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def needs_support_setup(self, app_id: str) -> bool:
|
||||
"""Return whether GOG setup instructions still need to run."""
|
||||
install_path = self.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
return False
|
||||
manifest = self._load_json(
|
||||
self.config_dir / "heroic_gogdl" / "manifests" / app_id, {}
|
||||
)
|
||||
marker = install_path / f".gogsetup-v{GOG_SETUP_MARKER_VERSION}-{app_id}"
|
||||
return bool(
|
||||
isinstance(manifest, dict)
|
||||
and manifest.get("scriptInterpreter")
|
||||
and not marker.is_file()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_support_arguments(
|
||||
app_id: str, manifest: dict, install_path: Path, support_path: Path
|
||||
) -> list[str]:
|
||||
product = next(
|
||||
(
|
||||
item for item in manifest.get("products", [])
|
||||
if str(item.get("productId", "")) == app_id
|
||||
),
|
||||
{},
|
||||
)
|
||||
product_id = str(product.get("productId", app_id))
|
||||
wine_install_path = "Z:" + str(install_path).replace("/", "\\")
|
||||
wine_support_path = "Z:" + str(support_path).replace("/", "\\")
|
||||
return [
|
||||
"/VERYSILENT", f"/DIR={wine_install_path}", "/Language=English",
|
||||
"/LANG=English", f"/ProductId={product_id}", "/galaxyclient",
|
||||
f"/buildId={manifest.get('buildId', '')}",
|
||||
f"/versionName={manifest.get('versionName', '')}",
|
||||
"/lang-code=en-US", f"/supportDir={wine_support_path}",
|
||||
"/nodesktopshorctut", "/nodesktopshortcut",
|
||||
]
|
||||
|
||||
def _get_primary_task(self, app_id: str) -> tuple[dict | None, Path | None]:
|
||||
"""Return the primary GOG launch task and installation path."""
|
||||
install_path = self.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
return None, None
|
||||
info = self._load_json(install_path / f"goggame-{app_id}.info", {})
|
||||
tasks = info.get("playTasks", []) if isinstance(info, dict) else []
|
||||
primary = next((task for task in tasks if task.get("isPrimary")), None)
|
||||
if primary is None and tasks:
|
||||
primary = tasks[0]
|
||||
if not isinstance(primary, dict) or primary.get("type") != "FileTask":
|
||||
return None, install_path
|
||||
return primary, install_path
|
||||
|
||||
def _get_library_entries(self, user_id: str, token: str) -> list[dict]:
|
||||
entries = []
|
||||
page_token = ""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
while True:
|
||||
url = f"https://galaxy-library.gog.com/users/{user_id}/releases"
|
||||
response = requests.get(url, headers=headers, params={"page_token": page_token} if page_token else {}, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
entries.extend(data.get("items", []))
|
||||
page_token = data.get("next_page_token", "")
|
||||
if not page_token:
|
||||
return entries
|
||||
|
||||
def _get_game(self, entry: dict, token: str) -> dict:
|
||||
if entry.get("platform_id") != "gog" or not entry.get("external_id"):
|
||||
return {}
|
||||
app_id = str(entry["external_id"])
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
if entry.get("certificate"):
|
||||
headers["X-GOG-Library-Cert"] = str(entry["certificate"])
|
||||
response = requests.get(
|
||||
f"https://gamesdb.gog.com/platforms/gog/external_releases/{app_id}",
|
||||
headers=headers, timeout=15,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
game = data.get("game", {})
|
||||
if not game.get("visible_in_library", True):
|
||||
return {}
|
||||
steam_release = next(
|
||||
(
|
||||
release for release in game.get("releases", [])
|
||||
if release.get("platform_id") == "steam"
|
||||
),
|
||||
{},
|
||||
)
|
||||
description = self._localized_value(data.get("summary"))
|
||||
language = get_metadata_language().lower()
|
||||
try:
|
||||
product_response = requests.get(
|
||||
f"https://api.gog.com/products/{app_id}",
|
||||
params={
|
||||
"expand": "description",
|
||||
"locale": GOG_PRODUCT_LOCALES.get(language, language),
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
product_response.raise_for_status()
|
||||
product_description = product_response.json().get("description", {})
|
||||
localized_lead = str(product_description.get("lead") or "")
|
||||
if localized_lead:
|
||||
paragraphs = re.split(
|
||||
r"(?:<br\s*/?>\s*){2,}", localized_lead, flags=re.IGNORECASE
|
||||
)
|
||||
paragraph_count = (
|
||||
2 if re.match(r"\s*<(?:b|strong)>", paragraphs[0], re.IGNORECASE)
|
||||
else 1
|
||||
)
|
||||
description = "<br><br>".join(
|
||||
paragraph.strip() for paragraph in paragraphs[:paragraph_count]
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
logger.warning(
|
||||
"Failed to load localized GOG description for %s: %s",
|
||||
app_id, error,
|
||||
)
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"title": (
|
||||
self._localized_value(data.get("title"))
|
||||
or self._localized_value(game.get("title"))
|
||||
or app_id
|
||||
).strip(),
|
||||
"description": description,
|
||||
"cover": self._format_image(game.get("vertical_cover") or game.get("logo") or game.get("background")),
|
||||
"steam_appid": str(steam_release.get("external_id", "")),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _localized_value(values: dict | None) -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ""
|
||||
language = get_metadata_language().lower()
|
||||
for key in (language, language.replace("_", "-")):
|
||||
if values.get(key):
|
||||
return str(values[key])
|
||||
localized = next(
|
||||
(value for key, value in values.items() if key.lower().startswith(language)),
|
||||
None,
|
||||
)
|
||||
return str(localized or values.get("*", ""))
|
||||
|
||||
@staticmethod
|
||||
def _format_image(image: dict | None) -> str:
|
||||
template = image.get("url_format", "") if isinstance(image, dict) else ""
|
||||
return template.replace("{formatter}", "").replace("{ext}", "jpg")
|
||||
|
||||
@staticmethod
|
||||
def parse_download_sizes(output: bytes) -> tuple[int, int]:
|
||||
"""Return download and disk sizes from gogdl info output."""
|
||||
data = orjson.loads(output)
|
||||
sizes = data.get("size", {}) if isinstance(data, dict) else {}
|
||||
languages = data.get("languages", []) if isinstance(data, dict) else []
|
||||
selected = [sizes.get("*", {})]
|
||||
if languages:
|
||||
selected.append(sizes.get(str(languages[0]), {}))
|
||||
download_size = sum(int(item.get("download_size", 0)) for item in selected)
|
||||
disk_size = sum(int(item.get("disk_size", 0)) for item in selected)
|
||||
return download_size, disk_size
|
||||
|
||||
@staticmethod
|
||||
def extract_auth_code(url: str) -> str:
|
||||
"""Extract the OAuth code from GOG's redirect URL."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.netloc != "embed.gog.com" or parsed.path != "/on_login_success":
|
||||
return ""
|
||||
values = parse_qs(parsed.query)
|
||||
return values.get("code", [""])[0]
|
||||
|
||||
@staticmethod
|
||||
def _load_json(path: Path, fallback):
|
||||
try:
|
||||
return orjson.loads(path.read_bytes())
|
||||
except (OSError, orjson.JSONDecodeError):
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _save_json(path: Path, data) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(orjson.dumps(data, option=orjson.OPT_INDENT_2))
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Unified icon extractor for NE and PE files."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import io
|
||||
import pefile
|
||||
@@ -15,22 +13,6 @@ RT_GROUP_ICON = 14
|
||||
THUMBNAIL_SIZE = 128
|
||||
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache"),
|
||||
)
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
return os.path.join(
|
||||
cache_home,
|
||||
"PortProtonQt",
|
||||
"images",
|
||||
"exe_icons",
|
||||
f"{icon_name}.png",
|
||||
)
|
||||
|
||||
|
||||
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
|
||||
"""
|
||||
Generates a thumbnail for an .exe file.
|
||||
|
||||
+542
-356
File diff suppressed because it is too large.
Load diff
@@ -42,12 +42,6 @@ msgstr ""
|
||||
msgid "Failed to update AppImage."
|
||||
msgstr ""
|
||||
|
||||
msgid "Run in logging mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Run in silent mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -148,9 +142,6 @@ msgstr ""
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Repair"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr ""
|
||||
|
||||
@@ -163,12 +154,6 @@ msgstr ""
|
||||
msgid "Add to Menu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -236,10 +221,6 @@ msgstr ""
|
||||
msgid "Desktop"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
@@ -316,6 +297,9 @@ msgstr ""
|
||||
msgid "Paste"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
|
||||
@@ -358,9 +342,6 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloads"
|
||||
msgstr ""
|
||||
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
@@ -434,15 +415,6 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (empty exec line)"
|
||||
msgstr ""
|
||||
|
||||
@@ -453,6 +425,12 @@ msgstr ""
|
||||
msgid "Failed to launch game: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Choose Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -757,12 +735,6 @@ msgstr ""
|
||||
msgid "To diagnose the problem, copy the ENTIRE log to the site:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
@@ -778,9 +750,6 @@ msgstr ""
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -793,12 +762,18 @@ msgstr ""
|
||||
msgid "Create Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
@@ -852,10 +827,10 @@ msgstr ""
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Browse…"
|
||||
msgid "Path to Executable:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgid "Browse…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Cover:"
|
||||
@@ -885,15 +860,6 @@ msgstr ""
|
||||
msgid "No cover selected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
@@ -921,6 +887,9 @@ msgstr ""
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear All"
|
||||
msgstr ""
|
||||
|
||||
@@ -984,6 +953,9 @@ msgstr ""
|
||||
msgid "Version WINE/Proton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr ""
|
||||
|
||||
@@ -1927,115 +1899,6 @@ msgstr ""
|
||||
msgid "Set output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Queued"
|
||||
msgstr ""
|
||||
|
||||
msgid "Completed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Now Downloading"
|
||||
msgstr ""
|
||||
|
||||
msgid "Waiting…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disk: "
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "Started at"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Store"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown error"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log out"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select GOG game folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2072,6 +1935,9 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2081,12 +1947,6 @@ msgstr ""
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Accounts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2099,6 +1959,9 @@ msgstr ""
|
||||
msgid "Tray Menu Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
@@ -2120,12 +1983,6 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
@@ -2147,9 +2004,6 @@ msgstr ""
|
||||
msgid "Auto update AppImage"
|
||||
msgstr ""
|
||||
|
||||
msgid "Integrate AppImage"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr ""
|
||||
|
||||
@@ -2216,14 +2070,6 @@ msgstr ""
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr ""
|
||||
|
||||
@@ -2261,6 +2107,9 @@ msgstr ""
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-07-28 16:30+0000\n"
|
||||
"PO-Revision-Date: 2026-07-11 07:06+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -18,11 +18,11 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.7.1\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Spiel beenden"
|
||||
msgstr "Das Spiel beenden"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
@@ -43,12 +43,6 @@ msgstr "Fehler"
|
||||
msgid "Failed to update AppImage."
|
||||
msgstr "Das AppImage konnte nicht aktualisiert werden."
|
||||
|
||||
msgid "Run in logging mode"
|
||||
msgstr "Mit Protokollierung ausführen"
|
||||
|
||||
msgid "Run in silent mode"
|
||||
msgstr "Ohne Protokollierung ausführen"
|
||||
|
||||
msgid "PortProton directory not found"
|
||||
msgstr "Portproton-Ordner nicht gefunden"
|
||||
|
||||
@@ -153,9 +147,6 @@ msgstr "Von PortProton löschen"
|
||||
msgid "Launch Game"
|
||||
msgstr "Spiel starten"
|
||||
|
||||
msgid "Repair"
|
||||
msgstr "Reparieren"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "Vom Desktop entfernen"
|
||||
|
||||
@@ -168,12 +159,6 @@ msgstr "Aus dem Menü entfernen"
|
||||
msgid "Add to Menu"
|
||||
msgstr "Zum Menü hinzufügen"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Löschen"
|
||||
|
||||
msgid "Import"
|
||||
msgstr "Import"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Spielordner öffnen"
|
||||
|
||||
@@ -245,10 +230,6 @@ msgstr "'{game_name}' wurde entfernt von {location}"
|
||||
msgid "Desktop"
|
||||
msgstr "Desktop"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Fehler beim Speichern der Benutzerdaten: {error}"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "Spielname und Ausführungspfad werden benötigt"
|
||||
|
||||
@@ -329,6 +310,9 @@ msgstr "Kopieren"
|
||||
msgid "Paste"
|
||||
msgstr "Einfügen"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Löschen"
|
||||
|
||||
msgid "Select All"
|
||||
msgstr "Alle auswählen"
|
||||
|
||||
@@ -371,9 +355,6 @@ msgstr "System"
|
||||
msgid "Themes"
|
||||
msgstr "Themen"
|
||||
|
||||
msgid "Downloads"
|
||||
msgstr "Downloads"
|
||||
|
||||
msgid "Warning"
|
||||
msgstr "Warnung"
|
||||
|
||||
@@ -445,15 +426,6 @@ msgstr "Abhängigkeiten installieren"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Notwendige 32-Bit-Software fehlen. Jetzt installieren?"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "PortProton-Startskript nicht gefunden"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "Das Spiel kann nicht gestartet werden, während ein anderes Spiel läuft"
|
||||
|
||||
msgid "Invalid command format (empty exec line)"
|
||||
msgstr "Ungültiges Befehlsformat (leere exec-Zeile)"
|
||||
|
||||
@@ -464,6 +436,12 @@ msgstr "Ungültiges Befehlsformat"
|
||||
msgid "Failed to launch game: {0}"
|
||||
msgstr "Spiel konnte nicht gestartet werden: {0}"
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "PortProton-Startskript nicht gefunden"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "Das Spiel kann nicht gestartet werden, während ein anderes Spiel läuft"
|
||||
|
||||
msgid "Choose Folder"
|
||||
msgstr "Ordner auswählen"
|
||||
|
||||
@@ -828,12 +806,6 @@ msgid "To diagnose the problem, copy the ENTIRE log to the site:"
|
||||
msgstr ""
|
||||
"Für die Problembehandlung, teile das GESAMTE Protokoll auf der Website mit:"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Herunterladen"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "Größe"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "LETZTER START"
|
||||
|
||||
@@ -847,10 +819,7 @@ msgid "MAIN + SIDES"
|
||||
msgstr "HAUPTSPIEL+DLC"
|
||||
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr "GESAMT"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Installieren"
|
||||
msgstr "VOLLSTÄNDIG"
|
||||
|
||||
msgid "Open Folder"
|
||||
msgstr "Ordner öffnen"
|
||||
@@ -864,12 +833,18 @@ msgstr "Einstellungen"
|
||||
msgid "Create Log"
|
||||
msgstr "Protokoll erstellen"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Installieren"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Ansicht öffnen"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Neuinstallation"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Protokollierung beenden"
|
||||
|
||||
@@ -923,12 +898,12 @@ msgstr "Start {0}"
|
||||
msgid "Name:"
|
||||
msgstr "Titel:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "Ansicht…"
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr "Pfad zur ausführbaren Datei:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "Ansicht…"
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr "Eigenes Titelbild:"
|
||||
|
||||
@@ -956,17 +931,6 @@ msgstr "Titelbild wird heruntergeladen…"
|
||||
msgid "No cover selected"
|
||||
msgstr "Kein Titelbild ausgewählt"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr "Kompatibilitätsbericht"
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
"Das Programm wurde beendet. Kompatibilitätsdetails wurden automatisch "
|
||||
"erfasst."
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
@@ -994,8 +958,11 @@ msgstr "Speichern"
|
||||
msgid "Search"
|
||||
msgstr "Suchen"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Herunterladen"
|
||||
|
||||
msgid "Clear All"
|
||||
msgstr "Auswahl aufheben"
|
||||
msgstr "Ausgewähltes entfernen"
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr "Datei-Explorer"
|
||||
@@ -1057,6 +1024,9 @@ msgstr "Fehler beim Herunterladen von WINE/Proton: {error}"
|
||||
msgid "Version WINE/Proton"
|
||||
msgstr "WINE/Proton Version"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "Größe"
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr "Unbekannt"
|
||||
|
||||
@@ -2024,121 +1994,12 @@ msgstr "Entbinden"
|
||||
msgid "Set output"
|
||||
msgstr "Ausgabe festlegen"
|
||||
|
||||
msgid "Queued"
|
||||
msgstr "In der Warteschlange"
|
||||
|
||||
msgid "Completed"
|
||||
msgstr "Fertig"
|
||||
|
||||
msgid "Clear List"
|
||||
msgstr "Liste leeren"
|
||||
|
||||
msgid "Now Downloading"
|
||||
msgstr "Wird jetzt heruntergeladen"
|
||||
|
||||
msgid "Waiting…"
|
||||
msgstr "Warten…"
|
||||
|
||||
msgid "Disk: "
|
||||
msgstr "Datenträger: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Spieltitel"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Begonnen"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Typ"
|
||||
|
||||
msgid "Store"
|
||||
msgstr "Store"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr "Link in die Zwischenablage kopieren"
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr "Abmeldung vom GOG-Konto fehlgeschlagen"
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "Aktualisieren…"
|
||||
|
||||
msgid "Unknown error"
|
||||
msgstr "Unbekannter Fehler"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr "GOG-Anmeldung fehlgeschlagen: {0}"
|
||||
|
||||
msgid "GOG account connected"
|
||||
msgstr "GOG-Konto verknüpft"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Log out"
|
||||
msgstr "Abmelden"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Anmeldeseite öffnen"
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr "GOG-Konto nicht verbunden"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr "GOG-Bibliothek wird aktualisiert… {0}/{1}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Aktualisierung der GOG-Bibliothek fehlgeschlagen: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Installationsordner auswählen"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Eine andere GOG-Aktion läuft bereits"
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr "GOG-Installation nicht gefunden"
|
||||
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "GOG-Spielordner auswählen"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "Der ausgewählte Ordner ist nicht dieses GOG-Spiel"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "Starten"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr "Download: {0} MiB/s"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr "Datenträger: {0} MiB/s"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr "Voraussichtliche Dauer: {0}"
|
||||
|
||||
msgid "Failed"
|
||||
msgstr "Fehlgeschlagen"
|
||||
|
||||
msgid "Repaired"
|
||||
msgstr "Repariert"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Schnellstart"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete cards without executable"
|
||||
msgstr "Titelbilder ohne ausführbaren Dateien entfernen"
|
||||
msgstr "Pfad zur ausführbaren Datei:"
|
||||
|
||||
msgid "Library Settings"
|
||||
msgstr "Bibliothekseinstellungen"
|
||||
@@ -2170,23 +2031,20 @@ msgstr "Versteckt"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Anzeigetyp von Titelbildern:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing…"
|
||||
msgstr "Aktualisieren…"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
"Sollen '{count}' Titelbidler ohne ausführbaren Dateien wirklich gelöscht "
|
||||
"werden? Dabei werden Benutzerdaten und .desktop-Dateien gelöscht."
|
||||
"Soll das Spiel '{game_name}' wirklich gelöscht werden? Benutzerdaten und "
|
||||
".desktop-Datei werden gelöscht."
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton-Einstellungen"
|
||||
|
||||
msgid "Accounts"
|
||||
msgstr "Accounts"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Vereinfacht"
|
||||
|
||||
@@ -2199,6 +2057,9 @@ msgstr "Interface-Einstellungen"
|
||||
msgid "Tray Menu Type:"
|
||||
msgstr "Typ des Tray-Menüs:"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr "Gamepad-Typ:"
|
||||
|
||||
@@ -2220,12 +2081,6 @@ msgstr "Minimiert im Tray starten"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Tab „Automatische Installation“ ausblenden"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Runtime-Download deaktivieren"
|
||||
|
||||
@@ -2247,9 +2102,6 @@ msgstr "Automatischer Download von PPDB von"
|
||||
msgid "Auto update AppImage"
|
||||
msgstr "AppImage automatisch aktualisieren"
|
||||
|
||||
msgid "Integrate AppImage"
|
||||
msgstr "AppImage integrieren"
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr "System-DPI für Wine erzwingen"
|
||||
|
||||
@@ -2320,14 +2172,6 @@ msgstr ""
|
||||
"Soll der Cache wirklich gelöscht werden? Dieser Vorgang kann nicht "
|
||||
"rückgängig gemacht werden."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr "AppImage integriert unter: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr "Integration des AppImage fehlgeschlagen: {error}"
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr "Der xdg-autostart-Eintrag konnte nicht aktualisiert werden."
|
||||
|
||||
@@ -2365,6 +2209,9 @@ msgstr "Teilen"
|
||||
msgid "Profile"
|
||||
msgstr "Profil"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Typ"
|
||||
|
||||
msgid "Add"
|
||||
msgstr "Hinzufügen"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-07-25 17:07+0000\n"
|
||||
"PO-Revision-Date: 2026-07-11 07:06+0000\n"
|
||||
"Last-Translator: gallegonovato <gallegonovato@noreply.codeberg.org>\n"
|
||||
"Language-Team: Spanish <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/es/>\n"
|
||||
@@ -45,12 +45,6 @@ msgstr "Error"
|
||||
msgid "Failed to update AppImage."
|
||||
msgstr "Error al actualizar AppImage."
|
||||
|
||||
msgid "Run in logging mode"
|
||||
msgstr "Ejecutar con registro"
|
||||
|
||||
msgid "Run in silent mode"
|
||||
msgstr "Ejecutar sin registrar"
|
||||
|
||||
msgid "PortProton directory not found"
|
||||
msgstr "No encontrado el directorio PortProton"
|
||||
|
||||
@@ -155,9 +149,6 @@ msgstr "Eliminar de PortProton"
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar juego"
|
||||
|
||||
msgid "Repair"
|
||||
msgstr "Reparar"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "Eliminar del escritorio"
|
||||
|
||||
@@ -170,12 +161,6 @@ msgstr "Eliminar del menú"
|
||||
msgid "Add to Menu"
|
||||
msgstr "Añadir al menú"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Borrar"
|
||||
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir la carpeta del juego"
|
||||
|
||||
@@ -245,10 +230,6 @@ msgstr "Se ha eliminado «{game_name}» de {location}"
|
||||
msgid "Desktop"
|
||||
msgstr "Escritorio"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "No se han podido guardar los datos personalizados: {error}"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
"Es necesario indicar el nombre del juego y la ruta del archivo ejecutable"
|
||||
@@ -330,6 +311,9 @@ msgstr "Copiar"
|
||||
msgid "Paste"
|
||||
msgstr "Pegar"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Borrar"
|
||||
|
||||
msgid "Select All"
|
||||
msgstr "Seleccionar todo"
|
||||
|
||||
@@ -372,9 +356,6 @@ msgstr "Sistema"
|
||||
msgid "Themes"
|
||||
msgstr "Temas"
|
||||
|
||||
msgid "Downloads"
|
||||
msgstr "Descargas"
|
||||
|
||||
msgid "Warning"
|
||||
msgstr "Alerta"
|
||||
|
||||
@@ -447,15 +428,6 @@ msgstr "Instalar las dependencias"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Faltan algunas dependencias de 32 bits. ¿Las instalas ahora?"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Juego no encontrado."
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "No se ha encontrado el script de inicio de PortProton"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "No se puede iniciar el juego mientras hay otro en ejecución"
|
||||
|
||||
msgid "Invalid command format (empty exec line)"
|
||||
msgstr "Formato de comando no válido (línea de ejecución vacía)"
|
||||
|
||||
@@ -466,6 +438,12 @@ msgstr "Formato de comando no válido (nativo)"
|
||||
msgid "Failed to launch game: {0}"
|
||||
msgstr "Error al iniciar el juego: {0}"
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "No se ha encontrado el script de inicio de PortProton"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "No se puede iniciar el juego mientras hay otro en ejecución"
|
||||
|
||||
msgid "Choose Folder"
|
||||
msgstr "Seleccionar carpeta"
|
||||
|
||||
@@ -844,12 +822,6 @@ msgstr "Se ha iniciado el modo de registro de depuración"
|
||||
msgid "To diagnose the problem, copy the ENTIRE log to the site:"
|
||||
msgstr "Para diagnosticar el problema, copie el registro COMPLETO en:"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Descargar"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "Tamaño"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANZAMIENTO"
|
||||
|
||||
@@ -865,9 +837,6 @@ msgstr "TRAMA PRINCIPAL + SECUNDARIA"
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr "COMPLETISTA"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Instalar"
|
||||
|
||||
msgid "Open Folder"
|
||||
msgstr "Abrir carpeta"
|
||||
|
||||
@@ -880,12 +849,18 @@ msgstr "Ajustes"
|
||||
msgid "Create Log"
|
||||
msgstr "Crear registro"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Instalar"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Carta abierta"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Reinstalar"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Juego no encontrado."
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Detener el registro"
|
||||
|
||||
@@ -939,12 +914,12 @@ msgstr "Comenzando {0}"
|
||||
msgid "Name:"
|
||||
msgstr "Nombre:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "Examinar…"
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr "Ruta al ejecutable:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "Examinar…"
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr "Portada personalizada:"
|
||||
|
||||
@@ -972,15 +947,6 @@ msgstr "Descargando portada…"
|
||||
msgid "No cover selected"
|
||||
msgstr "No se ha seleccionado ninguna portada"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
@@ -1008,6 +974,9 @@ msgstr "Guardar"
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Descargar"
|
||||
|
||||
msgid "Clear All"
|
||||
msgstr "Limpiar todo"
|
||||
|
||||
@@ -1056,85 +1025,83 @@ msgid "Selected WINE/Proton:"
|
||||
msgstr "Vino/Proton seleccionado:"
|
||||
|
||||
msgid "No WINE/Proton selected"
|
||||
msgstr "No se ha seleccionado VINO/Protón"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading: "
|
||||
msgstr "Descargando: "
|
||||
msgstr ""
|
||||
|
||||
msgid "Download Selected"
|
||||
msgstr "Descargar seleccionados"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error loading WINE/Proton: {error}"
|
||||
msgstr "Error al cargar WINE/Proton: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Version WINE/Proton"
|
||||
msgstr "Versión de WINE/Proton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr "Desconocido"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "{display_name} (installed)"
|
||||
msgstr "{display_name} (instalado)"
|
||||
msgstr ""
|
||||
|
||||
msgid "No Wine/Proton versions installed"
|
||||
msgstr "No hay versiones de Wine/Proton instaladas"
|
||||
msgstr ""
|
||||
|
||||
msgid "Installed"
|
||||
msgstr "Instalado"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select to remove this WINE/Proton"
|
||||
msgstr "Seleccione para eliminar WINE/Proton"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Selected {} WINE/Proton:\n"
|
||||
msgstr "Seleccionado {} WINE/Proton:\n"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"Total size to delete: {}\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Tamaño total a eliminar: {}\n"
|
||||
|
||||
msgid "Delete Selected"
|
||||
msgstr "Eliminar selección"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Selected {} assets:\n"
|
||||
msgstr "Activos seleccionados {}:\n"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"Total size to download: {}\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Tamaño total de la descarga: {}\n"
|
||||
|
||||
msgid "Downloading in Progress"
|
||||
msgstr "Descarga en curso"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cannot clear selection while extraction is in progress."
|
||||
msgstr "No se puede borrar la selección mientras la extracción está en curso."
|
||||
msgstr ""
|
||||
|
||||
msgid "No Selection"
|
||||
msgstr "Sin selección"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please select at least one WINE/Proton to download."
|
||||
msgstr "Selecciona al menos un WINE/Proton para descargar."
|
||||
msgstr ""
|
||||
|
||||
msgid "Please wait for current downloading to complete."
|
||||
msgstr "Por favor, espere a que finalice la descarga actual."
|
||||
msgstr ""
|
||||
|
||||
msgid "Please select at least one WINE/Proton to delete."
|
||||
msgstr ""
|
||||
"Por favor, selecciona al menos un elemento de WINE/Proton para eliminarlo."
|
||||
|
||||
msgid "Confirm Clear"
|
||||
msgstr "Confirmar el borrado"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
@@ -2022,119 +1989,6 @@ msgstr ""
|
||||
msgid "Set output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Queued"
|
||||
msgstr "En cola"
|
||||
|
||||
msgid "Completed"
|
||||
msgstr "Completado"
|
||||
|
||||
msgid "Clear List"
|
||||
msgstr "Borrar lista"
|
||||
|
||||
msgid "Now Downloading"
|
||||
msgstr "Descargando"
|
||||
|
||||
msgid "Waiting…"
|
||||
msgstr "Esperando…"
|
||||
|
||||
msgid "Disk: "
|
||||
msgstr "Disco: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título del juego"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Comenzó en"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Tipo"
|
||||
|
||||
msgid "Store"
|
||||
msgstr "Tienda"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "Actualizando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Unknown error"
|
||||
msgstr "Juego desconocido"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr "Archivo: {0}"
|
||||
|
||||
msgid "GOG account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log out"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Abrir página de inicio de sesión"
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Error al iniciar el juego: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "Borrar la carpeta"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "Iniciar"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr "Descargando… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr "Archivo: {0}"
|
||||
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2172,6 +2026,9 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "Actualizando…"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2183,12 +2040,6 @@ msgstr ""
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Accounts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2201,6 +2052,9 @@ msgstr ""
|
||||
msgid "Tray Menu Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
@@ -2222,12 +2076,6 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
@@ -2249,10 +2097,6 @@ msgstr ""
|
||||
msgid "Auto update AppImage"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Integrate AppImage"
|
||||
msgstr "Error al actualizar AppImage."
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr ""
|
||||
|
||||
@@ -2319,14 +2163,6 @@ msgstr ""
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr "No se pudo cambiar el nombre del archivo: {error}"
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr ""
|
||||
|
||||
@@ -2364,6 +2200,9 @@ msgstr ""
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-07-28 11:58+0500\n"
|
||||
"POT-Creation-Date: 2026-07-11 11:54+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -38,12 +38,6 @@ msgstr ""
|
||||
msgid "Failed to update AppImage."
|
||||
msgstr ""
|
||||
|
||||
msgid "Run in logging mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Run in silent mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -144,9 +138,6 @@ msgstr ""
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Repair"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr ""
|
||||
|
||||
@@ -159,12 +150,6 @@ msgstr ""
|
||||
msgid "Add to Menu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -232,10 +217,6 @@ msgstr ""
|
||||
msgid "Desktop"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
@@ -312,6 +293,9 @@ msgstr ""
|
||||
msgid "Paste"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
|
||||
@@ -354,9 +338,6 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloads"
|
||||
msgstr ""
|
||||
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
@@ -430,15 +411,6 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (empty exec line)"
|
||||
msgstr ""
|
||||
|
||||
@@ -449,6 +421,12 @@ msgstr ""
|
||||
msgid "Failed to launch game: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Choose Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -753,12 +731,6 @@ msgstr ""
|
||||
msgid "To diagnose the problem, copy the ENTIRE log to the site:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
@@ -774,9 +746,6 @@ msgstr ""
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -789,12 +758,18 @@ msgstr ""
|
||||
msgid "Create Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
@@ -848,10 +823,10 @@ msgstr ""
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Browse…"
|
||||
msgid "Path to Executable:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgid "Browse…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Cover:"
|
||||
@@ -881,15 +856,6 @@ msgstr ""
|
||||
msgid "No cover selected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
@@ -917,6 +883,9 @@ msgstr ""
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear All"
|
||||
msgstr ""
|
||||
|
||||
@@ -980,6 +949,9 @@ msgstr ""
|
||||
msgid "Version WINE/Proton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr ""
|
||||
|
||||
@@ -1923,115 +1895,6 @@ msgstr ""
|
||||
msgid "Set output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Queued"
|
||||
msgstr ""
|
||||
|
||||
msgid "Completed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Now Downloading"
|
||||
msgstr ""
|
||||
|
||||
msgid "Waiting…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disk: "
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "Started at"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Store"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown error"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log out"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select GOG game folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2068,6 +1931,9 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2077,12 +1943,6 @@ msgstr ""
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Accounts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2095,6 +1955,9 @@ msgstr ""
|
||||
msgid "Tray Menu Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
@@ -2116,12 +1979,6 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
@@ -2143,9 +2000,6 @@ msgstr ""
|
||||
msgid "Auto update AppImage"
|
||||
msgstr ""
|
||||
|
||||
msgid "Integrate AppImage"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr ""
|
||||
|
||||
@@ -2213,14 +2067,6 @@ msgstr ""
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr ""
|
||||
|
||||
@@ -2258,6 +2104,9 @@ msgstr ""
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"PO-Revision-Date: 2026-07-11 07:06+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
|
||||
"portprotonqt/portprotonqt/pt_BR/>\n"
|
||||
@@ -43,12 +43,6 @@ msgstr "Erro"
|
||||
msgid "Failed to update AppImage."
|
||||
msgstr "Falha ao atualizar o AppImage."
|
||||
|
||||
msgid "Run in logging mode"
|
||||
msgstr "Executar em modo de log"
|
||||
|
||||
msgid "Run in silent mode"
|
||||
msgstr "Executar em modo silencioso"
|
||||
|
||||
msgid "PortProton directory not found"
|
||||
msgstr "Diretório do PortProton não encontrado"
|
||||
|
||||
@@ -153,10 +147,6 @@ msgstr "Excluir do PortProton"
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "Pareado"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "Remover da Área de Trabalho"
|
||||
|
||||
@@ -169,12 +159,6 @@ msgstr "Remover do Menu"
|
||||
msgid "Add to Menu"
|
||||
msgstr "Adicionar ao Menu"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Excluir"
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir Pasta do Jogo"
|
||||
|
||||
@@ -246,10 +230,6 @@ msgstr "'{game_name}' removido de {location}"
|
||||
msgid "Desktop"
|
||||
msgstr "Área de Trabalho"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Falha ao salvar os dados personalizados: {error}"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "O nome do jogo e o caminho do executável são obrigatórios"
|
||||
|
||||
@@ -330,6 +310,9 @@ msgstr "Copiar"
|
||||
msgid "Paste"
|
||||
msgstr "Colar"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Excluir"
|
||||
|
||||
msgid "Select All"
|
||||
msgstr "Selecionar Tudo"
|
||||
|
||||
@@ -372,10 +355,6 @@ msgstr "Sistema"
|
||||
msgid "Themes"
|
||||
msgstr "Temas"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Downloads"
|
||||
msgstr "Baixar"
|
||||
|
||||
msgid "Warning"
|
||||
msgstr "Aviso"
|
||||
|
||||
@@ -446,15 +425,6 @@ msgstr "Instalar dependências"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "As dependências de 32 bits estão faltando. Instalá-las agora?"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "Script de inicialização do PortProton não encontrado"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "Não é possível iniciar o jogo enquanto outro jogo estiver em execução"
|
||||
|
||||
msgid "Invalid command format (empty exec line)"
|
||||
msgstr "Formato de comando inválido (linha exec vazia)"
|
||||
|
||||
@@ -465,6 +435,12 @@ msgstr "Formato de comando inválido (nativo)"
|
||||
msgid "Failed to launch game: {0}"
|
||||
msgstr "Falha ao iniciar o jogo: {0}"
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "Script de inicialização do PortProton não encontrado"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "Não é possível iniciar o jogo enquanto outro jogo estiver em execução"
|
||||
|
||||
msgid "Choose Folder"
|
||||
msgstr "Escolher Pasta"
|
||||
|
||||
@@ -831,12 +807,6 @@ msgstr "O modo de log de depuração foi iniciado"
|
||||
msgid "To diagnose the problem, copy the ENTIRE log to the site:"
|
||||
msgstr "Para diagnosticar o problema, copie o log INTEIRO para o site:"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Baixar"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "Tamanho"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANÇAMENTO"
|
||||
|
||||
@@ -852,9 +822,6 @@ msgstr "PRINCIPAL + EXTRAS"
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr "100%"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Instalar"
|
||||
|
||||
msgid "Open Folder"
|
||||
msgstr "Abrir pasta"
|
||||
|
||||
@@ -867,12 +834,18 @@ msgstr "Configurações"
|
||||
msgid "Create Log"
|
||||
msgstr "Criar log"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Instalar"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Abrir cartão"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Reinstale"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Encerrar log"
|
||||
|
||||
@@ -926,12 +899,12 @@ msgstr "Iniciando {0}"
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "Procurar…"
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr "Caminho do Executável:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "Procurar…"
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr "Capa Personalizada:"
|
||||
|
||||
@@ -959,16 +932,6 @@ msgstr "Baixando capa…"
|
||||
msgid "No cover selected"
|
||||
msgstr "Nenhuma capa selecionada"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "Mouse"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
@@ -996,6 +959,9 @@ msgstr "Salvar"
|
||||
msgid "Search"
|
||||
msgstr "Pesquisar"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Baixar"
|
||||
|
||||
msgid "Clear All"
|
||||
msgstr "Limpar Tudo"
|
||||
|
||||
@@ -1059,6 +1025,9 @@ msgstr "Erro ao carregar WINE/Proton: {error}"
|
||||
msgid "Version WINE/Proton"
|
||||
msgstr "Versão WINE/Proton"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "Tamanho"
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr "Desconhecido"
|
||||
|
||||
@@ -2023,137 +1992,12 @@ msgstr "Desmontar"
|
||||
msgid "Set output"
|
||||
msgstr "Definir saída"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Queued"
|
||||
msgstr "Usado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Completed"
|
||||
msgstr "Atualização concluída"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Clear List"
|
||||
msgstr "Limpar Tudo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Now Downloading"
|
||||
msgstr "Baixando"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "Aviso"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "Link:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Game Title"
|
||||
msgstr "Largura do jogo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
msgstr "Estado"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Tipo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Store"
|
||||
msgstr "Armazenamento"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "Atualizando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Unknown error"
|
||||
msgstr "Jogo Desconhecido"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr "Arquivo: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "GOG account connected"
|
||||
msgstr "Conectado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Log out"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Falha ao iniciar o jogo: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "Configuração"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr "Baixando… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr ", ETA: {}s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
msgstr "Detalhado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repaired"
|
||||
msgstr "Pareado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Download em Andamento"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Início Rápido"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete cards without executable"
|
||||
msgstr "Excluir atalhos sem executável"
|
||||
msgstr "Caminho do Executável:"
|
||||
|
||||
msgid "Library Settings"
|
||||
msgstr "Configurações da Biblioteca"
|
||||
@@ -2185,24 +2029,20 @@ msgstr "Oculto"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Modo de Exibição do Selo:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing…"
|
||||
msgstr "Atualizando…"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
"Tem certeza de que deseja excluir {count} cartões sem executável? Isso "
|
||||
"removerá os arquivos .desktop e os dados personalizados."
|
||||
"Tem certeza de que deseja excluir '{game_name}'? Isso removerá o arquivo "
|
||||
".desktop e os dados personalizados."
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Configurações do PortProton"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Accounts"
|
||||
msgstr "Montar"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Automático"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Resumido"
|
||||
|
||||
@@ -2215,6 +2055,9 @@ msgstr "Configurações da Interface"
|
||||
msgid "Tray Menu Type:"
|
||||
msgstr "Tipo de Menu da Bandeja:"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Automático"
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr "Tipo de Controle:"
|
||||
|
||||
@@ -2236,12 +2079,6 @@ msgstr "Iniciar na bandeja"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Ocultar Aba de Instalação Automática"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Ativar sons da interface"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
|
||||
@@ -2263,10 +2100,6 @@ msgstr "Baixar automaticamente o PPDB de"
|
||||
msgid "Auto update AppImage"
|
||||
msgstr "Atualização automática do AppImage"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Integrate AppImage"
|
||||
msgstr "Atualização automática do AppImage"
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr "Forçar DPI do sistema para o Wine"
|
||||
|
||||
@@ -2335,14 +2168,6 @@ msgstr "Confirmar Limpeza do Cache"
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr "Tem certeza de que deseja limpar o cache? Esta ação não pode ser desfeita."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr "Falha ao atualizar a entrada xdg-autostart."
|
||||
|
||||
@@ -2380,6 +2205,9 @@ msgstr "Compartilhar"
|
||||
msgid "Profile"
|
||||
msgstr "Perfil"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Tipo"
|
||||
|
||||
msgid "Add"
|
||||
msgstr "Adicionar"
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-07-24 20:10+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"PO-Revision-Date: 2026-06-18 18:46+0000\n"
|
||||
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
"Language: ru\n"
|
||||
@@ -44,12 +44,6 @@ msgstr "Ошибка"
|
||||
msgid "Failed to update AppImage."
|
||||
msgstr "Не удалось обновить AppImage."
|
||||
|
||||
msgid "Run in logging mode"
|
||||
msgstr "Запустить с ведением журнала"
|
||||
|
||||
msgid "Run in silent mode"
|
||||
msgstr "Запустить в тихом режиме"
|
||||
|
||||
msgid "PortProton directory not found"
|
||||
msgstr "Не найден каталог PortProton"
|
||||
|
||||
@@ -154,9 +148,6 @@ msgstr "Удалить из PortProton"
|
||||
msgid "Launch Game"
|
||||
msgstr "Запустить игру"
|
||||
|
||||
msgid "Repair"
|
||||
msgstr "Исправить"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "Удалить с рабочего стола"
|
||||
|
||||
@@ -169,12 +160,6 @@ msgstr "Удалить из меню"
|
||||
msgid "Add to Menu"
|
||||
msgstr "Добавить в меню"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Удалить"
|
||||
|
||||
msgid "Import"
|
||||
msgstr "Импорт"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Открыть папку с игрой"
|
||||
|
||||
@@ -244,10 +229,6 @@ msgstr "'{game_name}' удалён(а) из {location}"
|
||||
msgid "Desktop"
|
||||
msgstr "Рабочий стол"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Не удалось сохранить пользовательские данные: {error}"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "Требуются название игры и путь к исполняемому файлу"
|
||||
|
||||
@@ -328,6 +309,9 @@ msgstr "Копировать"
|
||||
msgid "Paste"
|
||||
msgstr "Вставить"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Удалить"
|
||||
|
||||
msgid "Select All"
|
||||
msgstr "Выбрать всё"
|
||||
|
||||
@@ -370,9 +354,6 @@ msgstr "Система"
|
||||
msgid "Themes"
|
||||
msgstr "Темы"
|
||||
|
||||
msgid "Downloads"
|
||||
msgstr "Загрузки"
|
||||
|
||||
msgid "Warning"
|
||||
msgstr "Предупреждение"
|
||||
|
||||
@@ -443,8 +424,15 @@ msgstr "Установить зависимости"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Отсутствуют 32-битные зависимости. Установить их сейчас?"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
msgid "Invalid command format (empty exec line)"
|
||||
msgstr "Неправильный формат команды (пустая строка exec)"
|
||||
|
||||
msgid "Invalid command format (native)"
|
||||
msgstr "Неверный формат команды (натив"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to launch game: {0}"
|
||||
msgstr "Не удалось запустить игру: {0}"
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "Скрипт запуска PortProton не найден"
|
||||
@@ -452,16 +440,6 @@ msgstr "Скрипт запуска PortProton не найден"
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "Невозможно запустить игру пока запущена другая"
|
||||
|
||||
msgid "Invalid command format (empty exec line)"
|
||||
msgstr "Неправильный формат команды (пустая строка exec)"
|
||||
|
||||
msgid "Invalid command format (native)"
|
||||
msgstr "Неверный формат команды (нативный)"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to launch game: {0}"
|
||||
msgstr "Не удалось запустить игру: {0}"
|
||||
|
||||
msgid "Choose Folder"
|
||||
msgstr "Выбрать папку"
|
||||
|
||||
@@ -742,7 +720,7 @@ msgstr "Локаль"
|
||||
|
||||
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
|
||||
msgstr ""
|
||||
"Принудительное использование определённой локали для приложения. Исправляет "
|
||||
"Принудительно установить определённую локаль для приложения. Исправляет "
|
||||
"проблемы с кодировкой в устаревшем ПО"
|
||||
|
||||
msgid "Window Mode"
|
||||
@@ -829,12 +807,6 @@ msgstr "Запущен режим логирования"
|
||||
msgid "To diagnose the problem, copy the ENTIRE log to the site:"
|
||||
msgstr "Для диагностики проблемы скопируйте ВЕСЬ журнал на сайт:"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Скачать"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "Размер"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ПОСЛЕДНИЙ ЗАПУСК"
|
||||
|
||||
@@ -850,9 +822,6 @@ msgstr "СЮЖЕТ + ПОБОЧКИ"
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr "100%"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Установить"
|
||||
|
||||
msgid "Open Folder"
|
||||
msgstr "Открыть папку"
|
||||
|
||||
@@ -865,12 +834,18 @@ msgstr "Настройки"
|
||||
msgid "Create Log"
|
||||
msgstr "Создать лог"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Установить"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Открыть карточку"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Переустановить"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Остановить лог"
|
||||
|
||||
@@ -924,12 +899,12 @@ msgstr "Запуск {0}"
|
||||
msgid "Name:"
|
||||
msgstr "Название:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "Обзор…"
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr "Путь к исполняемому файлу:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "Обзор…"
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr "Пользовательская обложка:"
|
||||
|
||||
@@ -957,16 +932,6 @@ msgstr "Скачивание обложки…"
|
||||
msgid "No cover selected"
|
||||
msgstr "Обложка не выбрана"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "Мышь"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
|
||||
@@ -994,6 +959,9 @@ msgstr "Сохранить"
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Скачать"
|
||||
|
||||
msgid "Clear All"
|
||||
msgstr "Очистить выбранное"
|
||||
|
||||
@@ -1057,6 +1025,9 @@ msgstr "Ошибка загрузки WINE/Proton: {error}"
|
||||
msgid "Version WINE/Proton"
|
||||
msgstr "Версия WINE/Proton"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "Размер"
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr "Неизвестен"
|
||||
|
||||
@@ -1101,7 +1072,7 @@ msgstr ""
|
||||
"Общий размер на загрузку: {}\n"
|
||||
|
||||
msgid "Downloading in Progress"
|
||||
msgstr "Загрузка"
|
||||
msgstr "Скачивание в процессе"
|
||||
|
||||
msgid "Cannot clear selection while extraction is in progress."
|
||||
msgstr "Невозможно очистить выделение во время распаковки."
|
||||
@@ -1158,7 +1129,7 @@ msgstr ", Расчетное время: {}с"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ", Speed: {:.1f}MB/s"
|
||||
msgstr ", Скорость: {:.1f} МБ/с"
|
||||
msgstr ", Скорость: {:.1f}МБ/с"
|
||||
|
||||
msgid "Extracting: {name}{speed}{eta}"
|
||||
msgstr "Распаковка: {name}{speed}{eta}"
|
||||
@@ -1174,7 +1145,7 @@ msgid "Operation Cancelled"
|
||||
msgstr "Операция отменена"
|
||||
|
||||
msgid "Download or extraction has been cancelled."
|
||||
msgstr "Скачивание или распаковка отменены."
|
||||
msgstr "Скачивание или распаковка успешно отменена."
|
||||
|
||||
msgid "System WINE"
|
||||
msgstr "Системный WINE"
|
||||
@@ -1460,7 +1431,7 @@ msgid "Custom"
|
||||
msgstr "Пользовательский"
|
||||
|
||||
msgid "Save custom"
|
||||
msgstr "Сохранить"
|
||||
msgstr "Сохранить пользовательские настройки"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr "Очистить"
|
||||
@@ -1626,7 +1597,7 @@ msgid "Dynamic frametime Y-axis"
|
||||
msgstr "Динамическая ордината времени кадра"
|
||||
|
||||
msgid "Short engine names"
|
||||
msgstr "Краткие названия движков"
|
||||
msgstr "Короткое имя графического API"
|
||||
|
||||
msgid "Engine version (OpenGL/Vulkan)"
|
||||
msgstr "Версия графического API (OpenGL/Vulkan)"
|
||||
@@ -1809,7 +1780,7 @@ msgid "Force trilinear filtering"
|
||||
msgstr "Включить трилинейную фильтрацию"
|
||||
|
||||
msgid "Automatic log uploads"
|
||||
msgstr "Автоматическая отправка журналов"
|
||||
msgstr "Автоматическая загрузка лога в flightlessmango.com"
|
||||
|
||||
msgid "MangoHud version"
|
||||
msgstr "Версия MangoHud"
|
||||
@@ -2020,111 +1991,6 @@ msgstr "Размонтировать"
|
||||
msgid "Set output"
|
||||
msgstr "Выбрать выход"
|
||||
|
||||
msgid "Queued"
|
||||
msgstr "В очереди"
|
||||
|
||||
msgid "Completed"
|
||||
msgstr "Завершено"
|
||||
|
||||
msgid "Clear List"
|
||||
msgstr "Очистить список"
|
||||
|
||||
msgid "Now Downloading"
|
||||
msgstr "Сейчас загружается"
|
||||
|
||||
msgid "Waiting…"
|
||||
msgstr "Ожидание…"
|
||||
|
||||
msgid "Disk: "
|
||||
msgstr "Диск: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Название игры"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Начато"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Тип"
|
||||
|
||||
msgid "Store"
|
||||
msgstr "Магазин"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr "Скопировать ссылку в буфер обмена"
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr "Не удалось выйти из аккаунта GOG"
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "Обновление…"
|
||||
|
||||
msgid "Unknown error"
|
||||
msgstr "Неизвестная ошибка"
|
||||
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr "Ошибка входа в GOG: {0}"
|
||||
|
||||
msgid "GOG account connected"
|
||||
msgstr "Аккаунт GOG подключён"
|
||||
|
||||
msgid "Log out"
|
||||
msgstr "Выйти"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Открыть страницу входа"
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr "Аккаунт GOG не подключён"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr "Обновление библиотеки GOG… {0}/{1}"
|
||||
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Не удалось обновить библиотеку GOG: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Выберите папку установки"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Другая операция GOG уже выполняется"
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr "Установка GOG не найдена"
|
||||
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "Выберите папку игры GOG"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "Выбранная папка не является этой игрой GOG"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Удалить «{0}» и все файлы в папке установки?"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "Запуск"
|
||||
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr "Загрузка: {0} МБ/с"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr "Диск: {0} МБ/с"
|
||||
|
||||
msgid "ETA: {0}"
|
||||
msgstr "Осталось: {0}"
|
||||
|
||||
msgid "Failed"
|
||||
msgstr "Ошибка"
|
||||
|
||||
msgid "Repaired"
|
||||
msgstr "Исправлено"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
@@ -2161,6 +2027,9 @@ msgstr "Скрытый"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Тип отображения бейджей:"
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "Обновление…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2172,12 +2041,6 @@ msgstr ""
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProtonQt"
|
||||
|
||||
msgid "Accounts"
|
||||
msgstr "Аккаунты"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Упрощённый"
|
||||
|
||||
@@ -2190,6 +2053,9 @@ msgstr "Настройки интерфейса"
|
||||
msgid "Tray Menu Type:"
|
||||
msgstr "Тип области уведомления:"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr "Тип геймпада:"
|
||||
|
||||
@@ -2211,12 +2077,6 @@ msgstr "Запускать свёрнутым"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Скрыть вкладку \"Автоустановка\""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Включить звуки интерфейса"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Отключить скачивание контейнера"
|
||||
|
||||
@@ -2238,9 +2098,6 @@ msgstr "Автоматическая загрузка PPDB с"
|
||||
msgid "Auto update AppImage"
|
||||
msgstr "Автоматическое обновление AppImage"
|
||||
|
||||
msgid "Integrate AppImage"
|
||||
msgstr "Интегрировать AppImage"
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr "Принудительно установить системный DPI для Wine"
|
||||
|
||||
@@ -2309,13 +2166,6 @@ msgstr "Подтверждение очистки кэша"
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr "Вы уверены, что хотите очистить кэш? Это действие нельзя отменить."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr "AppImage интегрирован в: {path}"
|
||||
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr "Не удалось интегрировать AppImage: {error}"
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr "Не удалось обновить запись xdg-autostart."
|
||||
|
||||
@@ -2353,6 +2203,9 @@ msgstr "Поделиться"
|
||||
msgid "Profile"
|
||||
msgstr "Профиль"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Тип"
|
||||
|
||||
msgid "Add"
|
||||
msgstr "Добавить"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"PO-Revision-Date: 2026-06-19 11:38+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Chinese (Simplified Han script) <https://"
|
||||
"translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/>\n"
|
||||
@@ -29,23 +29,19 @@ msgid ""
|
||||
"folder for PortProton data."
|
||||
msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择其他文件夹。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Update complete"
|
||||
msgstr "更新完成"
|
||||
msgstr "下载完成"
|
||||
|
||||
msgid "AppImage updated. Restart the application to use the new version."
|
||||
msgstr "AppImage 已更新。请重新启动应用程序以使用新版本。"
|
||||
msgstr ""
|
||||
|
||||
msgid "Error"
|
||||
msgstr "错误"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to update AppImage."
|
||||
msgstr "AppImage 更新失败。"
|
||||
|
||||
msgid "Run in logging mode"
|
||||
msgstr "日志模式运行"
|
||||
|
||||
msgid "Run in silent mode"
|
||||
msgstr "静默模式运行"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "PortProton directory not found"
|
||||
msgstr "未找到 PortProton 目录"
|
||||
@@ -147,10 +143,6 @@ msgstr "从 PortProton 中删除"
|
||||
msgid "Launch Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "已配对"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "从桌面移除"
|
||||
|
||||
@@ -163,12 +155,6 @@ msgstr "从菜单移除"
|
||||
msgid "Add to Menu"
|
||||
msgstr "添加到菜单"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "删除"
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "打开游戏文件夹"
|
||||
|
||||
@@ -236,10 +222,6 @@ msgstr "已从 {location} 移除\"{game_name}\""
|
||||
msgid "Desktop"
|
||||
msgstr "桌面"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "保存自定义数据失败:{error}"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "游戏名称和可执行文件路径为必填项"
|
||||
|
||||
@@ -316,6 +298,9 @@ msgstr "复制"
|
||||
msgid "Paste"
|
||||
msgstr "粘贴"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "删除"
|
||||
|
||||
msgid "Select All"
|
||||
msgstr "全选"
|
||||
|
||||
@@ -358,10 +343,6 @@ msgstr "系统"
|
||||
msgid "Themes"
|
||||
msgstr "主题"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Downloads"
|
||||
msgstr "下载"
|
||||
|
||||
msgid "Warning"
|
||||
msgstr "警告"
|
||||
|
||||
@@ -424,22 +405,13 @@ msgstr "未找到前缀\"{0}\"。是否安装推荐库?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Repository x86_64-i586 is not enabled. See: {0}"
|
||||
msgstr "未启用 x86_64-i586 仓库。请参阅:{0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install dependencies"
|
||||
msgstr "安装依赖"
|
||||
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "未找到 PortProton 启动脚本"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "另一游戏正在运行时无法启动游戏"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (empty exec line)"
|
||||
msgstr "命令格式无效(exec 行为空)"
|
||||
@@ -451,6 +423,12 @@ msgstr "命令格式无效(原生)"
|
||||
msgid "Failed to launch game: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "PortProton start script not found"
|
||||
msgstr "未找到 PortProton 启动脚本"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "另一游戏正在运行时无法启动游戏"
|
||||
|
||||
msgid "Choose Folder"
|
||||
msgstr "选择文件夹"
|
||||
|
||||
@@ -786,12 +764,6 @@ msgstr "调试日志模式已启动"
|
||||
msgid "To diagnose the problem, copy the ENTIRE log to the site:"
|
||||
msgstr "要诊断问题,请将完整日志复制到网站:"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "下载"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "大小"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "上次启动"
|
||||
|
||||
@@ -807,9 +779,6 @@ msgstr "主线 + 支线"
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr "完美主义者"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "安装"
|
||||
|
||||
msgid "Open Folder"
|
||||
msgstr "打开文件夹"
|
||||
|
||||
@@ -822,12 +791,18 @@ msgstr "设置"
|
||||
msgid "Create Log"
|
||||
msgstr "创建日志"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "安装"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "打开卡片"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "重新安装"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "停止日志"
|
||||
|
||||
@@ -856,23 +831,28 @@ msgstr "部分"
|
||||
msgid "none"
|
||||
msgstr "无"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Update available"
|
||||
msgstr "有可用更新"
|
||||
msgstr "可用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "有新的 AppImage 更新可用。"
|
||||
msgstr "可执行文件路径不可用。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Update"
|
||||
msgstr "更新"
|
||||
msgstr "粘贴"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Later"
|
||||
msgstr "稍后"
|
||||
msgstr "晚"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disable updates"
|
||||
msgstr "禁用更新"
|
||||
msgstr "已禁用"
|
||||
|
||||
msgid "Updating AppImage..."
|
||||
msgstr "正在更新 AppImage..."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
@@ -881,12 +861,12 @@ msgstr "正在启动 {0}"
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "浏览…"
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr "可执行文件路径:"
|
||||
|
||||
msgid "Browse…"
|
||||
msgstr "浏览…"
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr "自定义封面:"
|
||||
|
||||
@@ -914,16 +894,6 @@ msgstr "正在下载封面…"
|
||||
msgid "No cover selected"
|
||||
msgstr "未选择封面"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
|
||||
@@ -951,6 +921,9 @@ msgstr "保存"
|
||||
msgid "Search"
|
||||
msgstr "搜索"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "下载"
|
||||
|
||||
msgid "Clear All"
|
||||
msgstr "全部清除"
|
||||
|
||||
@@ -1014,6 +987,9 @@ msgstr "加载 WINE/Proton 时出错:{error}"
|
||||
msgid "Version WINE/Proton"
|
||||
msgstr "WINE/Proton 版本"
|
||||
|
||||
msgid "Size"
|
||||
msgstr "大小"
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr "未知"
|
||||
|
||||
@@ -1962,137 +1938,12 @@ msgstr "卸载"
|
||||
msgid "Set output"
|
||||
msgstr "设置输出"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Queued"
|
||||
msgstr "已使用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Completed"
|
||||
msgstr "更新完成"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Clear List"
|
||||
msgstr "全部清除"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Now Downloading"
|
||||
msgstr "正在下载"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "警告"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Game Title"
|
||||
msgstr "游戏宽度"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
msgstr "状态"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "类型"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Store"
|
||||
msgstr "存储"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "正在刷新…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Unknown error"
|
||||
msgstr "未知游戏"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr "文件:{0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "GOG account connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Log out"
|
||||
msgstr "注销"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "设置项"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr "正在下载… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr ",预计剩余时间:{}秒"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
msgstr "详细"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repaired"
|
||||
msgstr "已配对"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "快速启动"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete cards without executable"
|
||||
msgstr "删除没有可执行文件的卡片"
|
||||
msgstr "可执行文件路径:"
|
||||
|
||||
msgid "Library Settings"
|
||||
msgstr "库设置"
|
||||
@@ -2124,24 +1975,18 @@ msgstr "隐藏"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "徽章视图类型:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing…"
|
||||
msgstr "正在刷新…"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
|
||||
"据。"
|
||||
msgstr "确定要删除\"{game_name}\"吗?这将移除 .desktop 文件和自定义数据。"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton 设置"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Accounts"
|
||||
msgstr "挂载"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "简要"
|
||||
|
||||
@@ -2154,6 +1999,9 @@ msgstr "界面设置"
|
||||
msgid "Tray Menu Type:"
|
||||
msgstr "托盘菜单类型:"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr "游戏手柄类型:"
|
||||
|
||||
@@ -2175,12 +2023,6 @@ msgstr "启动时最小化到托盘"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "隐藏自动安装标签页"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "禁用运行时下载"
|
||||
|
||||
@@ -2200,11 +2042,7 @@ msgid "Auto download PPDB from"
|
||||
msgstr "自动从以下位置下载 PPDB"
|
||||
|
||||
msgid "Auto update AppImage"
|
||||
msgstr "自动更新 AppImage"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Integrate AppImage"
|
||||
msgstr "自动更新 AppImage"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr "强制为 Wine 设置系统 DPI"
|
||||
@@ -2272,14 +2110,6 @@ msgstr "确认清除缓存"
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr "确定要清除缓存吗?此操作无法撤销。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr "更新 xdg-autostart 条目失败。"
|
||||
|
||||
@@ -2317,6 +2147,9 @@ msgstr "分享"
|
||||
msgid "Profile"
|
||||
msgstr "配置文件"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "类型"
|
||||
|
||||
msgid "Add"
|
||||
msgstr "添加"
|
||||
|
||||
|
||||
+40
-303
@@ -8,20 +8,17 @@ import subprocess
|
||||
import tempfile
|
||||
import psutil
|
||||
import re
|
||||
import time
|
||||
from queue import Empty, Queue
|
||||
from threading import Thread
|
||||
from typing import TYPE_CHECKING
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.animations import DetailPageAnimations
|
||||
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel
|
||||
from portprotonqt.detail_pages import DetailPageManager
|
||||
from portprotonqt.portproton_api import PortProtonAPI, remove_empty_custom_data_dirs
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.debug_utils import get_prefix_name
|
||||
from portprotonqt.input_manager import InputManager, MainWindowProtocol
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
@@ -49,7 +46,6 @@ from portprotonqt.config import (
|
||||
get_portproton_start_command,
|
||||
get_portproton_scripts_path,
|
||||
find_game_by_exe,
|
||||
resolve_custom_data_dir,
|
||||
)
|
||||
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
@@ -58,17 +54,9 @@ from portprotonqt.tray_manager import TrayManager
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.disc_image_utils import DiscImageManager
|
||||
from portprotonqt.compatibility_report import (
|
||||
CompatibilityLaunch,
|
||||
analyze_launch,
|
||||
has_dxvk_vulkan_incompatibility,
|
||||
is_suspected_crash,
|
||||
)
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
from portprotonqt.tabs.control_hints import MainWindowControlHintsMixin
|
||||
from portprotonqt.tabs.autoinstall_tab import MainWindowAutoInstallTabMixin
|
||||
from portprotonqt.tabs.library_tab import MainWindowLibraryTabMixin
|
||||
from portprotonqt.tabs.gog_tab import GOGLibraryWorker, MainWindowGOGTabMixin
|
||||
from portprotonqt.tabs.settings_tab import MainWindowSettingsTabMixin
|
||||
from portprotonqt.tabs.system_tab import MainWindowSystemTabMixin
|
||||
from portprotonqt.tabs.theme_tab import MainWindowThemeTabMixin
|
||||
@@ -144,7 +132,6 @@ class MainWindow(
|
||||
MainWindowControlHintsMixin,
|
||||
MainWindowAutoInstallTabMixin,
|
||||
MainWindowLibraryTabMixin,
|
||||
MainWindowGOGTabMixin,
|
||||
MainWindowSettingsTabMixin,
|
||||
MainWindowSystemTabMixin,
|
||||
MainWindowThemeTabMixin,
|
||||
@@ -153,7 +140,6 @@ class MainWindow(
|
||||
QMainWindow,
|
||||
):
|
||||
games_loaded = Signal(list)
|
||||
compatibility_report_ready = Signal(str)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
@@ -314,16 +300,12 @@ class MainWindow(
|
||||
self.target_exe = None
|
||||
self.game_start_time = None
|
||||
self.game_start_exe = None
|
||||
self.game_launch_monotonic = None
|
||||
self.game_stopped_by_user = False
|
||||
self.current_running_button = None
|
||||
self.disc_image_manager = DiscImageManager()
|
||||
self.portproton_location = get_portproton_location()
|
||||
self.start_sh = get_portproton_start_command()
|
||||
self.launch_exe = launch_exe # Store launch_exe path
|
||||
self._pending_log_exe: str | None = None
|
||||
self.appimageUpdateWorker: AppImageUpdateWorker | None = None
|
||||
self.compatibility_report_ready.connect(self._show_compatibility_report)
|
||||
|
||||
self.game_library_manager = GameLibraryManager(self, self.theme, None)
|
||||
|
||||
@@ -373,7 +355,6 @@ class MainWindow(
|
||||
# Start watching dist and prefixes directories if they exist
|
||||
QTimer.singleShot(0, self.start_watching_directories) # Delay to ensure portproton_location is set
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
self.gog_api = GOGAPI()
|
||||
self.portproton_api = PortProtonAPI(self.downloader)
|
||||
self.autoInstallCustomDataThread = None
|
||||
|
||||
@@ -427,7 +408,6 @@ class MainWindow(
|
||||
self.leftNavButton = NavLabel()
|
||||
self.leftNavButton.setFixedSize(32, 32)
|
||||
self.leftNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.leftNavButton.setProperty("sound_event", False)
|
||||
self.leftNavButton.clicked.connect(lambda: self.switchVisibleTab(-1))
|
||||
navLayout.addWidget(self.leftNavButton)
|
||||
|
||||
@@ -439,13 +419,11 @@ class MainWindow(
|
||||
_("Wine Settings"),
|
||||
_("PPQT Settings"),
|
||||
_("System"),
|
||||
_("Themes"),
|
||||
_("Downloads")
|
||||
_("Themes")
|
||||
]
|
||||
for i, tabName in enumerate(tabs):
|
||||
btn = NavLabel(tabName)
|
||||
btn.setCheckable(True)
|
||||
btn.setProperty("sound_event", False)
|
||||
btn.clicked.connect(lambda index=i: self.switchTab(index))
|
||||
btn.setStyleSheet(self.theme.NAV_BUTTON_STYLE)
|
||||
navLayout.addWidget(btn)
|
||||
@@ -457,7 +435,6 @@ class MainWindow(
|
||||
self.rightNavButton = NavLabel()
|
||||
self.rightNavButton.setFixedSize(32, 32)
|
||||
self.rightNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.rightNavButton.setProperty("sound_event", False)
|
||||
self.rightNavButton.clicked.connect(lambda: self.switchVisibleTab(1))
|
||||
navLayout.addWidget(self.rightNavButton)
|
||||
|
||||
@@ -506,7 +483,6 @@ class MainWindow(
|
||||
if hidden_system_page:
|
||||
hidden_system_page.setVisible(False)
|
||||
self.createThemeTab()
|
||||
self.createGOGDownloadsTab()
|
||||
|
||||
self.controlHintsWidget = self.createControlHintsWidget()
|
||||
self.controlHintsWidget.setStyleSheet(self.theme.HINT_BAR_STYLE)
|
||||
@@ -850,8 +826,7 @@ class MainWindow(
|
||||
progress_match = re.fullmatch(r'([0-9]*\.?[0-9]+)%', line_text)
|
||||
if progress_match:
|
||||
percent = float(progress_match.group(1))
|
||||
elif "update prefix log:" in line_lower \
|
||||
or "the prefix has been updated" in line_lower \
|
||||
elif "the prefix has been updated" in line_lower \
|
||||
or "log wine:" in line_lower \
|
||||
or "log from runtime and wine:" in line_lower:
|
||||
launch_started = True
|
||||
@@ -950,23 +925,21 @@ class MainWindow(
|
||||
def start_loading():
|
||||
if display_filter == "steam":
|
||||
self._load_steam_games_async(lambda games: self.games_loaded.emit(games))
|
||||
elif display_filter == "gog":
|
||||
self._load_gog_games_async(lambda games: self.games_loaded.emit(games))
|
||||
elif display_filter == "portproton":
|
||||
self._load_portproton_games_async(lambda games: self.games_loaded.emit(games))
|
||||
elif display_filter == "favorites":
|
||||
def on_all_games_favorites(portproton_games, steam_games, gog_games):
|
||||
games = [game for game in portproton_games + steam_games + gog_games if game[0] in favorites]
|
||||
def on_all_games_favorites(portproton_games, steam_games):
|
||||
games = [game for game in portproton_games + steam_games if game[0] in favorites]
|
||||
self.games_loaded.emit(games)
|
||||
|
||||
# Load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': []}
|
||||
completed = {'portproton': False, 'steam': False, 'gog': False}
|
||||
results = {'portproton': [], 'steam': []}
|
||||
completed = {'portproton': False, 'steam': False}
|
||||
|
||||
def check_completion():
|
||||
if all(completed.values()):
|
||||
QApplication.processEvents() # Keep UI responsive
|
||||
on_all_games_favorites(results['portproton'], results['steam'], results['gog'])
|
||||
on_all_games_favorites(results['portproton'], results['steam'])
|
||||
|
||||
def portproton_callback(games):
|
||||
results['portproton'] = games
|
||||
@@ -980,23 +953,17 @@ class MainWindow(
|
||||
QApplication.processEvents() # Keep UI responsive
|
||||
check_completion()
|
||||
|
||||
def gog_callback(games):
|
||||
results['gog'] = games
|
||||
completed['gog'] = True
|
||||
check_completion()
|
||||
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
else:
|
||||
# For 'all' filter - load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': []}
|
||||
completed = {'portproton': False, 'steam': False, 'gog': False}
|
||||
results = {'portproton': [], 'steam': []}
|
||||
completed = {'portproton': False, 'steam': False}
|
||||
|
||||
def on_all_games():
|
||||
seen = set()
|
||||
games = []
|
||||
for game in results['portproton'] + results['steam'] + results['gog']:
|
||||
for game in results['portproton'] + results['steam']:
|
||||
# Unique key: name + exec_line
|
||||
key = (game[0], game[5])
|
||||
if key not in seen:
|
||||
@@ -1022,91 +989,13 @@ class MainWindow(
|
||||
QApplication.processEvents() # Keep UI responsive
|
||||
check_completion()
|
||||
|
||||
def gog_callback(games):
|
||||
results['gog'] = games
|
||||
completed['gog'] = True
|
||||
check_completion()
|
||||
|
||||
# Load all sources in parallel
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
|
||||
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
api = getattr(self, "gog_api", None) or GOGAPI()
|
||||
installed = api.load_installed()
|
||||
library = [game for game in api.load_library() if game.get("app_id")]
|
||||
if self._upgrade_legacy_gog_library(api, library, callback):
|
||||
return
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
processed_count = 0
|
||||
|
||||
def on_game_info(game: dict, steam_info: dict) -> None:
|
||||
nonlocal processed_count
|
||||
app_id = str(game.get("app_id", ""))
|
||||
action = "launch" if api.is_game_installed(app_id, installed) else "install"
|
||||
games.append((
|
||||
str(game.get("title", app_id)),
|
||||
str(game.get("description", "")), str(game.get("cover", "")),
|
||||
app_id,
|
||||
steam_info.get("controller_support", ""),
|
||||
f"gog://{action}/{app_id}",
|
||||
_("Never"), format_playtime(0),
|
||||
steam_info.get("protondb_tier", ""),
|
||||
steam_info.get("anticheat_status", ""),
|
||||
0, 0,
|
||||
"gog",
|
||||
steam_info.get("anticheat_slug", ""),
|
||||
steam_info.get("ppdb_id", ""), steam_info.get("ppdb_rating", ""),
|
||||
steam_info.get("appid", ""),
|
||||
))
|
||||
processed_count += 1
|
||||
if processed_count == len(library):
|
||||
callback(games)
|
||||
|
||||
for game in library:
|
||||
app_id = str(game.get("app_id", ""))
|
||||
steam_appid = str(game.get("steam_appid", ""))
|
||||
if steam_appid:
|
||||
get_full_steam_game_info_async(
|
||||
int(steam_appid),
|
||||
lambda info, current_game=game: on_game_info(current_game, info),
|
||||
fallback_name=str(game.get("title", app_id)),
|
||||
)
|
||||
continue
|
||||
get_steam_game_info_async(
|
||||
str(game.get("title", app_id)),
|
||||
f"gog://launch/{app_id}",
|
||||
lambda info, current_game=game: on_game_info(current_game, info),
|
||||
)
|
||||
|
||||
def _upgrade_legacy_gog_library(
|
||||
self, api: GOGAPI, library: list[dict], callback: Callable[[list], None]
|
||||
) -> bool:
|
||||
auth_path = getattr(api, "auth_path", None)
|
||||
needs_upgrade = any("steam_appid" not in game for game in library)
|
||||
if (
|
||||
not needs_upgrade
|
||||
or getattr(self, "_gog_metadata_upgrade_attempted", False)
|
||||
or auth_path is None
|
||||
or not auth_path.is_file()
|
||||
):
|
||||
return False
|
||||
self._gog_metadata_upgrade_attempted = True
|
||||
worker = GOGLibraryWorker(api)
|
||||
worker.loaded.connect(lambda _games: self._load_gog_games_async(callback))
|
||||
worker.failed.connect(lambda _error: self._load_gog_games_async(callback))
|
||||
worker.finished.connect(lambda: setattr(self, "gog_metadata_worker", None))
|
||||
self.gog_metadata_worker = worker
|
||||
worker.start()
|
||||
return True
|
||||
|
||||
def _load_steam_games_async(self, callback: Callable[[list[tuple]], None]):
|
||||
steam_games = []
|
||||
installed_games = get_steam_installed_games()
|
||||
@@ -1129,12 +1018,11 @@ class MainWindow(
|
||||
'name': name,
|
||||
'game_source': 'steam'
|
||||
}
|
||||
name, custom_cover = self._get_custom_steam_data(appid, name)
|
||||
last_launch = format_last_launch(datetime.fromtimestamp(last_played)) if last_played else _("Never")
|
||||
steam_games.append((
|
||||
name,
|
||||
info.get('description', ''),
|
||||
custom_cover or info.get('cover', ''),
|
||||
info.get('cover', ''),
|
||||
appid,
|
||||
info.get('controller_support', ''),
|
||||
f"steam://rungameid/{appid}",
|
||||
@@ -1157,28 +1045,6 @@ class MainWindow(
|
||||
for name, appid, last_played, playtime_seconds in installed_games:
|
||||
get_full_steam_game_info_async(appid, lambda info, n=name, a=appid, lp=last_played, pt=playtime_seconds: on_game_info(info, n, a, lp, pt), fallback_name=name)
|
||||
|
||||
@staticmethod
|
||||
def _get_custom_steam_data(
|
||||
appid: int | str, fallback_name: str
|
||||
) -> tuple[str, str]:
|
||||
"""Return custom Steam name and cover stored by AppID."""
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
game_dir = os.path.join(
|
||||
xdg_data_home, "PortProtonQt", "custom_data", str(appid)
|
||||
)
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
custom_name = fallback_name
|
||||
if os.path.isfile(metadata_path):
|
||||
translations = read_metadata_translations(metadata_path, get_metadata_language())
|
||||
custom_name = translations.get("name", fallback_name).strip()
|
||||
for extension in COVER_IMAGE_EXTENSIONS:
|
||||
cover_path = os.path.join(game_dir, f"cover{extension}")
|
||||
if os.path.isfile(cover_path):
|
||||
return custom_name, cover_path
|
||||
return custom_name, ""
|
||||
|
||||
def _load_portproton_games_async(self, callback: Callable[[list[tuple]], None]):
|
||||
games = []
|
||||
xdg_data_home = os.getenv(
|
||||
@@ -1276,7 +1142,7 @@ class MainWindow(
|
||||
economy_mode = ui_config.get_economy_mode()
|
||||
if game_exe:
|
||||
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
|
||||
user_game_folder = resolve_custom_data_dir(user_custom_folder, game_exe)
|
||||
user_game_folder = os.path.join(user_custom_folder, exe_name)
|
||||
themed_launch_icon = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(game_exe)[1].lower(), "")
|
||||
generated_img_icon = self._generate_missing_portproton_icon(
|
||||
game_exe, entry.get("Icon", ""), desktop_name
|
||||
@@ -1351,8 +1217,14 @@ class MainWindow(
|
||||
generated_img_icon or entry.get("Icon", "")
|
||||
)
|
||||
if not final_cover and game_exe and game_exe.lower().endswith(".exe") and os.path.exists(game_exe):
|
||||
generated_cover_path = get_exe_icon_cache_path(game_exe)
|
||||
os.makedirs(os.path.dirname(generated_cover_path), exist_ok=True)
|
||||
xdg_cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache"),
|
||||
)
|
||||
icon_cache_dir = os.path.join(xdg_cache_home, "PortProtonQt", "images", "exe_icons")
|
||||
os.makedirs(icon_cache_dir, exist_ok=True)
|
||||
safe_exe_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(game_exe))
|
||||
generated_cover_path = os.path.join(icon_cache_dir, f"{safe_exe_name}.png")
|
||||
if not os.path.exists(generated_cover_path):
|
||||
if not generate_thumbnail(game_exe, generated_cover_path, size=128):
|
||||
generated_cover_path = ""
|
||||
@@ -1416,7 +1288,6 @@ class MainWindow(
|
||||
|
||||
def switchTab(self, index):
|
||||
"""Set active tab by index."""
|
||||
SoundManager().play("tab_switch")
|
||||
# Check if the requested tab index is valid, exists, and is visible
|
||||
if (hasattr(self, 'tabButtons') and
|
||||
index in self.tabButtons and
|
||||
@@ -1427,7 +1298,7 @@ class MainWindow(
|
||||
btn.setChecked(i == index)
|
||||
self.stackedWidget.setCurrentIndex(index)
|
||||
if index == self.auto_install_tab_index:
|
||||
QTimer.singleShot(0, self._start_autoinstall_load)
|
||||
self._start_autoinstall_load()
|
||||
else:
|
||||
# If trying to switch to a non-existent or hidden tab (like auto-install when it's hidden),
|
||||
# find the first visible tab to switch to
|
||||
@@ -1565,30 +1436,12 @@ class MainWindow(
|
||||
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open game detail page."""
|
||||
SoundManager().play("open")
|
||||
exec_line = str(game_data.get("exec_line", ""))
|
||||
if str(game_data.get("game_source", "")).lower() == "gog":
|
||||
app_id = str(game_data.get("appid", ""))
|
||||
if (
|
||||
exec_line.startswith("gog://install/")
|
||||
and self.gog_api.is_game_installed(app_id)
|
||||
):
|
||||
game_data = dict(game_data)
|
||||
game_data["exec_line"] = f"gog://launch/{app_id}"
|
||||
logger.debug(
|
||||
"Opening detail page for %s from stacked index %d",
|
||||
game_data.get("name", ""),
|
||||
self.stackedWidget.currentIndex(),
|
||||
)
|
||||
self.detail_page_manager.openGameDetailPage(game_data)
|
||||
pending_log_exe = self._pending_log_exe
|
||||
detail_exe = extract_exec_target_path(game_data.get("exec_line", ""))
|
||||
if pending_log_exe and detail_exe:
|
||||
if os.path.abspath(detail_exe) == pending_log_exe:
|
||||
self._pending_log_exe = None
|
||||
log_button = self.detail_page_manager._debug_log_button
|
||||
if log_button:
|
||||
self.detail_page_manager._start_debug_log(pending_log_exe, log_button)
|
||||
logger.debug(
|
||||
"Detail page opened at stacked index %d, current_detail=%s",
|
||||
self.stackedWidget.currentIndex(),
|
||||
@@ -1639,7 +1492,7 @@ class MainWindow(
|
||||
}
|
||||
self.detail_page_manager.openAutoInstallDetailPage(game_data, return_tab_index=0)
|
||||
|
||||
def handle_launch_exe(self, exe_path: str, log_mode: bool = False) -> None:
|
||||
def handle_launch_exe(self, exe_path: str) -> None:
|
||||
"""Handle launching a supported file from CLI.
|
||||
|
||||
If the game exists in the library, open its detail page.
|
||||
@@ -1647,19 +1500,22 @@ class MainWindow(
|
||||
|
||||
Args:
|
||||
exe_path: Full path to the launch file
|
||||
log_mode: Start UI logging after opening the detail page
|
||||
"""
|
||||
# Normalize the exe path
|
||||
exe_path = os.path.abspath(exe_path)
|
||||
self._pending_log_exe = exe_path if log_mode else None
|
||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
economy_mode = ui_config.get_economy_mode()
|
||||
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_data_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data")
|
||||
user_game_folder = resolve_custom_data_dir(custom_data_root, exe_path)
|
||||
user_game_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
exe_name
|
||||
)
|
||||
|
||||
local_cover_path = ""
|
||||
if os.path.isdir(user_game_folder):
|
||||
@@ -1676,8 +1532,14 @@ class MainWindow(
|
||||
icon_path = self.theme_manager.get_icon(themed_launch_icon, as_path=True)
|
||||
themed_launch_cover = icon_path if isinstance(icon_path, str) else ""
|
||||
if not local_cover_path and os.path.isfile(exe_path) and exe_path.lower().endswith(".exe"):
|
||||
generated_cover_path = get_exe_icon_cache_path(exe_path)
|
||||
os.makedirs(os.path.dirname(generated_cover_path), exist_ok=True)
|
||||
xdg_cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache")
|
||||
)
|
||||
icon_cache_dir = os.path.join(xdg_cache_home, "PortProtonQt", "images", "exe_icons")
|
||||
os.makedirs(icon_cache_dir, exist_ok=True)
|
||||
safe_exe_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
generated_cover_path = os.path.join(icon_cache_dir, f"{safe_exe_name}.png")
|
||||
if not os.path.exists(generated_cover_path):
|
||||
if not generate_thumbnail(exe_path, generated_cover_path, size=128):
|
||||
generated_cover_path = ""
|
||||
@@ -1949,7 +1811,6 @@ class MainWindow(
|
||||
self._set_running_button_stop()
|
||||
elif not child_running:
|
||||
# Game completed - reset flag, reset button and stop timer
|
||||
self._analyze_short_launch()
|
||||
self.resetPlayButton()
|
||||
#self._uninhibit_screensaver()
|
||||
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
|
||||
@@ -1990,8 +1851,6 @@ class MainWindow(
|
||||
except Empty:
|
||||
break
|
||||
if launch_started:
|
||||
if not self.game_launch_started:
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
self.game_launch_started = True
|
||||
continue
|
||||
if status is not None:
|
||||
@@ -2029,8 +1888,6 @@ class MainWindow(
|
||||
self._update_playtime_after_exit(start_exe, elapsed)
|
||||
self.game_start_time = None
|
||||
self.game_start_exe = None
|
||||
self.game_launch_monotonic = None
|
||||
self.game_stopped_by_user = False
|
||||
|
||||
self.target_exe = None
|
||||
# Reset dependency setup monitoring
|
||||
@@ -2041,7 +1898,7 @@ class MainWindow(
|
||||
self.game_processes = [proc for proc in self.game_processes if proc.poll() is None]
|
||||
if not getattr(self, "_animated_covers_suspended", False):
|
||||
self.input_manager.resume_gamepad_polling()
|
||||
self._refresh_portproton_shortcuts()
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _update_game_list_playtime(
|
||||
self, games: list[tuple], exe_path: str, additional_seconds: int
|
||||
@@ -2153,50 +2010,6 @@ class MainWindow(
|
||||
if state is not None:
|
||||
self.launch_output_queue.put(state)
|
||||
|
||||
def _analyze_short_launch(self) -> None:
|
||||
if not ui_config.get_crash_reports_enabled():
|
||||
return
|
||||
executable = getattr(self, "game_start_exe", None) or ""
|
||||
started = getattr(self, "game_launch_monotonic", None)
|
||||
if started is None:
|
||||
return
|
||||
duration = time.monotonic() - started
|
||||
stopped_by_user = getattr(self, "game_stopped_by_user", False)
|
||||
exit_code = next(
|
||||
(process.poll() for process in self.game_processes if process.poll() is not None),
|
||||
None,
|
||||
)
|
||||
launch = CompatibilityLaunch(
|
||||
executable=executable,
|
||||
exit_code=exit_code,
|
||||
duration=duration,
|
||||
)
|
||||
worker = Thread(
|
||||
target=self._build_compatibility_report,
|
||||
args=(launch, stopped_by_user),
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
|
||||
def _build_compatibility_report(
|
||||
self, launch: CompatibilityLaunch, stopped_by_user: bool
|
||||
) -> None:
|
||||
try:
|
||||
incompatible_dxvk = has_dxvk_vulkan_incompatibility(launch.executable)
|
||||
if not incompatible_dxvk and not is_suspected_crash(
|
||||
launch.duration, stopped_by_user, launch.executable
|
||||
):
|
||||
return
|
||||
report = analyze_launch(launch, self.portproton_location or "")
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
logger.error("Compatibility analysis failed for %s: %s", launch.executable, error)
|
||||
return
|
||||
self.compatibility_report_ready.emit(report)
|
||||
|
||||
def _show_compatibility_report(self, report: str) -> None:
|
||||
dialog = CompatibilityReportDialog(self, self.theme, report)
|
||||
dialog.exec()
|
||||
|
||||
def _terminate_game_processes(self) -> None:
|
||||
for proc in self.game_processes:
|
||||
if proc.poll() is not None:
|
||||
@@ -2224,8 +2037,6 @@ class MainWindow(
|
||||
"""Stop current game via PortProton CLI stop command."""
|
||||
if button is not None:
|
||||
self.current_running_button = button
|
||||
self.game_stopped_by_user = True
|
||||
self._analyze_short_launch()
|
||||
|
||||
if not self._run_portproton_stop_command():
|
||||
return False
|
||||
@@ -2417,89 +2228,19 @@ class MainWindow(
|
||||
for command in get_steam_launch_commands(appid):
|
||||
try:
|
||||
subprocess.Popen(command)
|
||||
SoundManager().play("game_launch")
|
||||
return
|
||||
except OSError as e:
|
||||
logger.warning("Failed to launch Steam app %s with %s: %s", appid, command[0], e)
|
||||
|
||||
if QDesktopServices.openUrl(QUrl(exec_line)):
|
||||
SoundManager().play("game_launch")
|
||||
else:
|
||||
if not QDesktopServices.openUrl(QUrl(exec_line)):
|
||||
logger.warning("Failed to open Steam URI: %s", exec_line)
|
||||
|
||||
def _handle_gog_game(self, exec_line: str, button=None) -> None:
|
||||
action, app_id = exec_line.removeprefix("gog://").split("/", 1)
|
||||
if action == "install" and self.gog_api.is_game_installed(app_id):
|
||||
action = "launch"
|
||||
if action == "launch":
|
||||
self._launch_gog_game(app_id, button)
|
||||
return
|
||||
game = next(
|
||||
(item for item in self.gog_api.load_library()
|
||||
if str(item.get("app_id", "")) == app_id),
|
||||
None,
|
||||
)
|
||||
if game:
|
||||
self._install_gog_game(game)
|
||||
|
||||
def _launch_gog_game(self, app_id: str, button=None) -> None:
|
||||
self.gog_api.ensure_launch_parameters(app_id)
|
||||
discovered_path = self.gog_api.get_installed_path(app_id)
|
||||
install_path = str(discovered_path) if discovered_path else ""
|
||||
if not install_path:
|
||||
QMessageBox.warning(self, _("Error"), _("Game not found."))
|
||||
return
|
||||
if not self.start_sh:
|
||||
QMessageBox.warning(self, _("Error"), _("PortProton start script not found"))
|
||||
return
|
||||
target = os.path.basename(self.gog_api.get_launch_target(app_id) or app_id)
|
||||
if self.game_processes and self.target_exe == target:
|
||||
self.stop_running_game(button)
|
||||
return
|
||||
if self.game_processes:
|
||||
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||
return
|
||||
needs_setup = getattr(self.gog_api, "needs_support_setup", lambda _app_id: False)
|
||||
if needs_setup(app_id):
|
||||
self._start_gog_support_setup(app_id, button)
|
||||
return
|
||||
try:
|
||||
command = self.gog_api.build_command([
|
||||
"launch", install_path, app_id, "--platform", "windows",
|
||||
"--wine", self.start_sh[0],
|
||||
])
|
||||
process = subprocess.Popen(
|
||||
command, env=self.gog_api.get_environment(), shell=False,
|
||||
preexec_fn=os.setsid, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True, errors="replace",
|
||||
)
|
||||
except (OSError, ValueError) as error:
|
||||
logger.error("Failed to launch GOG game %s: %s", app_id, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
SoundManager().play("game_launch")
|
||||
self.game_processes.append(process)
|
||||
self.target_exe = target
|
||||
self.current_running_button = button
|
||||
self._start_launch_output_reader(process)
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||
self.checkProcessTimer.start(500)
|
||||
if button is not None:
|
||||
button.setText(_("Stop"))
|
||||
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
|
||||
|
||||
def toggleGame(self, exec_line, button=None, game_name=None):
|
||||
# Handle Steam games
|
||||
if exec_line.startswith("steam://"):
|
||||
self._launch_steam_game(exec_line)
|
||||
return
|
||||
|
||||
if exec_line.startswith("gog://"):
|
||||
self._handle_gog_game(exec_line, button)
|
||||
return
|
||||
|
||||
# Handle PortProton games
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
@@ -2563,7 +2304,6 @@ class MainWindow(
|
||||
|
||||
# Launch game
|
||||
try:
|
||||
self.game_stopped_by_user = False
|
||||
process = subprocess.Popen(
|
||||
launch_cmd,
|
||||
env=env_vars,
|
||||
@@ -2575,9 +2315,7 @@ class MainWindow(
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
)
|
||||
SoundManager().play("game_launch")
|
||||
self.game_processes.append(process)
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
self._start_launch_output_reader(process)
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
launch_time = datetime.now()
|
||||
@@ -2683,7 +2421,6 @@ class MainWindow(
|
||||
|
||||
def goBackDetailPage(self, page):
|
||||
"""Bridge method to detail page manager."""
|
||||
SoundManager().play("back")
|
||||
result = self.detail_page_manager.goBackDetailPage(page)
|
||||
# The detail page manager will handle the navigation properly
|
||||
return result
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
install_data(
|
||||
['__init__.py',
|
||||
'app.py',
|
||||
'appimage_integration.py',
|
||||
'appimage_updater.py',
|
||||
'cli.py',
|
||||
'context_menu_manager.py',
|
||||
@@ -10,11 +9,8 @@ install_data(
|
||||
'dialogs.py',
|
||||
'disc_image_utils.py',
|
||||
'downloader.py',
|
||||
'compatibility_report.py',
|
||||
'game_card.py',
|
||||
'gamepad_backend.py',
|
||||
'game_library_manager.py',
|
||||
'gog_api.py',
|
||||
'howlongtobeat_api.py',
|
||||
'image_utils.py',
|
||||
'icon_extractor.py',
|
||||
@@ -28,9 +24,8 @@ install_data(
|
||||
'preloader.py',
|
||||
'qt_utils.py',
|
||||
'search_utils.py',
|
||||
'settings_manager.py',
|
||||
'sound_manager.py',
|
||||
'theme_manager.py',
|
||||
'settings_manager.py',
|
||||
'theme_manager.py',
|
||||
'theme_security.py',
|
||||
'time_utils.py',
|
||||
'tray_manager.py',
|
||||
@@ -40,16 +35,13 @@ install_data(
|
||||
)
|
||||
|
||||
install_data(
|
||||
['compatibility_rules.json',
|
||||
'keyboard_layouts.json',
|
||||
],
|
||||
'keyboard_layouts.json',
|
||||
install_dir: pythondir / meson.project_name(),
|
||||
)
|
||||
|
||||
# Install animations package
|
||||
install_data(
|
||||
['animations/__init__.py',
|
||||
'animations/detail_background.py',
|
||||
'animations/detail_page.py',
|
||||
'animations/game_card.py',
|
||||
'animations/library_controls.py',
|
||||
@@ -75,7 +67,6 @@ install_data(
|
||||
'dialogs/appimage_update.py',
|
||||
'dialogs/base.py',
|
||||
'dialogs/dialog_utils.py',
|
||||
'dialogs/compatibility_report.py',
|
||||
'dialogs/file_explorer.py',
|
||||
'dialogs/winetricks_dialog.py',
|
||||
'dialogs/settings_dialog.py',
|
||||
@@ -117,7 +108,6 @@ install_data(
|
||||
['tabs/__init__.py',
|
||||
'tabs/autoinstall_tab.py',
|
||||
'tabs/control_hints.py',
|
||||
'tabs/gog_tab.py',
|
||||
'tabs/library_tab.py',
|
||||
'tabs/settings_tab.py',
|
||||
'tabs/system_tab.py',
|
||||
|
||||
@@ -30,7 +30,7 @@ from portprotonqt.image_utils import (
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
AUTOINSTALL_API_URL = "https://linux-gaming.ru/api/games/autoinstall"
|
||||
AUTOINSTALL_API_URL = "https://ppdb.linux-gaming.ru/api/games/autoinstall"
|
||||
HEAD_FAILURE_RETRY_DELAY = 60 # 1 minute cooldown for failed HEAD checks
|
||||
HEAD_CACHE_DURATION = 24 * 60 * 60
|
||||
HEAD_CACHE_NAME = "head_cache"
|
||||
@@ -560,7 +560,7 @@ class PortProtonAPI:
|
||||
def get_ppdb_url(self, game_name: str, exe_name: str) -> str:
|
||||
"""Get the PPDB URL for a given game.
|
||||
|
||||
Makes an API call to linux-gaming.ru to look up the game by exe name.
|
||||
Makes an API call to ppdb.linux-gaming.ru to look up the game by exe name.
|
||||
If the returned name matches the game name, returns the direct URL.
|
||||
Otherwise returns a search URL to avoid false positives (e.g., launcher.exe matches many games).
|
||||
|
||||
@@ -571,7 +571,7 @@ class PortProtonAPI:
|
||||
Returns:
|
||||
Full URL to the PPDB page or search page
|
||||
"""
|
||||
base_url = "https://linux-gaming.ru"
|
||||
base_url = "https://ppdb.linux-gaming.ru"
|
||||
|
||||
# Ensure exe_name has .exe extension
|
||||
if not exe_name.lower().endswith(".exe"):
|
||||
|
||||
@@ -2,8 +2,6 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
UNREAL_BOOTSTRAPPER_MAX_SIZE = 1_048_576
|
||||
|
||||
DIRECTX_IMPORTS = {
|
||||
"ddraw.dll": "DirectDraw",
|
||||
"d3d8.dll": "DirectX 8",
|
||||
@@ -55,43 +53,20 @@ def analyze_executable(file_path: str) -> dict[str, Any]:
|
||||
path = Path(file_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
analysis_path = resolve_graphics_executable(str(path))
|
||||
|
||||
directx = set()
|
||||
opengl = set()
|
||||
|
||||
if analysis_path.suffix.lower() in {".exe", ".dll"}:
|
||||
_add_pe_imports(analysis_path, directx, opengl)
|
||||
_add_file_signatures(analysis_path, directx, opengl)
|
||||
if path.suffix.lower() in {".exe", ".dll"}:
|
||||
_add_pe_imports(path, directx, opengl)
|
||||
_add_file_signatures(path, directx, opengl)
|
||||
|
||||
return {
|
||||
"highest_directx": _highest_directx(list(directx)),
|
||||
"uses_opengl": bool(opengl),
|
||||
"source": str(analysis_path),
|
||||
}
|
||||
|
||||
|
||||
def resolve_graphics_executable(file_path: str) -> Path:
|
||||
"""Resolve a launcher to the binary that implements its graphics API."""
|
||||
path = Path(file_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
if path.stat().st_size < UNREAL_BOOTSTRAPPER_MAX_SIZE:
|
||||
patterns = (
|
||||
"*/Binaries/Win64/*-Win64-Shipping.exe",
|
||||
"*/Binaries/Win32/*-Win32-Shipping.exe",
|
||||
)
|
||||
for pattern in patterns:
|
||||
shipping_binaries = sorted(path.parent.glob(pattern))
|
||||
if shipping_binaries:
|
||||
return shipping_binaries[0]
|
||||
if (path.parent / "renpy").is_dir():
|
||||
renpy_libraries = sorted(path.parent.glob("lib/*/librenpython.dll"))
|
||||
if renpy_libraries:
|
||||
return renpy_libraries[0]
|
||||
return path
|
||||
|
||||
|
||||
def _add_pe_imports(path: Path, directx: set[str], opengl: set[str]) -> None:
|
||||
try:
|
||||
import pefile
|
||||
|
||||
@@ -48,7 +48,7 @@ def find_ext_ppdb(exe_path: str) -> bool:
|
||||
return True
|
||||
|
||||
exe_filename = os.path.basename(exe_path)
|
||||
api_url = "https://linux-gaming.ru/api/lookup/exe/"
|
||||
api_url = "https://ppdb.linux-gaming.ru/api/lookup/exe/"
|
||||
api_url += urllib.parse.quote(exe_filename)
|
||||
session = get_requests_session()
|
||||
|
||||
|
||||
@@ -18,12 +18,17 @@ MIN_WORD_SEARCH_LENGTH = 3
|
||||
def build_search_items(
|
||||
entries: list[Any],
|
||||
name_getter: Callable[[Any], Any],
|
||||
description_getter: Callable[[Any], Any],
|
||||
) -> list[tuple[str, Any]]:
|
||||
items: list[tuple[str, Any]] = []
|
||||
for entry in entries:
|
||||
raw_name = name_getter(entry)
|
||||
raw_description = description_getter(entry)
|
||||
name = str(raw_name).lower() if raw_name else ""
|
||||
description = str(raw_description).lower() if raw_description else ""
|
||||
items.append((name, entry))
|
||||
if description:
|
||||
items.append((description, entry))
|
||||
for word in name.split():
|
||||
if len(word) >= MIN_WORD_SEARCH_LENGTH:
|
||||
items.append((word, entry))
|
||||
@@ -36,10 +41,6 @@ def search_index(
|
||||
limit: int = SEARCH_RESULT_LIMIT,
|
||||
min_score: float = SEARCH_MIN_SCORE,
|
||||
) -> list[Any]:
|
||||
if len(search_text) < MIN_WORD_SEARCH_LENGTH:
|
||||
prefix_results = optimizer.prefix_search(search_text)
|
||||
return _unique_search_results([payload for _match_text, payload in prefix_results])
|
||||
|
||||
exact_result = optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
return [exact_result]
|
||||
|
||||
@@ -321,14 +321,6 @@ def get_advanced_settings(disabled_text, logical_core_options=None, locale_optio
|
||||
'default': ''
|
||||
})
|
||||
|
||||
advanced_settings.append({
|
||||
'key': 'PW_NETWORK_BLOCKLIST',
|
||||
'name': _("Blocked Network Destinations"),
|
||||
'description': _("Comma-separated IP addresses, domains, wildcard patterns, or absolute paths to hosts files. Example: 192.0.2.1,example.com,*.ru,/home/user/hosts"),
|
||||
'type': 'text',
|
||||
'default': ''
|
||||
})
|
||||
|
||||
# 8. CPU cores limit
|
||||
advanced_settings.append({
|
||||
'key': 'PW_WINE_CPU_TOPOLOGY',
|
||||
@@ -413,7 +405,6 @@ ADVANCED_SETTING_KEYS = [
|
||||
'WINEDLLOVERRIDES',
|
||||
'LAUNCH_PARAMETERS',
|
||||
'PW_RUN_AFTER_EXE',
|
||||
'PW_NETWORK_BLOCKLIST',
|
||||
'PW_WINE_CPU_TOPOLOGY',
|
||||
'PW_MESA_GL_VERSION_OVERRIDE',
|
||||
'PW_VKD3D_FEATURE_LEVEL',
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Sound manager for UI feedback sounds, integrated with the theme system."""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QUrl, qInstallMessageHandler
|
||||
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_security import is_safe_sound_file
|
||||
|
||||
logger = get_logger(__name__)
|
||||
SOUND_EVENTS = frozenset({
|
||||
"navigate", "click", "back", "toggle", "open", "tab_switch",
|
||||
"game_launch", "gamepad_connect", "gamepad_off",
|
||||
})
|
||||
MISSING_MEDIA_BACKEND_WARNING = "No QtMultimedia backends found."
|
||||
|
||||
# Force Qt Multimedia FFmpeg backend to use PulseAudio instead of PipeWire.
|
||||
# PipeWire thread-loop initialisation deadlocks with the Python GIL:
|
||||
# QSoundEffect() → pw_thread_loop_lock() holds GIL
|
||||
# PipeWire QAudioContext callback → PyGILState_Ensure() waits for GIL
|
||||
os.environ.setdefault("QT_AUDIO_BACKEND", "pulseaudio")
|
||||
|
||||
|
||||
def _audio_service_available() -> bool:
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if not runtime_dir:
|
||||
return False
|
||||
socket_paths = (
|
||||
Path(runtime_dir) / "pipewire-0",
|
||||
Path(runtime_dir) / "pulse" / "native",
|
||||
)
|
||||
for socket_path in socket_paths:
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as audio_socket:
|
||||
audio_socket.settimeout(0.05)
|
||||
audio_socket.connect(str(socket_path))
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _multimedia_message_handler(message_type: object, context: object, message: str) -> None:
|
||||
if message.startswith(MISSING_MEDIA_BACKEND_WARNING):
|
||||
return
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
class _SoundSlot:
|
||||
"""A single player slot that pre-loads a sound file."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
previous_handler = qInstallMessageHandler(_multimedia_message_handler)
|
||||
try:
|
||||
from PySide6.QtMultimedia import QSoundEffect
|
||||
self._effect = QSoundEffect()
|
||||
finally:
|
||||
qInstallMessageHandler(previous_handler)
|
||||
self._effect.setLoopCount(1)
|
||||
self._loaded_event: str | None = None
|
||||
|
||||
def play(self, event: str, url: QUrl) -> None:
|
||||
if self._loaded_event != event:
|
||||
self._effect.setSource(url)
|
||||
self._loaded_event = event
|
||||
self._effect.stop()
|
||||
self._effect.play()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._effect.stop()
|
||||
self._effect.setSource(QUrl())
|
||||
self._loaded_event = None
|
||||
|
||||
|
||||
class SoundManager:
|
||||
"""Manages UI feedback sounds, loading from theme directories."""
|
||||
|
||||
_instance: "SoundManager | None" = None
|
||||
|
||||
def __new__(cls) -> "SoundManager":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self._enabled = ui_config.get_sounds_enabled()
|
||||
self._sounds_dirs: list[str] = []
|
||||
try:
|
||||
if QCoreApplication.instance() is None or not _audio_service_available():
|
||||
raise RuntimeError("audio service is unavailable")
|
||||
self._slots = [_SoundSlot() for _ in range(4)]
|
||||
except Exception as e:
|
||||
logger.warning("UI sounds are unavailable: %s", e)
|
||||
self._slots = []
|
||||
self._slot_index = 0
|
||||
self._url_cache: dict[str, QUrl] = {}
|
||||
|
||||
def set_sounds_dirs(self, dirs: list[str]) -> None:
|
||||
if dirs != self._sounds_dirs:
|
||||
self._sounds_dirs = dirs
|
||||
self._url_cache.clear()
|
||||
for slot in self._slots:
|
||||
slot.reset()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
self._enabled = ui_config.get_sounds_enabled()
|
||||
|
||||
def _find_sound_path(self, event: str) -> Path | None:
|
||||
if event not in SOUND_EVENTS:
|
||||
return None
|
||||
for sounds_dir in self._sounds_dirs:
|
||||
path = Path(sounds_dir) / f"{event}.wav"
|
||||
if path.is_file() and is_safe_sound_file(str(path)):
|
||||
return path
|
||||
return None
|
||||
|
||||
def _get_url(self, event: str) -> QUrl | None:
|
||||
if event in self._url_cache:
|
||||
return self._url_cache[event]
|
||||
path = self._find_sound_path(event)
|
||||
if path is None:
|
||||
return None
|
||||
url = QUrl.fromLocalFile(str(path))
|
||||
self._url_cache[event] = url
|
||||
return url
|
||||
|
||||
def play(self, event: str) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
url = self._get_url(event)
|
||||
if url is None or not self._slots:
|
||||
return
|
||||
try:
|
||||
for slot in self._slots:
|
||||
if slot._loaded_event == event:
|
||||
slot.play(event, url)
|
||||
return
|
||||
slot = self._slots[self._slot_index]
|
||||
self._slot_index = (self._slot_index + 1) % len(self._slots)
|
||||
slot.play(event, url)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to play UI sound %s: %s", event, e)
|
||||
|
||||
def play_widget_sound(self, widget: object) -> None:
|
||||
from PySide6.QtWidgets import QCheckBox, QPushButton, QTabBar
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, ClickableLabel, NavLabel
|
||||
property_method = getattr(widget, "property", None)
|
||||
sound_event = property_method("sound_event") if callable(property_method) else None
|
||||
if sound_event is False:
|
||||
return
|
||||
if isinstance(sound_event, str) and sound_event in SOUND_EVENTS:
|
||||
self.play(sound_event)
|
||||
return
|
||||
if isinstance(widget, (QPushButton, AutoSizeButton)):
|
||||
self.play("click")
|
||||
elif isinstance(widget, QCheckBox):
|
||||
self.play("toggle")
|
||||
elif isinstance(widget, QTabBar):
|
||||
self.play("tab_switch")
|
||||
elif isinstance(widget, (ClickableLabel, NavLabel)):
|
||||
self.play("click")
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
self._enabled = enabled
|
||||
ui_config.set_sounds_enabled(enabled)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
@@ -11,14 +11,12 @@ from portprotonqt.steam_api.utils import (
|
||||
get_steam_proton_versions,
|
||||
get_playtime_data,
|
||||
get_steam_installed_games,
|
||||
get_local_steam_cover,
|
||||
normalize_name,
|
||||
is_valid_candidate,
|
||||
filter_candidates,
|
||||
remove_duplicates,
|
||||
convert_steam_id,
|
||||
get_last_steam_user,
|
||||
get_steam_users,
|
||||
)
|
||||
from portprotonqt.steam_api.cache import (
|
||||
get_exiftool_data,
|
||||
@@ -69,14 +67,12 @@ __all__ = [
|
||||
"get_steam_proton_versions",
|
||||
"get_playtime_data",
|
||||
"get_steam_installed_games",
|
||||
"get_local_steam_cover",
|
||||
"normalize_name",
|
||||
"is_valid_candidate",
|
||||
"filter_candidates",
|
||||
"remove_duplicates",
|
||||
"convert_steam_id",
|
||||
"get_last_steam_user",
|
||||
"get_steam_users",
|
||||
# Cache
|
||||
"get_exiftool_data",
|
||||
"delete_cached_app_files",
|
||||
|
||||
@@ -601,7 +601,6 @@ def get_steam_game_info_async(
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_gog_uri = exec_line.startswith("gog://")
|
||||
|
||||
if game_exe.lower().endswith(('.bat', '.cmd')):
|
||||
if os.path.exists(game_exe):
|
||||
@@ -623,9 +622,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if is_gog_uri:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
if not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
meta_data = {}
|
||||
else:
|
||||
@@ -647,16 +644,14 @@ def get_steam_game_info_async(
|
||||
candidates.append(file_description)
|
||||
if desktop_name:
|
||||
candidates.append(desktop_name)
|
||||
if exe_name and not is_gog_uri:
|
||||
if exe_name:
|
||||
candidates.append(exe_name)
|
||||
if folder_name and not is_gog_uri:
|
||||
if folder_name:
|
||||
candidates.append(folder_name)
|
||||
candidates = filter_candidates(candidates)
|
||||
candidates = remove_duplicates(candidates)
|
||||
candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True)
|
||||
has_custom_data = (
|
||||
is_autoinstall or is_gog_uri or _has_custom_data_for_exe(exe_name)
|
||||
)
|
||||
has_custom_data = is_autoinstall or _has_custom_data_for_exe(exe_name)
|
||||
|
||||
def on_steam_apps_and_index(
|
||||
data_and_index: tuple[list | None, dict | None]
|
||||
|
||||
@@ -19,7 +19,6 @@ from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.config import (
|
||||
extract_exec_target_path,
|
||||
game_config,
|
||||
get_portproton_location,
|
||||
get_portproton_start_command,
|
||||
ui_config,
|
||||
@@ -28,7 +27,6 @@ from portprotonqt.steam_api.utils import (
|
||||
safe_vdf_load,
|
||||
get_steam_home,
|
||||
get_last_steam_user,
|
||||
get_steam_users,
|
||||
convert_steam_id,
|
||||
)
|
||||
from portprotonqt.steam_api.api import (
|
||||
@@ -403,26 +401,15 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
logger.error("Steam home directory not found")
|
||||
return (False, "Steam directory not found")
|
||||
|
||||
userdata_dir = steam_home / "userdata"
|
||||
selected_account = game_config.get_steam_account_id()
|
||||
if selected_account == "all":
|
||||
steam_users = get_steam_users(steam_home)
|
||||
user_dirs = [
|
||||
userdata_dir / str(convert_steam_id(int(user_id)))
|
||||
for user_id in steam_users
|
||||
]
|
||||
if not user_dirs:
|
||||
logger.error("Failed to retrieve Steam user IDs")
|
||||
return (False, "Failed to get Steam user IDs")
|
||||
else:
|
||||
last_user = get_last_steam_user(steam_home)
|
||||
if not last_user or 'SteamID' not in last_user:
|
||||
logger.error("Failed to retrieve Steam user ID")
|
||||
return (False, "Failed to get Steam user ID")
|
||||
unsigned_id = convert_steam_id(last_user['SteamID'])
|
||||
user_dirs = [userdata_dir / str(unsigned_id)]
|
||||
last_user = get_last_steam_user(steam_home)
|
||||
if not last_user or 'SteamID' not in last_user:
|
||||
logger.error("Failed to retrieve Steam user ID")
|
||||
return (False, "Failed to get Steam user ID")
|
||||
|
||||
user_dir = user_dirs[0]
|
||||
userdata_dir = steam_home / "userdata"
|
||||
user_id = last_user['SteamID']
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
user_dir = userdata_dir / str(unsigned_id)
|
||||
steam_shortcuts_path = user_dir / "config" / "shortcuts.vdf"
|
||||
grid_dir = user_dir / "config" / "grid"
|
||||
os.makedirs(grid_dir, exist_ok=True)
|
||||
@@ -430,17 +417,15 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
appid = None
|
||||
was_api_used = False
|
||||
|
||||
api_response = None
|
||||
if selected_account != "all":
|
||||
logger.info("Attempting to add shortcut via Steam CEF API")
|
||||
api_response = call_steam_api(
|
||||
"createShortcut",
|
||||
game_name,
|
||||
script_path,
|
||||
str(Path(script_path).parent),
|
||||
icon_path,
|
||||
""
|
||||
)
|
||||
logger.info("Attempting to add shortcut via Steam CEF API")
|
||||
api_response = call_steam_api(
|
||||
"createShortcut",
|
||||
game_name,
|
||||
script_path,
|
||||
str(Path(script_path).parent),
|
||||
icon_path,
|
||||
""
|
||||
)
|
||||
|
||||
if api_response and isinstance(api_response, dict) and 'id' in api_response:
|
||||
appid = api_response['id']
|
||||
@@ -448,13 +433,11 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
logger.info(f"Shortcut successfully added via API. AppID: {appid}")
|
||||
else:
|
||||
logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf")
|
||||
for user_dir in user_dirs:
|
||||
steam_shortcuts_path = user_dir / "config" / "shortcuts.vdf"
|
||||
appid, error = _add_shortcut_via_vdf(
|
||||
game_name, script_path, icon_path, steam_shortcuts_path, user_dir
|
||||
)
|
||||
if error:
|
||||
return (False, error)
|
||||
appid, error = _add_shortcut_via_vdf(
|
||||
game_name, script_path, icon_path, steam_shortcuts_path, user_dir
|
||||
)
|
||||
if error:
|
||||
return (False, error)
|
||||
|
||||
if not appid:
|
||||
return (False, "Failed to create shortcut using any method")
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import BinaryIO
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
import vdf
|
||||
|
||||
from portprotonqt.config import game_config
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
|
||||
@@ -254,28 +253,16 @@ def get_steam_compat_tool(appid: int) -> str | None:
|
||||
|
||||
def get_last_steam_user(steam_home: Path) -> dict | None:
|
||||
"""Return data for last Steam user from loginusers.vdf."""
|
||||
users = get_steam_users(steam_home)
|
||||
configured_user_id = game_config.get_steam_account_id()
|
||||
if configured_user_id != "auto" and configured_user_id in users:
|
||||
return {'SteamID': int(configured_user_id)}
|
||||
loginusers_path = steam_home / "config/loginusers.vdf"
|
||||
data = safe_vdf_load(loginusers_path)
|
||||
users = data.get('users', {}) if data else {}
|
||||
selected_user_id = None
|
||||
auto_login_user_id = None
|
||||
most_recent_user_id = None
|
||||
latest_timestamp = -1
|
||||
for user_id, user_info in users.items():
|
||||
try:
|
||||
timestamp = int(user_info.get('Timestamp', ''))
|
||||
except ValueError:
|
||||
timestamp = -1
|
||||
if timestamp > latest_timestamp:
|
||||
latest_timestamp = timestamp
|
||||
if user_info.get('MostRecent') == '1':
|
||||
selected_user_id = user_id
|
||||
break
|
||||
if selected_user_id is None and user_info.get('AutoLogin') == '1':
|
||||
selected_user_id = user_id
|
||||
if auto_login_user_id is None and user_info.get('AutoLogin') == '1':
|
||||
auto_login_user_id = user_id
|
||||
if most_recent_user_id is None and user_info.get('MostRecent') == '1':
|
||||
most_recent_user_id = user_id
|
||||
if selected_user_id is None:
|
||||
selected_user_id = auto_login_user_id or most_recent_user_id
|
||||
if selected_user_id is None:
|
||||
userdata_dir = steam_home / "userdata"
|
||||
try:
|
||||
@@ -303,17 +290,6 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_steam_users(steam_home: Path) -> dict[str, dict]:
|
||||
"""Return valid Steam users from loginusers.vdf."""
|
||||
data = safe_vdf_load(steam_home / "config/loginusers.vdf")
|
||||
users = data.get("users", {}) if data else {}
|
||||
return {
|
||||
str(user_id): user_info
|
||||
for user_id, user_info in users.items()
|
||||
if str(user_id).isdigit() and isinstance(user_info, dict)
|
||||
}
|
||||
|
||||
|
||||
def convert_steam_id(steam_id: int) -> int:
|
||||
"""Convert signed 32-bit integer to unsigned 32-bit integer."""
|
||||
return steam_id & 0xFFFFFFFF
|
||||
@@ -497,10 +473,13 @@ def _load_steam_app_metadata(
|
||||
return metadata
|
||||
|
||||
|
||||
def _is_steam_game(common: dict) -> bool:
|
||||
"""Return whether Steam metadata describes a game."""
|
||||
def _is_windows_steam_game(common: dict) -> bool:
|
||||
"""Return whether Steam metadata describes a non-native Windows game."""
|
||||
app_type = str(common.get("type", "")).lower()
|
||||
return app_type == "game"
|
||||
operating_systems = {
|
||||
value.strip().lower() for value in str(common.get("oslist", "")).split(",")
|
||||
}
|
||||
return app_type == "game" and "windows" in operating_systems and "linux" not in operating_systems
|
||||
|
||||
|
||||
def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
|
||||
@@ -532,7 +511,7 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
|
||||
)
|
||||
for app, appid in installed_apps:
|
||||
common = app_metadata.get(appid) if app_metadata is not None else None
|
||||
if common is not None and not _is_steam_game(common):
|
||||
if common is not None and not _is_windows_steam_game(common):
|
||||
continue
|
||||
name = app.get('name', f"Unknown ({appid})")
|
||||
lname = name.lower()
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
"""System manager package for network, bluetooth, storage and audio services."""
|
||||
|
||||
from portprotonqt.system_manager.audio import AudioManagerError, AudioManagerService, AudioManagerWorker
|
||||
from portprotonqt.system_manager.bluetooth import (
|
||||
BluetoothManagerError,
|
||||
BluetoothManagerService,
|
||||
BluetoothManagerWorker,
|
||||
)
|
||||
from portprotonqt.system_manager.common import (
|
||||
DbusFastSystemBus,
|
||||
NetworkManagerError,
|
||||
SystemManagerError,
|
||||
)
|
||||
from portprotonqt.system_manager.audio import AudioManagerService, AudioManagerWorker
|
||||
from portprotonqt.system_manager.bluetooth import BluetoothManagerService, BluetoothManagerWorker
|
||||
from portprotonqt.system_manager.common import DbusFastSystemBus, NetworkManagerError
|
||||
from portprotonqt.system_manager.network import NetworkManagerService, NetworkManagerWorker
|
||||
from portprotonqt.system_manager.storage import (
|
||||
StorageManagerError,
|
||||
StorageManagerService,
|
||||
StorageManagerWorker,
|
||||
)
|
||||
from portprotonqt.system_manager.storage import StorageManagerService, StorageManagerWorker
|
||||
|
||||
__all__ = [
|
||||
"AudioManagerError",
|
||||
"AudioManagerService",
|
||||
"AudioManagerWorker",
|
||||
"BluetoothManagerError",
|
||||
"BluetoothManagerService",
|
||||
"BluetoothManagerWorker",
|
||||
"DbusFastSystemBus",
|
||||
"NetworkManagerError",
|
||||
"NetworkManagerService",
|
||||
"NetworkManagerWorker",
|
||||
"StorageManagerError",
|
||||
"StorageManagerService",
|
||||
"StorageManagerWorker",
|
||||
"SystemManagerError",
|
||||
]
|
||||
@@ -10,16 +10,12 @@ from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.system_manager.common import SystemManagerError
|
||||
from portprotonqt.system_manager.common import NetworkManagerError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
AUDIO_MAX_VOLUME = 150
|
||||
|
||||
class AudioManagerError(SystemManagerError):
|
||||
"""Raised when audio manager operations fail."""
|
||||
|
||||
|
||||
class AudioManagerWorker(QThread):
|
||||
"""Run audio actions outside the UI thread."""
|
||||
|
||||
@@ -35,7 +31,7 @@ class AudioManagerWorker(QThread):
|
||||
service = AudioManagerService()
|
||||
try:
|
||||
payload = service.execute(self.operation, self.params)
|
||||
except SystemManagerError as exc:
|
||||
except NetworkManagerError as exc:
|
||||
self.operation_failed.emit(self.operation, str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -83,7 +79,7 @@ class AudioManagerService:
|
||||
try:
|
||||
volume_value = int(volume)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AudioManagerError("Invalid volume value") from exc
|
||||
raise NetworkManagerError("Invalid volume value") from exc
|
||||
volume_value = max(0, min(AUDIO_MAX_VOLUME, volume_value))
|
||||
self._run_pactl(["set-sink-volume", sink_name, f"{volume_value}%"])
|
||||
logger.info("Output volume updated")
|
||||
@@ -109,7 +105,7 @@ class AudioManagerService:
|
||||
logger.info("Audio profile updated")
|
||||
|
||||
else:
|
||||
raise AudioManagerError("Unsupported audio operation")
|
||||
raise NetworkManagerError("Unsupported audio operation")
|
||||
|
||||
def list_audio(self) -> dict:
|
||||
if not self.pactl_path:
|
||||
@@ -334,7 +330,7 @@ class AudioManagerService:
|
||||
|
||||
def _run_pactl(self, args: list[str]) -> str:
|
||||
if not self.pactl_path:
|
||||
raise AudioManagerError("pactl is not available")
|
||||
raise NetworkManagerError("pactl is not available")
|
||||
process_env = os.environ.copy()
|
||||
process_env["LC_ALL"] = "C"
|
||||
process_env["LANG"] = "C"
|
||||
@@ -348,7 +344,7 @@ class AudioManagerService:
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
error_text = result.stderr.strip() or result.stdout.strip()
|
||||
raise AudioManagerError(self._clean_audio_error(error_text))
|
||||
raise NetworkManagerError(self._clean_audio_error(error_text))
|
||||
|
||||
def _clean_audio_error(self, error_text: str) -> str:
|
||||
cleaned = (error_text or "").strip()
|
||||
|
||||
@@ -19,7 +19,7 @@ from portprotonqt.system_manager.common import (
|
||||
UPOWER_INTERFACE,
|
||||
UPOWER_PATH,
|
||||
UPOWER_SERVICE,
|
||||
SystemManagerError,
|
||||
NetworkManagerError,
|
||||
Variant,
|
||||
)
|
||||
|
||||
@@ -37,10 +37,6 @@ BLUEZ_AGENT_MANAGER_PATH = "/org/bluez"
|
||||
BLUEZ_AGENT_PATH = "/org/portprotonqt/BluetoothAgent"
|
||||
|
||||
|
||||
class BluetoothManagerError(SystemManagerError):
|
||||
"""Raised when Bluetooth manager operations fail."""
|
||||
|
||||
|
||||
def dbus_method(annotations: dict[str, str]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
func.__annotations__ = annotations
|
||||
@@ -147,7 +143,7 @@ class BluetoothManagerWorker(QThread):
|
||||
try:
|
||||
service = BluetoothManagerService(self._request_pairing_response)
|
||||
payload = service.execute(self.operation, self.params)
|
||||
except SystemManagerError as exc:
|
||||
except NetworkManagerError as exc:
|
||||
self.operation_failed.emit(self.operation, str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -221,7 +217,7 @@ class BluetoothManagerService:
|
||||
elif operation == "forget":
|
||||
self.forget_device(params.get("address", ""))
|
||||
else:
|
||||
raise BluetoothManagerError("Unsupported Bluetooth operation")
|
||||
raise NetworkManagerError("Unsupported Bluetooth operation")
|
||||
|
||||
def list_devices(self, discovered: list[tuple[str, str]] | None = None) -> dict:
|
||||
payload = {
|
||||
@@ -256,7 +252,7 @@ class BluetoothManagerService:
|
||||
finally:
|
||||
try:
|
||||
self._call_bluez(adapter_path, f"{BLUEZ_ADAPTER_INTERFACE}.StopDiscovery")
|
||||
except SystemManagerError:
|
||||
except NetworkManagerError:
|
||||
logger.warning("Failed to stop Bluetooth discovery")
|
||||
return self.list_devices()
|
||||
|
||||
@@ -321,7 +317,7 @@ class BluetoothManagerService:
|
||||
f"{BLUEZ_AGENT_MANAGER_INTERFACE}.UnregisterAgent",
|
||||
BLUEZ_AGENT_PATH,
|
||||
)
|
||||
except SystemManagerError:
|
||||
except NetworkManagerError:
|
||||
logger.warning("Failed to unregister Bluetooth pairing agent")
|
||||
self.dbus.unexport_interface(BLUEZ_AGENT_PATH)
|
||||
self._pairing_agent_registered = False
|
||||
@@ -358,7 +354,7 @@ class BluetoothManagerService:
|
||||
return
|
||||
device = self._require_device(address)
|
||||
if not bool(device.get("connected", False)):
|
||||
raise BluetoothManagerError("The selected Bluetooth device is not connected")
|
||||
raise NetworkManagerError("The selected Bluetooth device is not connected")
|
||||
self._call_bluez(str(device["path"]), f"{BLUEZ_DEVICE_INTERFACE}.Disconnect")
|
||||
logger.info("Bluetooth device disconnected")
|
||||
|
||||
@@ -378,7 +374,7 @@ class BluetoothManagerService:
|
||||
def _get_managed_objects(self) -> dict:
|
||||
raw = self._call_bluez(BLUEZ_ROOT_PATH, f"{BLUEZ_OBJECT_MANAGER_INTERFACE}.GetManagedObjects")
|
||||
if not isinstance(raw, dict):
|
||||
raise BluetoothManagerError("Failed to load Bluetooth objects")
|
||||
raise NetworkManagerError("Failed to load Bluetooth objects")
|
||||
return raw
|
||||
|
||||
def _get_adapter_path(self, objects: dict) -> str:
|
||||
@@ -390,10 +386,10 @@ class BluetoothManagerService:
|
||||
|
||||
def _require_adapter_path(self) -> str:
|
||||
if not self._has_physical_adapter():
|
||||
raise BluetoothManagerError("Bluetooth adapter not found")
|
||||
raise NetworkManagerError("Bluetooth adapter not found")
|
||||
adapter_path = self._get_adapter_path(self._get_managed_objects())
|
||||
if not adapter_path:
|
||||
raise BluetoothManagerError("Bluetooth adapter not found")
|
||||
raise NetworkManagerError("Bluetooth adapter not found")
|
||||
return adapter_path
|
||||
|
||||
def _has_physical_adapter(self) -> bool:
|
||||
@@ -457,14 +453,14 @@ class BluetoothManagerService:
|
||||
def _require_powered(self) -> None:
|
||||
payload = self.list_devices()
|
||||
if not payload["powered"]:
|
||||
raise BluetoothManagerError("Bluetooth is disabled")
|
||||
raise NetworkManagerError("Bluetooth is disabled")
|
||||
|
||||
def _require_device(self, address: str) -> dict:
|
||||
target = address.upper()
|
||||
for device in self.list_devices()["devices"]:
|
||||
if str(device["address"]).upper() == target:
|
||||
return device
|
||||
raise BluetoothManagerError("Bluetooth device not found")
|
||||
raise NetworkManagerError("Bluetooth device not found")
|
||||
|
||||
def _get_bluetooth_state(self, device: dict) -> str:
|
||||
if bool(device.get("connected", False)):
|
||||
@@ -552,7 +548,7 @@ class BluetoothManagerService:
|
||||
member,
|
||||
*args,
|
||||
)
|
||||
except SystemManagerError:
|
||||
except NetworkManagerError:
|
||||
return ""
|
||||
|
||||
def _call_upower_property(self, object_path: str, property_name: str) -> str:
|
||||
@@ -563,7 +559,7 @@ class BluetoothManagerService:
|
||||
"org.freedesktop.UPower.Device",
|
||||
property_name,
|
||||
)
|
||||
except SystemManagerError:
|
||||
except NetworkManagerError:
|
||||
return ""
|
||||
return self._extract_dbus_property_value(output)
|
||||
|
||||
@@ -577,8 +573,8 @@ class BluetoothManagerService:
|
||||
member,
|
||||
*args,
|
||||
)
|
||||
except SystemManagerError as exc:
|
||||
raise BluetoothManagerError(self._clean_bluetooth_error(str(exc))) from exc
|
||||
except NetworkManagerError as exc:
|
||||
raise NetworkManagerError(self._clean_bluetooth_error(str(exc))) from exc
|
||||
|
||||
def _clean_bluetooth_error(self, error_text: str) -> str:
|
||||
cleaned = error_text.split(":", 1)[-1].strip()
|
||||
|
||||
@@ -28,11 +28,7 @@ UPOWER_PATH = "/org/freedesktop/UPower"
|
||||
UPOWER_INTERFACE = "org.freedesktop.UPower"
|
||||
DBUS_PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
|
||||
|
||||
class SystemManagerError(Exception):
|
||||
"""Base error for system manager operations."""
|
||||
|
||||
|
||||
class NetworkManagerError(SystemManagerError):
|
||||
class NetworkManagerError(Exception):
|
||||
"""Raised when NetworkManager D-Bus operations fail."""
|
||||
|
||||
|
||||
@@ -48,7 +44,7 @@ class DbusFastSystemBus:
|
||||
except Exception as exc:
|
||||
asyncio.set_event_loop(None)
|
||||
self._loop.close()
|
||||
raise SystemManagerError("Failed to connect to system D-Bus") from exc
|
||||
raise NetworkManagerError("Failed to connect to system D-Bus") from exc
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
@@ -63,15 +59,15 @@ class DbusFastSystemBus:
|
||||
member_name = self._to_member_name(member)
|
||||
method = getattr(dbus_interface, f"call_{member_name}", None)
|
||||
if method is None:
|
||||
raise SystemManagerError(f"D-Bus method not found: {interface}.{member}")
|
||||
raise NetworkManagerError(f"D-Bus method not found: {interface}.{member}")
|
||||
result = self._run(method(*args))
|
||||
if isinstance(result, tuple) and len(result) == 1:
|
||||
return result[0]
|
||||
return result
|
||||
except SystemManagerError:
|
||||
except NetworkManagerError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise SystemManagerError(str(exc)) from exc
|
||||
raise NetworkManagerError(str(exc)) from exc
|
||||
|
||||
def get_property(self, service: str, path: str, interface: str, prop: str):
|
||||
value = self.call(
|
||||
|
||||
@@ -25,7 +25,6 @@ from portprotonqt.system_manager.common import (
|
||||
NM_WIFI_DEVICE_TYPE,
|
||||
NM_WIRELESS_INTERFACE,
|
||||
NetworkManagerError,
|
||||
SystemManagerError,
|
||||
Variant,
|
||||
)
|
||||
|
||||
@@ -46,7 +45,7 @@ class NetworkManagerWorker(QThread):
|
||||
try:
|
||||
service = NetworkManagerService()
|
||||
payload = service.execute(self.operation, self.params)
|
||||
except SystemManagerError as exc:
|
||||
except NetworkManagerError as exc:
|
||||
self.operation_failed.emit(self.operation, str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
|
||||
@@ -7,14 +7,10 @@ from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.system_manager.common import DbusFastSystemBus, SystemManagerError, Variant
|
||||
from portprotonqt.system_manager.common import DbusFastSystemBus, NetworkManagerError, Variant
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class StorageManagerError(SystemManagerError):
|
||||
"""Raised when storage manager operations fail."""
|
||||
|
||||
|
||||
class StorageManagerWorker(QThread):
|
||||
"""Run storage actions outside the UI thread."""
|
||||
|
||||
@@ -30,7 +26,7 @@ class StorageManagerWorker(QThread):
|
||||
try:
|
||||
service = StorageManagerService()
|
||||
payload = service.execute(self.operation, self.params)
|
||||
except SystemManagerError as exc:
|
||||
except NetworkManagerError as exc:
|
||||
self.operation_failed.emit(self.operation, str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -72,7 +68,7 @@ class StorageManagerService:
|
||||
elif operation == "unmount":
|
||||
self.unmount_device(params.get("device_path", ""))
|
||||
else:
|
||||
raise StorageManagerError("Unsupported storage operation")
|
||||
raise NetworkManagerError("Unsupported storage operation")
|
||||
|
||||
def list_devices(self) -> dict:
|
||||
objects = self._get_managed_objects()
|
||||
@@ -89,7 +85,7 @@ class StorageManagerService:
|
||||
objects = self._get_managed_objects()
|
||||
device = self._require_device(device_path, objects)
|
||||
if device["mounted"]:
|
||||
raise StorageManagerError("The selected device is already mounted")
|
||||
raise NetworkManagerError("The selected device is already mounted")
|
||||
self._call_filesystem_method(device["object_path"], "Mount")
|
||||
logger.info("Device mounted")
|
||||
|
||||
@@ -100,7 +96,7 @@ class StorageManagerService:
|
||||
objects = self._get_managed_objects()
|
||||
device = self._require_device(device_path, objects)
|
||||
if not device["mounted"]:
|
||||
raise StorageManagerError("The selected device is not mounted")
|
||||
raise NetworkManagerError("The selected device is not mounted")
|
||||
self._call_filesystem_method(device["object_path"], "Unmount")
|
||||
logger.info("Device unmounted")
|
||||
|
||||
@@ -112,7 +108,7 @@ class StorageManagerService:
|
||||
"GetManagedObjects",
|
||||
)
|
||||
if not isinstance(managed_objects, dict):
|
||||
raise StorageManagerError("Failed to parse storage device list")
|
||||
raise NetworkManagerError("Failed to parse storage device list")
|
||||
return managed_objects
|
||||
|
||||
def _collect_storage_devices(self, objects: dict) -> list[dict]:
|
||||
@@ -192,7 +188,7 @@ class StorageManagerService:
|
||||
for device in self._collect_storage_devices(objects):
|
||||
if device["path"] == device_path:
|
||||
return device
|
||||
raise StorageManagerError("Storage device not found")
|
||||
raise NetworkManagerError("Storage device not found")
|
||||
|
||||
def _call_filesystem_method(self, object_path: str, method_name: str) -> None:
|
||||
self.dbus.call(
|
||||
|
||||
@@ -333,6 +333,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
build_search_items(
|
||||
self.allAutoInstallCards,
|
||||
lambda card: card.name,
|
||||
lambda card: card.description,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
icon_label,
|
||||
(
|
||||
self.theme_manager.get_icon(icon_name, self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image(icon_name, self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -293,8 +293,8 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
guide_icon,
|
||||
(
|
||||
self.theme_manager.get_icon("xbox_view", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_icon("ps_share", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image("xbox_view", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("ps_share", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -304,8 +304,8 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
guide_icon,
|
||||
(
|
||||
self.theme_manager.get_icon("xbox_xbox", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_icon("ps_ps", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image("xbox_xbox", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("ps_ps", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -318,7 +318,7 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
plus_icon,
|
||||
(
|
||||
self.theme_manager.get_icon("key_+", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image("key_+", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -333,8 +333,8 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
select_icon,
|
||||
(
|
||||
self.theme_manager.get_icon("xbox_start", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_icon("ps_options", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image("xbox_start", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("ps_options", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -344,8 +344,8 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
select_icon,
|
||||
(
|
||||
self.theme_manager.get_icon("xbox_view", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_icon("ps_share", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image("xbox_view", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("ps_share", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -425,7 +425,7 @@ class MainWindowControlHintsMixin:
|
||||
else:
|
||||
left_icon_name = "key_left"
|
||||
left_pix = _load_control_hint_pixmap(
|
||||
(self.theme_manager.get_icon(left_icon_name, self.current_theme_name, as_path=True),),
|
||||
(self.theme_manager.get_theme_image(left_icon_name, self.current_theme_name),),
|
||||
32,
|
||||
32,
|
||||
)
|
||||
@@ -438,7 +438,7 @@ class MainWindowControlHintsMixin:
|
||||
else:
|
||||
right_icon_name = "key_right"
|
||||
right_pix = _load_control_hint_pixmap(
|
||||
(self.theme_manager.get_icon(right_icon_name, self.current_theme_name, as_path=True),),
|
||||
(self.theme_manager.get_theme_image(right_icon_name, self.current_theme_name),),
|
||||
32,
|
||||
32,
|
||||
)
|
||||
@@ -505,7 +505,7 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
guide_icon,
|
||||
(
|
||||
self.theme_manager.get_icon(guide_icon_name, self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image(guide_icon_name, self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -515,7 +515,7 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
plus_icon,
|
||||
(
|
||||
self.theme_manager.get_icon("key_+", self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image("key_+", self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -539,7 +539,7 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
select_icon,
|
||||
(
|
||||
self.theme_manager.get_icon(select_icon_name, self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image(select_icon_name, self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
@@ -557,7 +557,7 @@ class MainWindowControlHintsMixin:
|
||||
_set_control_hint_icon(
|
||||
icon_label,
|
||||
(
|
||||
self.theme_manager.get_icon(icon_name, self.current_theme_name, as_path=True),
|
||||
self.theme_manager.get_theme_image(icon_name, self.current_theme_name),
|
||||
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
|
||||
),
|
||||
26,
|
||||
|
||||
@@ -1,790 +0,0 @@
|
||||
"""Shared download manager with GOG download support."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PySide6.QtCore import QProcess, QProcessEnvironment, QThread, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHeaderView,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
from portprotonqt.gog_api import GOGAPI, GOG_LOGIN_URL
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
GOG_CANCEL_KILL_TIMEOUT_MS = 3000
|
||||
GOG_LOGIN_TIMEOUT_MS = 300000
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
|
||||
_MainWindowTypingBase = QMainWindow
|
||||
else:
|
||||
_MainWindowTypingBase = object
|
||||
|
||||
|
||||
class GOGLibraryWorker(QThread):
|
||||
"""Refresh the GOG library outside the UI thread."""
|
||||
|
||||
loaded = Signal(list)
|
||||
failed = Signal(str)
|
||||
progress = Signal(int, int)
|
||||
|
||||
def __init__(self, api: GOGAPI) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.loaded.emit(self.api.refresh_library(self.progress.emit))
|
||||
except Exception as error:
|
||||
logger.exception("Failed to refresh GOG library")
|
||||
self.failed.emit(str(error))
|
||||
|
||||
|
||||
class GOGAuthWorker(QThread):
|
||||
"""Exchange the GOG OAuth code outside the UI thread."""
|
||||
|
||||
authenticated = Signal(bool, str)
|
||||
|
||||
def __init__(self, api: GOGAPI, code: str) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
self.code = code
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
authenticated, error = self.api.authenticate(self.code)
|
||||
self.authenticated.emit(authenticated, error)
|
||||
except Exception as error:
|
||||
logger.exception("Failed to authenticate with GOG: %s", error)
|
||||
self.authenticated.emit(False, str(error))
|
||||
|
||||
|
||||
class GOGAccountWorker(QThread):
|
||||
"""Load the GOG account name outside the UI thread."""
|
||||
|
||||
loaded = Signal(str)
|
||||
|
||||
def __init__(self, api: GOGAPI) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.loaded.emit(self.api.refresh_account_name())
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh GOG account name")
|
||||
self.loaded.emit("")
|
||||
|
||||
|
||||
class GOGSupportWorker(QThread):
|
||||
"""Install GOG support instructions outside the UI thread."""
|
||||
|
||||
failed = Signal(str)
|
||||
launched = Signal(object)
|
||||
succeeded = Signal()
|
||||
|
||||
def __init__(self, api: GOGAPI, app_id: str, start_command: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
self.app_id = app_id
|
||||
self.start_command = start_command
|
||||
self.error = ""
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.api.install_support(
|
||||
self.app_id, self.start_command, self.launched.emit
|
||||
)
|
||||
self.succeeded.emit()
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
logger.exception("Failed to install GOG support for %s", self.app_id)
|
||||
self.error = str(error)
|
||||
self.failed.emit(self.error)
|
||||
|
||||
|
||||
class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
"""Add account actions and the shared downloads page."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
def __getattr__(self, name: str) -> Any: ...
|
||||
|
||||
def createGOGDownloadsTab(self) -> None:
|
||||
self.gog_process = None
|
||||
self.gog_download_queue = []
|
||||
self.gog_download_output = ""
|
||||
self.gog_support_workers = []
|
||||
self.downloadTableHeadings = {}
|
||||
page = QWidget()
|
||||
page.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setSpacing(self.theme.downloadsSectionSpacing)
|
||||
title = QLabel(_("Downloads"))
|
||||
title.setStyleSheet(self.theme.TAB_TITLE_STYLE)
|
||||
layout.addWidget(title)
|
||||
self._create_active_download_card(layout)
|
||||
self.downloadQueuedTable = self._create_download_table(_("Queued"), layout)
|
||||
completed_header = QHBoxLayout()
|
||||
completed_header.addWidget(QLabel(_("Completed")))
|
||||
clear_button = QPushButton(_("Clear List"))
|
||||
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
clear_button.clicked.connect(self._clear_completed_downloads)
|
||||
completed_header.addWidget(clear_button)
|
||||
completed_header.addStretch()
|
||||
layout.addLayout(completed_header)
|
||||
self.downloadCompletedTable = self._create_download_table("", layout)
|
||||
layout.addStretch()
|
||||
self.stackedWidget.addWidget(page)
|
||||
self._update_downloads_tab_visibility()
|
||||
|
||||
def _update_downloads_tab_visibility(self) -> None:
|
||||
has_downloads = (
|
||||
not self.downloadActiveCard.isHidden()
|
||||
or self.downloadQueuedTable.rowCount() > 0
|
||||
or self.downloadCompletedTable.rowCount() > 0
|
||||
)
|
||||
self.tabButtons[6].setVisible(has_downloads)
|
||||
if not has_downloads and self.stackedWidget.currentIndex() == 6:
|
||||
self.switchTab(0)
|
||||
|
||||
def _create_active_download_card(self, layout: QVBoxLayout) -> None:
|
||||
self.downloadActiveHeading = QLabel(_("Now Downloading"))
|
||||
self.downloadActiveHeading.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
layout.addWidget(self.downloadActiveHeading)
|
||||
self.downloadActiveCard = QFrame()
|
||||
self.downloadActiveCard.setObjectName("downloadsActiveCard")
|
||||
self.downloadActiveCard.setStyleSheet(self.theme.DOWNLOADS_ACTIVE_STYLE)
|
||||
self.downloadActiveCard.setFixedHeight(self.theme.downloadsActiveCardHeight)
|
||||
card_layout = QVBoxLayout(self.downloadActiveCard)
|
||||
card_layout.setContentsMargins(*self.theme.downloadsActiveCardMargins)
|
||||
card_layout.setSpacing(self.theme.downloadsActiveCardSpacing)
|
||||
content = QHBoxLayout()
|
||||
content.setSpacing(self.theme.downloadsActiveCardSpacing)
|
||||
self.downloadActiveCover = QLabel()
|
||||
self.downloadActiveCover.setFixedSize(*self.theme.downloadsActiveCoverSize)
|
||||
content.addWidget(self.downloadActiveCover)
|
||||
info = QVBoxLayout()
|
||||
header = QHBoxLayout()
|
||||
self.downloadActiveTitle = QLabel()
|
||||
self.downloadActiveTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
header.addWidget(self.downloadActiveTitle)
|
||||
header.addStretch()
|
||||
self.downloadCancelButton = QPushButton(_("Cancel"))
|
||||
self.downloadCancelButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.downloadCancelButton.clicked.connect(self._cancel_gog_download)
|
||||
header.addWidget(self.downloadCancelButton)
|
||||
info.addLayout(header)
|
||||
self.downloadActiveDetails = QLabel(_("Waiting…"))
|
||||
self.downloadActiveDetails.setStyleSheet(self.theme.CONTENT_STYLE)
|
||||
info.addWidget(self.downloadActiveDetails)
|
||||
metrics = QHBoxLayout()
|
||||
self.downloadSpeedLabel = QLabel(_("Downloading: ") + "\u2014")
|
||||
self.diskSpeedLabel = QLabel(_("Disk: ") + "\u2014")
|
||||
metrics.addWidget(self.downloadSpeedLabel)
|
||||
metrics.addWidget(self.diskSpeedLabel)
|
||||
metrics.addStretch()
|
||||
info.addLayout(metrics)
|
||||
content.addLayout(info, stretch=1)
|
||||
card_layout.addLayout(content)
|
||||
self.downloadOverallProgress = QProgressBar()
|
||||
self.downloadOverallProgress.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
|
||||
card_layout.addWidget(self.downloadOverallProgress)
|
||||
layout.addWidget(self.downloadActiveCard)
|
||||
self.downloadActiveHeading.setVisible(False)
|
||||
self.downloadActiveCard.setVisible(False)
|
||||
|
||||
def _create_download_table(self, heading: str, layout: QVBoxLayout) -> QTableWidget:
|
||||
label = None
|
||||
if heading:
|
||||
label = QLabel(heading)
|
||||
label.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
layout.addWidget(label)
|
||||
columns = (_("Game Title"), _("Started at"), _("Type"), _("Store"))
|
||||
table = QTableWidget(0, len(columns))
|
||||
table.setHorizontalHeaderLabels(columns)
|
||||
table.setStyleSheet(self.theme.DOWNLOADS_TABLE_STYLE)
|
||||
table.setAlternatingRowColors(True)
|
||||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||||
table.setShowGrid(False)
|
||||
header = table.horizontalHeader()
|
||||
header.setFixedHeight(self.theme.downloadsTableHeaderHeight)
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
for column in range(1, len(columns)):
|
||||
header.setSectionResizeMode(column, QHeaderView.ResizeMode.ResizeToContents)
|
||||
table.verticalHeader().setVisible(False)
|
||||
self._update_download_table_height(table)
|
||||
layout.addWidget(table)
|
||||
if label is not None:
|
||||
self.downloadTableHeadings[table] = label
|
||||
label.setVisible(False)
|
||||
table.setVisible(False)
|
||||
return table
|
||||
|
||||
def _update_download_table_height(self, table: QTableWidget) -> None:
|
||||
rows = max(1, table.rowCount())
|
||||
height = self.theme.downloadsTableHeaderHeight + rows * self.theme.downloadsTableRowHeight
|
||||
table.setFixedHeight(height)
|
||||
has_rows = table.rowCount() > 0
|
||||
table.setVisible(has_rows)
|
||||
heading = self.downloadTableHeadings.get(table)
|
||||
if heading is not None:
|
||||
heading.setVisible(has_rows)
|
||||
|
||||
def _clear_completed_downloads(self) -> None:
|
||||
self.downloadCompletedTable.setRowCount(0)
|
||||
self._update_download_table_height(self.downloadCompletedTable)
|
||||
self._update_downloads_tab_visibility()
|
||||
|
||||
def _start_gog_login(self) -> None:
|
||||
if getattr(self, "gog_auth_worker", None) is not None:
|
||||
return
|
||||
clipboard = QApplication.clipboard()
|
||||
if not getattr(self, "gog_auth_clipboard_connected", False):
|
||||
clipboard.dataChanged.connect(self._check_gog_login_clipboard)
|
||||
self.gog_auth_clipboard_connected = True
|
||||
self.gog_auth_timer = QTimer(self)
|
||||
self.gog_auth_timer.setSingleShot(True)
|
||||
self.gog_auth_timer.timeout.connect(self._cancel_gog_login)
|
||||
self.gog_auth_timer.start(GOG_LOGIN_TIMEOUT_MS)
|
||||
self.gogLoginButton.setEnabled(False)
|
||||
self.gogAccountStatus.setText(_("Copy the link to clipboard"))
|
||||
if not QDesktopServices.openUrl(QUrl(GOG_LOGIN_URL)):
|
||||
self._stop_gog_login_clipboard()
|
||||
self.gogLoginButton.setEnabled(True)
|
||||
self._update_gog_account_state()
|
||||
|
||||
def _handle_gog_account_action(self) -> None:
|
||||
if not self.gog_api.auth_path.is_file():
|
||||
self._start_gog_login()
|
||||
return
|
||||
if not self.gog_api.logout():
|
||||
self.gogAccountStatus.setText(_("Failed to log out of GOG account"))
|
||||
return
|
||||
self._update_gog_account_state()
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _check_gog_login_clipboard(self) -> None:
|
||||
code = self.gog_api.extract_auth_code(QApplication.clipboard().text().strip())
|
||||
if not code:
|
||||
return
|
||||
self._stop_gog_login_clipboard()
|
||||
self.gogAccountStatus.setText(_("Refreshing…"))
|
||||
worker = GOGAuthWorker(self.gog_api, code)
|
||||
worker.authenticated.connect(self._on_gog_authenticated)
|
||||
worker.finished.connect(self._on_gog_auth_worker_finished)
|
||||
self.gog_auth_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _stop_gog_login_clipboard(self) -> None:
|
||||
timer = getattr(self, "gog_auth_timer", None)
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
self.gog_auth_timer = None
|
||||
if not getattr(self, "gog_auth_clipboard_connected", False):
|
||||
return
|
||||
QApplication.clipboard().dataChanged.disconnect(
|
||||
self._check_gog_login_clipboard
|
||||
)
|
||||
self.gog_auth_clipboard_connected = False
|
||||
|
||||
def _cancel_gog_login(self) -> None:
|
||||
self._stop_gog_login_clipboard()
|
||||
self.gogLoginButton.setEnabled(True)
|
||||
self._update_gog_account_state()
|
||||
|
||||
def _on_gog_authenticated(self, authenticated: bool, error: str) -> None:
|
||||
if not authenticated:
|
||||
message = error or _("Unknown error")
|
||||
self.gogAccountStatus.setText(_("GOG login failed: {0}").format(message))
|
||||
return
|
||||
account_name = self.gog_api.get_account_name()
|
||||
self.gogAccountStatus.setText(
|
||||
account_name or _("GOG account connected")
|
||||
)
|
||||
self.loadGames(force_load=True)
|
||||
self._refresh_gog_library()
|
||||
|
||||
def _on_gog_auth_worker_finished(self) -> None:
|
||||
self.gogLoginButton.setEnabled(True)
|
||||
button_text = (
|
||||
_("Log out")
|
||||
if self.gog_api.auth_path.is_file() else _("Open login page")
|
||||
)
|
||||
self.gogLoginButton.setText(button_text)
|
||||
self.gog_auth_worker = None
|
||||
|
||||
def _update_gog_account_state(self) -> None:
|
||||
connected = self.gog_api.auth_path.is_file()
|
||||
account_name = self.gog_api.get_account_name() if connected else ""
|
||||
status = (
|
||||
account_name or _("GOG account connected")
|
||||
if connected else _("GOG account not connected")
|
||||
)
|
||||
self.gogAccountStatus.setText(status)
|
||||
self.gogLoginButton.setEnabled(True)
|
||||
self.gogLoginButton.setText(
|
||||
_("Log out") if connected else _("Open login page")
|
||||
)
|
||||
if connected and not account_name:
|
||||
self._refresh_gog_account_name()
|
||||
|
||||
def _refresh_gog_account_name(self) -> None:
|
||||
if getattr(self, "gog_account_worker", None) is not None:
|
||||
return
|
||||
worker = GOGAccountWorker(self.gog_api)
|
||||
worker.loaded.connect(self._on_gog_account_name_loaded)
|
||||
worker.finished.connect(self._on_gog_account_worker_finished)
|
||||
self.gog_account_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_gog_account_name_loaded(self, account_name: str) -> None:
|
||||
if account_name:
|
||||
self.gogAccountStatus.setText(account_name)
|
||||
|
||||
def _on_gog_account_worker_finished(self) -> None:
|
||||
self.gog_account_worker = None
|
||||
|
||||
def _refresh_gog_library(self) -> None:
|
||||
if getattr(self, "gog_library_worker", None) is not None:
|
||||
return
|
||||
self.gogAccountStatus.setText(_("Refreshing…"))
|
||||
worker = GOGLibraryWorker(self.gog_api)
|
||||
worker.loaded.connect(self._on_gog_library_loaded)
|
||||
worker.failed.connect(self._on_gog_library_failed)
|
||||
worker.progress.connect(self._on_gog_library_progress)
|
||||
worker.finished.connect(self._on_gog_library_worker_finished)
|
||||
self.gog_library_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_gog_library_progress(self, completed: int, total: int) -> None:
|
||||
self.gogAccountStatus.setText(
|
||||
_("Refreshing GOG library… {0}/{1}").format(completed, total)
|
||||
)
|
||||
|
||||
def _on_gog_library_loaded(self, _games: list) -> None:
|
||||
self._update_gog_account_state()
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _on_gog_library_failed(self, message: str) -> None:
|
||||
self.gogAccountStatus.setText(_("Failed to refresh GOG library: {0}").format(message))
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _on_gog_library_worker_finished(self) -> None:
|
||||
self.gog_library_worker = None
|
||||
|
||||
def _install_gog_game(self, game: dict) -> None:
|
||||
if self.gog_process is not None:
|
||||
self.gog_download_queue.append(game)
|
||||
self._append_download_row(self.downloadQueuedTable, game, _("Install"))
|
||||
self.switchTab(6)
|
||||
return
|
||||
app_id = str(game["app_id"])
|
||||
install_path = self.gog_api.get_install_path(app_id, str(game["title"]))
|
||||
install_path.mkdir(parents=True, exist_ok=True)
|
||||
selected_paths: list[str] = []
|
||||
file_explorer = FileExplorer(
|
||||
self,
|
||||
theme=self.theme,
|
||||
initial_path=str(install_path),
|
||||
directory_only=True,
|
||||
)
|
||||
file_explorer.setWindowTitle(_("Select installation folder"))
|
||||
file_explorer.file_signal.file_selected.connect(selected_paths.append)
|
||||
file_explorer.exec()
|
||||
if not selected_paths:
|
||||
return
|
||||
install_path = Path(selected_paths[0])
|
||||
manifest_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
)
|
||||
if manifest_path.is_file() and not self.gog_api.is_game_installed(app_id):
|
||||
try:
|
||||
manifest_path.unlink()
|
||||
except OSError as error:
|
||||
logger.error("Failed to reset incomplete GOG download: %s", error)
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
support_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "gog-support" / app_id
|
||||
)
|
||||
try:
|
||||
command = self.gog_api.build_command(
|
||||
[
|
||||
"download", app_id, "--path", str(install_path),
|
||||
"--support", str(support_path), "--platform", "windows",
|
||||
]
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
self._start_gog_download(game, install_path, command, _("Install"))
|
||||
|
||||
def _repair_gog_game(self, game: dict) -> None:
|
||||
if self.gog_process is not None:
|
||||
self.gogAccountStatus.setText(_("Another GOG operation is already running"))
|
||||
return
|
||||
app_id = str(game["app_id"])
|
||||
install_path = self.gog_api.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
self.gogAccountStatus.setText(_("GOG installation not found"))
|
||||
return
|
||||
support_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "gog-support" / app_id
|
||||
)
|
||||
try:
|
||||
command = self.gog_api.build_command(
|
||||
[
|
||||
"repair", app_id, "--path", str(install_path),
|
||||
"--support", str(support_path), "--platform", "windows",
|
||||
]
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
self._start_gog_download(game, install_path, command, _("Repair"))
|
||||
|
||||
def _import_gog_game(self, game: dict) -> None:
|
||||
selected_paths: list[str] = []
|
||||
file_explorer = FileExplorer(
|
||||
self, theme=self.theme, initial_path=str(Path.home() / "Games"),
|
||||
directory_only=True,
|
||||
)
|
||||
file_explorer.setWindowTitle(_("Select GOG game folder"))
|
||||
file_explorer.file_signal.file_selected.connect(selected_paths.append)
|
||||
file_explorer.exec()
|
||||
if not selected_paths:
|
||||
return
|
||||
app_id = str(game["app_id"])
|
||||
game_path = self.gog_api.find_install_path(app_id, Path(selected_paths[0]))
|
||||
if game_path is None:
|
||||
self.gogAccountStatus.setText(_("Selected folder is not this GOG game"))
|
||||
return
|
||||
self.gog_api.save_installed_game(
|
||||
app_id, {"install_path": str(game_path), "title": str(game["title"])}
|
||||
)
|
||||
self.gog_api.ensure_launch_parameters(app_id)
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _delete_gog_game(self, game: dict) -> None:
|
||||
app_id = str(game["app_id"])
|
||||
install_path = self.gog_api.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
self.gogAccountStatus.setText(_("GOG installation not found"))
|
||||
return
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game["title"]
|
||||
)
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(install_path)
|
||||
except OSError as error:
|
||||
logger.error("Failed to delete GOG game %s: %s", app_id, error)
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
manifest_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
)
|
||||
try:
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
|
||||
self.gog_api.remove_installed_game(app_id)
|
||||
self.context_menu_manager.remove_gog_shortcuts(str(game["title"]))
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _start_gog_download(
|
||||
self, game: dict, install_path: Path, command: list[str], action: str
|
||||
) -> None:
|
||||
app_id = str(game["app_id"])
|
||||
self.downloadActiveTitle.setText(str(game["title"]))
|
||||
self.downloadActiveDetails.setText(_("Starting"))
|
||||
cover_width, cover_height = self.theme.downloadsActiveCoverSize
|
||||
load_pixmap_async(
|
||||
str(game.get("cover", "")), cover_width, cover_height,
|
||||
self.downloadActiveCover.setPixmap,
|
||||
app_name=f"download-active-{app_id}",
|
||||
)
|
||||
self.downloadActiveHeading.setVisible(True)
|
||||
self.downloadActiveCard.setVisible(True)
|
||||
self._update_downloads_tab_visibility()
|
||||
process = QProcess(self)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._gog_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.readyReadStandardOutput.connect(self._read_gog_download_output)
|
||||
process.finished.connect(self._on_gog_download_finished)
|
||||
self.gog_process = process
|
||||
self.gog_active_app_id = app_id
|
||||
self.gog_active_game = game
|
||||
self.gog_active_install_path = install_path
|
||||
self.gog_active_action = action
|
||||
self.gog_download_output = ""
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
self.downloadCancelButton.setEnabled(True)
|
||||
self.switchTab(6)
|
||||
process.start()
|
||||
|
||||
def _cancel_gog_download(self) -> None:
|
||||
if self.gog_process is None:
|
||||
return
|
||||
process = self.gog_process
|
||||
self.downloadCancelButton.setEnabled(False)
|
||||
self.downloadActiveDetails.setText(_("Cancel"))
|
||||
process.terminate()
|
||||
QTimer.singleShot(
|
||||
GOG_CANCEL_KILL_TIMEOUT_MS,
|
||||
lambda: self._kill_gog_process(process),
|
||||
)
|
||||
|
||||
def _kill_gog_process(self, process: QProcess) -> None:
|
||||
if process.state() != QProcess.ProcessState.NotRunning:
|
||||
logger.warning("gogdl did not terminate after cancellation; killing it")
|
||||
process.kill()
|
||||
|
||||
def _append_download_row(
|
||||
self, table: QTableWidget, game: dict, action: str
|
||||
) -> tuple[int, QLabel]:
|
||||
row = table.rowCount()
|
||||
table.insertRow(row)
|
||||
game_cell, details_label = self._create_download_game_cell(game)
|
||||
table.setCellWidget(row, 0, game_cell)
|
||||
values = (datetime.now().strftime("%H:%M:%S"), action, "GOG")
|
||||
for column, value in enumerate(values, 1):
|
||||
table.setItem(row, column, QTableWidgetItem(value))
|
||||
table.setRowHeight(row, self.theme.downloadsTableRowHeight)
|
||||
if table is self.downloadQueuedTable:
|
||||
details_label.setText(_("Queued"))
|
||||
else:
|
||||
details_label.setText(action)
|
||||
self._update_download_table_height(table)
|
||||
self._update_downloads_tab_visibility()
|
||||
return row, details_label
|
||||
|
||||
def _create_download_game_cell(self, game: dict) -> tuple[QWidget, QLabel]:
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout(widget)
|
||||
layout.setContentsMargins(*self.theme.downloadsCellMargins)
|
||||
layout.setSpacing(self.theme.downloadsCellSpacing)
|
||||
cover = QLabel()
|
||||
cover_width, cover_height = self.theme.downloadsCoverSize
|
||||
cover.setFixedSize(cover_width, cover_height)
|
||||
layout.addWidget(cover)
|
||||
text_layout = QVBoxLayout()
|
||||
title = QLabel(str(game["title"]))
|
||||
details = QLabel(_("Waiting…"))
|
||||
details.setStyleSheet(self.theme.CONTENT_STYLE)
|
||||
text_layout.addWidget(title)
|
||||
text_layout.addWidget(details)
|
||||
layout.addLayout(text_layout)
|
||||
load_pixmap_async(
|
||||
str(game.get("cover", "")), cover_width, cover_height, cover.setPixmap,
|
||||
app_name=f"download-{game['app_id']}",
|
||||
)
|
||||
return widget, details
|
||||
|
||||
def _gog_process_environment(self) -> QProcessEnvironment:
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
environment.insert("GOGDL_CONFIG_PATH", str(self.gog_api.config_dir))
|
||||
return environment
|
||||
|
||||
def _read_gog_download_output(self) -> None:
|
||||
if self.gog_process is None:
|
||||
return
|
||||
output = bytes(
|
||||
self.gog_process.readAllStandardOutput().data()
|
||||
).decode(errors="replace")
|
||||
self.gog_download_output = (self.gog_download_output + output)[-4096:]
|
||||
percentages = re.findall(
|
||||
r"Progress:\s+(\d{1,3}(?:\.\d+)?)", self.gog_download_output
|
||||
)
|
||||
if percentages:
|
||||
self.downloadOverallProgress.setValue(int(float(percentages[-1])))
|
||||
download_speeds = re.findall(
|
||||
r"Download\t-\s+(\S+)\s+MiB", self.gog_download_output
|
||||
)
|
||||
disk_speeds = re.findall(
|
||||
r"Disk\t-\s+(\S+)\s+MiB", self.gog_download_output
|
||||
)
|
||||
downloaded = re.findall(
|
||||
r"Downloaded:\s+([\d.]+\s+MiB)", self.gog_download_output
|
||||
)
|
||||
eta = re.findall(r"ETA:\s+(\d{2}:\d{2}:\d{2})", self.gog_download_output)
|
||||
if download_speeds:
|
||||
self.downloadSpeedLabel.setText(
|
||||
_("Download: {0} MiB/s").format(download_speeds[-1])
|
||||
)
|
||||
if disk_speeds:
|
||||
self.diskSpeedLabel.setText(
|
||||
_("Disk: {0} MiB/s").format(disk_speeds[-1])
|
||||
)
|
||||
details = []
|
||||
if downloaded:
|
||||
details.append(downloaded[-1])
|
||||
if download_speeds:
|
||||
details.append(f"{download_speeds[-1]} MiB/s")
|
||||
if eta:
|
||||
details.append(_("ETA: {0}").format(eta[-1]))
|
||||
if details:
|
||||
self._update_active_download_details(details)
|
||||
|
||||
def _update_active_download_details(self, details: list[str]) -> None:
|
||||
self.downloadActiveDetails.setText(" · ".join(details))
|
||||
|
||||
def _on_gog_download_finished(
|
||||
self, code: int, _status: QProcess.ExitStatus
|
||||
) -> None:
|
||||
game = self.gog_active_game
|
||||
install_path = self.gog_active_install_path
|
||||
app_id = str(game["app_id"])
|
||||
self.downloadActiveHeading.setVisible(False)
|
||||
self.downloadActiveCard.setVisible(False)
|
||||
game_path = self.gog_api.find_install_path(app_id, install_path)
|
||||
if code == 0 and game_path is None:
|
||||
code = 1
|
||||
completed_action = _("Failed")
|
||||
if code == 0:
|
||||
completed_action = (
|
||||
_("Repaired") if self.gog_active_action == _("Repair")
|
||||
else _("Installed")
|
||||
)
|
||||
_completed_row, completed_details = self._append_download_row(
|
||||
self.downloadCompletedTable, game, completed_action,
|
||||
)
|
||||
if code == 0:
|
||||
self.gog_api.save_installed_game(
|
||||
app_id, {"install_path": str(game_path), "title": game["title"]}
|
||||
)
|
||||
self._update_installed_gog_detail(app_id)
|
||||
self.loadGames(force_load=True)
|
||||
else:
|
||||
error = self._get_gog_download_error()
|
||||
completed_details.setText(error)
|
||||
logger.error("GOG download failed for %s: %s", app_id, error)
|
||||
self.gog_process = None
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
self.downloadSpeedLabel.setText(_("Downloading: ") + "\u2014")
|
||||
self.diskSpeedLabel.setText(_("Disk: ") + "\u2014")
|
||||
if self.gog_download_queue:
|
||||
next_game = self.gog_download_queue.pop(0)
|
||||
self.downloadQueuedTable.removeRow(0)
|
||||
self._update_download_table_height(self.downloadQueuedTable)
|
||||
self._install_gog_game(next_game)
|
||||
|
||||
def _start_gog_support_setup(self, app_id: str, button=None) -> None:
|
||||
if getattr(self, "gog_support_workers", []):
|
||||
return
|
||||
start_command = getattr(self, "start_sh", None)
|
||||
if not start_command:
|
||||
logger.warning("PortProton is unavailable for GOG support setup")
|
||||
return
|
||||
self.downloadActiveTitle.setText("GOG Redist")
|
||||
self.downloadActiveDetails.setText(_("Starting"))
|
||||
self.downloadActiveCover.clear()
|
||||
self.downloadActiveHeading.setVisible(True)
|
||||
self.downloadActiveCard.setVisible(True)
|
||||
self._update_downloads_tab_visibility()
|
||||
self.downloadCancelButton.setEnabled(False)
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
worker = GOGSupportWorker(self.gog_api, app_id, start_command)
|
||||
worker.failed.connect(self.gogAccountStatus.setText)
|
||||
worker.launched.connect(
|
||||
lambda process: self._track_gog_support_process(
|
||||
app_id, process, button
|
||||
)
|
||||
)
|
||||
worker.succeeded.connect(
|
||||
lambda: self._launch_after_gog_support(app_id, button)
|
||||
)
|
||||
worker.finished.connect(lambda: self._finish_gog_support_setup(worker))
|
||||
self.gog_support_workers.append(worker)
|
||||
worker.start()
|
||||
|
||||
def _track_gog_support_process(
|
||||
self, app_id: str, process: subprocess.Popen, button=None
|
||||
) -> None:
|
||||
target = self.gog_api.get_launch_target(app_id)
|
||||
self.game_processes.append(process)
|
||||
self.target_exe = os.path.basename(target or app_id)
|
||||
self.current_running_button = button
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self._set_running_button_stop()
|
||||
|
||||
def _launch_after_gog_support(self, app_id: str, button=None) -> None:
|
||||
self.game_processes = [
|
||||
process for process in self.game_processes
|
||||
if process.poll() is None
|
||||
]
|
||||
self._launch_gog_game(app_id, button)
|
||||
|
||||
def _finish_gog_support_setup(self, worker: GOGSupportWorker) -> None:
|
||||
workers = getattr(self, "gog_support_workers", [])
|
||||
if worker in workers:
|
||||
workers.remove(worker)
|
||||
self.downloadActiveHeading.setVisible(False)
|
||||
self.downloadActiveCard.setVisible(False)
|
||||
action = _("Failed") if worker.error else _("Installed")
|
||||
redist = {"app_id": "gog-redist", "title": "GOG Redist", "cover": ""}
|
||||
_row, details = self._append_download_row(
|
||||
self.downloadCompletedTable, redist, action
|
||||
)
|
||||
if worker.error:
|
||||
details.setText(worker.error)
|
||||
self.resetPlayButton()
|
||||
|
||||
def _get_gog_download_error(self) -> str:
|
||||
lines = [line.strip() for line in self.gog_download_output.splitlines()]
|
||||
error_lines = [line for line in lines if "ERROR" in line.upper()]
|
||||
if error_lines:
|
||||
return error_lines[-1]
|
||||
useful = [line for line in lines if line and "[PROGRESS]" not in line]
|
||||
return useful[-1] if useful else _("Download process failed")
|
||||
|
||||
def _update_installed_gog_detail(self, app_id: str) -> None:
|
||||
install_uri = f"gog://install/{app_id}"
|
||||
if self.current_exec_line != install_uri:
|
||||
return
|
||||
if self.stackedWidget.currentWidget() is not self.currentDetailPage:
|
||||
return
|
||||
source = self.detail_page_manager._current_detail_source
|
||||
if source is None or source[0] != "game":
|
||||
return
|
||||
source[1]["exec_line"] = f"gog://launch/{app_id}"
|
||||
self.current_exec_line = source[1]["exec_line"]
|
||||
self.detail_page_manager._reopen_current_detail_page()
|
||||
@@ -23,7 +23,6 @@ from portprotonqt.config import (
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
extract_exec_target_path,
|
||||
game_config,
|
||||
get_custom_data_dir_name,
|
||||
parse_desktop_entry,
|
||||
ui_config,
|
||||
)
|
||||
@@ -154,7 +153,6 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.addGameButton = AutoSizeButton(_("Add a shortcut"), icon=self.theme_manager.get_icon("addgame", as_path=True))
|
||||
self.addGameButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE)
|
||||
self.addGameButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.addGameButton.setProperty("sound_event", "open")
|
||||
self.addGameButton.clicked.connect(self.openAddGameDialog)
|
||||
buttons_layout.addWidget(self.addGameButton)
|
||||
buttons_layout.addStretch()
|
||||
@@ -277,8 +275,8 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.gamesSortCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
controls_layout.addWidget(self.gamesSortCombo)
|
||||
|
||||
self.filter_keys = ["all", "steam", "gog", "portproton", "favorites"]
|
||||
self.filter_labels = [_("All"), "Steam", "GOG", "PortProton", _("Favorites")]
|
||||
self.filter_keys = ["all", "steam", "portproton", "favorites"]
|
||||
self.filter_labels = [_("All"), "Steam", "PortProton", _("Favorites")]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
self._set_combo_current_key(
|
||||
self.gamesDisplayCombo,
|
||||
@@ -377,13 +375,6 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
if hasattr(self.game_library_manager, 'gamesListLayout'):
|
||||
self.game_library_manager.gamesListLayout.update()
|
||||
|
||||
if (
|
||||
self.gog_api.auth_path.is_file()
|
||||
and getattr(self, "gog_library_worker", None) is None
|
||||
):
|
||||
self._refresh_gog_library()
|
||||
return
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
# Use a small delay to allow UI to update before starting the refresh
|
||||
QTimer.singleShot(50, lambda: self.loadGames(force_load=True))
|
||||
@@ -391,7 +382,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _get_games_without_exe(self) -> list[tuple]:
|
||||
missing_games = []
|
||||
for game in self.game_library_manager.games:
|
||||
if len(game) <= 12 or game[12] != "portproton":
|
||||
if len(game) <= 12 or game[12] == "steam":
|
||||
continue
|
||||
exec_line = game[5] if len(game) > 5 else ""
|
||||
exe_path = extract_exec_target_path(exec_line) if exec_line else None
|
||||
@@ -543,13 +534,14 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
if desktop_entry and desktop_path:
|
||||
self._write_desktop_file(desktop_entry, desktop_path)
|
||||
|
||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
custom_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
get_custom_data_dir_name(exe_path),
|
||||
exe_name
|
||||
)
|
||||
|
||||
# Handle user cover copy
|
||||
|
||||
@@ -17,7 +17,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.appimage_integration import AppImageIntegrationWorker
|
||||
from portprotonqt.cli import (
|
||||
add_steam_compat_tool,
|
||||
is_steam_compat_tool_installed,
|
||||
@@ -42,11 +41,7 @@ from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.portproton_api import get_user_conf_setting, set_user_conf_setting
|
||||
from portprotonqt.qt_utils import get_system_dpi_for_wine
|
||||
from portprotonqt.steam_api import (
|
||||
get_steam_compatibilitytools_dir,
|
||||
get_steam_home,
|
||||
get_steam_users,
|
||||
)
|
||||
from portprotonqt.steam_api import get_steam_compatibilitytools_dir
|
||||
from portprotonqt.time_utils import format_playtime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -124,47 +119,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
genForm.setRowWrapPolicy(QFormLayout.RowWrapPolicy.DontWrapRows)
|
||||
scrollLayout.addWidget(genFrame)
|
||||
|
||||
gogFrame, gogForm = create_section(_("Accounts"), self.theme)
|
||||
gogForm.setRowWrapPolicy(QFormLayout.RowWrapPolicy.DontWrapRows)
|
||||
scrollLayout.addWidget(gogFrame)
|
||||
self.steamAccountCombo = CustomComboBox(theme=self.theme)
|
||||
self.steamAccountCombo.setStyleSheet(
|
||||
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
|
||||
)
|
||||
self.steamAccountCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.steamAccountCombo.addItem(_("Auto"), "auto")
|
||||
self.steamAccountCombo.addItem(_("All"), "all")
|
||||
steam_home = get_steam_home()
|
||||
steam_users = get_steam_users(steam_home) if steam_home else {}
|
||||
for user_id, user_info in steam_users.items():
|
||||
account_name = user_info.get("AccountName", user_id)
|
||||
persona_name = user_info.get("PersonaName", account_name)
|
||||
self.steamAccountCombo.addItem(
|
||||
f"{persona_name} ({account_name})", user_id
|
||||
)
|
||||
selected_account = game_config.get_steam_account_id()
|
||||
selected_index = self.steamAccountCombo.findData(selected_account)
|
||||
self.steamAccountCombo.setCurrentIndex(max(selected_index, 0))
|
||||
steam_account_title = QLabel(f"{_('Account')} Steam:")
|
||||
steam_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(steam_account_title, self.steamAccountCombo)
|
||||
self.gogAccountStatus = QLabel()
|
||||
self.gogAccountStatus.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
self.gogAccountStatus.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.gogLoginButton = AutoSizeButton(_("Open login page"))
|
||||
self.gogLoginButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.gogLoginButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.gogLoginButton.clicked.connect(self._handle_gog_account_action)
|
||||
gog_buttons = QHBoxLayout()
|
||||
gog_buttons.addWidget(self.gogAccountStatus)
|
||||
gog_buttons.addWidget(self.gogLoginButton)
|
||||
gog_account_title = QLabel(f"{_('Account')} GOG:")
|
||||
gog_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(gog_account_title, gog_buttons)
|
||||
self._update_gog_account_state()
|
||||
|
||||
self.timeDetailCombo = CustomComboBox(theme=self.theme)
|
||||
self.timeDetailCombo.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
@@ -219,9 +173,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
idx = 0
|
||||
self.trayMenuModeCombo.setCurrentIndex(idx)
|
||||
uiForm.addRow(self.trayMenuModeTitle, self.trayMenuModeCombo)
|
||||
self.setTabOrder(self.timeDetailCombo, self.steamAccountCombo)
|
||||
self.setTabOrder(self.steamAccountCombo, self.gogLoginButton)
|
||||
self.setTabOrder(self.gogLoginButton, self.trayMenuModeCombo)
|
||||
|
||||
self.gamepad_type_keys = ["auto", "xbox", "playstation"]
|
||||
self.gamepad_type_labels = [_("Auto"), "Xbox", "PlayStation"]
|
||||
@@ -344,44 +295,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
hide_autoinstall_layout.addStretch()
|
||||
uiForm.addRow(hide_autoinstall_layout)
|
||||
|
||||
self.soundsEnabledCheckBox = QCheckBox()
|
||||
self.soundsEnabledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.soundsEnabledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.soundsEnabledTitle = QLabel(_("Enable UI Sounds"))
|
||||
self.soundsEnabledTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.soundsEnabledTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
|
||||
self.soundsEnabledTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.soundsEnabledCheckBox.setChecked(ui_config.get_sounds_enabled())
|
||||
sounds_enabled_layout = QHBoxLayout()
|
||||
sounds_enabled_layout.setContentsMargins(0, 0, 0, 0)
|
||||
sounds_enabled_layout.addWidget(self.soundsEnabledCheckBox)
|
||||
sounds_enabled_layout.addWidget(self.soundsEnabledTitle)
|
||||
sounds_enabled_layout.addStretch()
|
||||
uiForm.addRow(sounds_enabled_layout)
|
||||
|
||||
self.crashReportsEnabledCheckBox = QCheckBox()
|
||||
self.crashReportsEnabledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.crashReportsEnabledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.crashReportsEnabledTitle = QLabel(
|
||||
_("Show compatibility report after game crash")
|
||||
)
|
||||
self.crashReportsEnabledTitle.setSizePolicy(
|
||||
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.crashReportsEnabledTitle.setStyleSheet(
|
||||
self.theme.SETTINGS_TITLE_CHECKBOX_STYLE
|
||||
)
|
||||
self.crashReportsEnabledTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.crashReportsEnabledCheckBox.setChecked(
|
||||
ui_config.get_crash_reports_enabled()
|
||||
)
|
||||
crash_reports_enabled_layout = QHBoxLayout()
|
||||
crash_reports_enabled_layout.setContentsMargins(0, 0, 0, 0)
|
||||
crash_reports_enabled_layout.addWidget(self.crashReportsEnabledCheckBox)
|
||||
crash_reports_enabled_layout.addWidget(self.crashReportsEnabledTitle)
|
||||
crash_reports_enabled_layout.addStretch()
|
||||
uiForm.addRow(crash_reports_enabled_layout)
|
||||
|
||||
disable_runtime_download_layout = None
|
||||
if not os.getenv("FLATPAK_ID"):
|
||||
self.disableRuntimeDownloadCheckBox = QCheckBox()
|
||||
@@ -479,7 +392,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.autoDownloadPPDBCheckBox = QCheckBox()
|
||||
self.autoDownloadPPDBCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.autoDownloadPPDBCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.autoDownloadPPDBTitle = QLabel(_("Auto download PPDB from") + " linux-gaming.ru")
|
||||
self.autoDownloadPPDBTitle = QLabel(_("Auto download PPDB from") + " ppdb.linux-gaming.ru")
|
||||
self.autoDownloadPPDBTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.autoDownloadPPDBTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
|
||||
self.autoDownloadPPDBTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
@@ -505,10 +418,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
auto_appimage_updates_layout.addWidget(self.autoAppImageUpdatesCheckBox)
|
||||
auto_appimage_updates_layout.addWidget(self.autoAppImageUpdatesTitle)
|
||||
auto_appimage_updates_layout.addStretch()
|
||||
self.integrateAppImageButton = AutoSizeButton(_("Integrate AppImage"))
|
||||
self.integrateAppImageButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.integrateAppImageButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.integrateAppImageButton.clicked.connect(self.integrateAppImage)
|
||||
|
||||
self.forceSystemDpiCheckBox = QCheckBox()
|
||||
self.forceSystemDpiCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
@@ -547,7 +456,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.enableThemeStoreCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.enableThemeStoreTitle = QLabel(
|
||||
_("Enable Theme Store from %(source)s (%(warning)s)") % {
|
||||
"source": "linux-gaming.ru",
|
||||
"source": "ppdb.linux-gaming.ru",
|
||||
"warning": _("third-party themes may be unsafe"),
|
||||
}
|
||||
)
|
||||
@@ -666,9 +575,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.migrateShortcutsButton.clicked.connect(self.migrateLegacyShortcuts)
|
||||
buttonsLayout.addWidget(self.migrateShortcutsButton)
|
||||
|
||||
if os.getenv("APPIMAGE"):
|
||||
buttonsLayout.addWidget(self.integrateAppImageButton)
|
||||
|
||||
self.clearCacheButton = AutoSizeButton(_("Clear Cache"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.clearCacheButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.clearCacheButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -728,37 +634,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
cache_config.clear_cache()
|
||||
self.gog_api.clear_library_cache()
|
||||
|
||||
def integrateAppImage(self) -> None:
|
||||
"""Install the running AppImage in the user application menu."""
|
||||
self.integrateAppImageButton.setEnabled(False)
|
||||
self.appImageIntegrationWorker = AppImageIntegrationWorker(self)
|
||||
self.appImageIntegrationWorker.completed.connect(
|
||||
self._onAppImageIntegrationCompleted
|
||||
)
|
||||
self.appImageIntegrationWorker.finished.connect(
|
||||
self.appImageIntegrationWorker.deleteLater
|
||||
)
|
||||
self.appImageIntegrationWorker.finished.connect(
|
||||
lambda: setattr(self, "appImageIntegrationWorker", None)
|
||||
)
|
||||
self.appImageIntegrationWorker.start()
|
||||
|
||||
def _onAppImageIntegrationCompleted(self, success: bool, message: str) -> None:
|
||||
self.integrateAppImageButton.setEnabled(True)
|
||||
if success:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
_("Success"),
|
||||
_("AppImage integrated at: {path}").format(path=message),
|
||||
)
|
||||
return
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
_("Error"),
|
||||
_("Failed to integrate AppImage: {error}").format(error=message),
|
||||
)
|
||||
|
||||
def applySettingsDelayed(self):
|
||||
ui_config.get_time_detail_level()
|
||||
@@ -821,9 +696,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def savePortProtonSettings(self):
|
||||
previous_economy_mode = ui_config.get_economy_mode()
|
||||
game_config.set_steam_account_id(
|
||||
str(self.steamAccountCombo.currentData())
|
||||
)
|
||||
time_idx = self.timeDetailCombo.currentIndex()
|
||||
time_key = self.time_keys[time_idx]
|
||||
ui_config.set_time_detail_level(time_key)
|
||||
@@ -969,15 +841,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
# Save the hide auto-install tab setting to config
|
||||
ui_config.set_hide_autoinstall_tab(hide_autoinstall)
|
||||
|
||||
sounds_enabled = self.soundsEnabledCheckBox.isChecked()
|
||||
ui_config.set_sounds_enabled(sounds_enabled)
|
||||
ui_config.set_crash_reports_enabled(
|
||||
self.crashReportsEnabledCheckBox.isChecked()
|
||||
)
|
||||
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().reload_config()
|
||||
|
||||
def _apply_gamepad_type_setting(self) -> None:
|
||||
"""Apply configured gamepad type to current input manager."""
|
||||
input_manager = getattr(self, "input_manager", None)
|
||||
|
||||
@@ -33,7 +33,6 @@ from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.dialogs import FileExplorer
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
from portprotonqt.preloader import Preloader
|
||||
from portprotonqt.system_manager import (
|
||||
AudioManagerWorker,
|
||||
@@ -46,7 +45,6 @@ logger = get_logger(__name__)
|
||||
|
||||
AUDIO_MAX_VOLUME = 150
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
|
||||
@@ -265,7 +263,6 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
button.setCheckable(True)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.setProperty("sound_event", False)
|
||||
button.clicked.connect(lambda _checked=False, section_index=index: self.switchSystemSection(section_index))
|
||||
self.systemSectionButtons.append(button)
|
||||
section_switcher_layout.addWidget(button)
|
||||
@@ -337,10 +334,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
return False
|
||||
if index < len(self.systemSectionButtons) and not self.systemSectionButtons[index].isVisible():
|
||||
return False
|
||||
previous_index = self.systemSectionStack.currentIndex()
|
||||
self.systemSectionStack.setCurrentIndex(index)
|
||||
if previous_index != index:
|
||||
SoundManager().play("tab_switch")
|
||||
for button_index, button in enumerate(self.systemSectionButtons):
|
||||
button.setChecked(button_index == index)
|
||||
self._focusCurrentSystemSection(index)
|
||||
@@ -897,29 +891,25 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
layout.addLayout(buttons_layout)
|
||||
layout.addStretch()
|
||||
|
||||
def _runSystemAction(self, action: str) -> None:
|
||||
command = "systemctl" if os.path.isdir("/run/systemd/system") else "loginctl"
|
||||
if QProcess.startDetached(command, [action]):
|
||||
def _runLoginctlAction(self, action: str) -> None:
|
||||
args = [action]
|
||||
if action == "terminate-session":
|
||||
args.append("self")
|
||||
if QProcess.startDetached("loginctl", args):
|
||||
return
|
||||
logger.error("Failed to execute %s %s", command, action)
|
||||
logger.error("Failed to execute loginctl %s", " ".join(args))
|
||||
|
||||
def rebootSystem(self) -> None:
|
||||
self._runSystemAction("reboot")
|
||||
self._runLoginctlAction("reboot")
|
||||
|
||||
def shutdownSystem(self) -> None:
|
||||
self._runSystemAction("poweroff")
|
||||
self._runLoginctlAction("poweroff")
|
||||
|
||||
def suspendSystem(self) -> None:
|
||||
self._runSystemAction("suspend")
|
||||
self._runLoginctlAction("suspend")
|
||||
|
||||
def logoutSystem(self) -> None:
|
||||
session_id = os.getenv("XDG_SESSION_ID")
|
||||
if not session_id:
|
||||
logger.error("Cannot log out: XDG_SESSION_ID is not set")
|
||||
return
|
||||
if QProcess.startDetached("loginctl", ["terminate-session", session_id]):
|
||||
return
|
||||
logger.error("Failed to execute loginctl terminate-session %s", session_id)
|
||||
self._runLoginctlAction("terminate-session")
|
||||
|
||||
def loadSystemNetworks(self) -> None:
|
||||
self.runNetworkOperation("load")
|
||||
|
||||
@@ -43,7 +43,6 @@ class ThemeStoreCard(QFrame):
|
||||
self.theme_data = theme_data
|
||||
self.card_width = card_width
|
||||
self.setObjectName("themeStoreCard")
|
||||
self.setProperty("sound_event", "open")
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setFixedWidth(card_width)
|
||||
@@ -198,7 +197,6 @@ class ThemeStoreMixin:
|
||||
header = QHBoxLayout()
|
||||
self.themeStoreBackButton = AutoSizeButton(_("Back"))
|
||||
self.themeStoreBackButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreBackButton.setProperty("sound_event", "back")
|
||||
self.themeStoreBackButton.clicked.connect(self._show_theme_store_list)
|
||||
header.addWidget(self.themeStoreBackButton)
|
||||
header.addStretch(1)
|
||||
@@ -304,6 +302,9 @@ class ThemeStoreMixin:
|
||||
if not getattr(self, "themeStoreLoaded", False):
|
||||
return
|
||||
self.themeStoreLoaded = False
|
||||
worker = getattr(self, "themeStoreImageWorker", None)
|
||||
if worker is not None:
|
||||
worker.cancel()
|
||||
self._load_theme_store()
|
||||
|
||||
def _on_theme_store_loaded(self, themes: list) -> None:
|
||||
@@ -325,6 +326,7 @@ class ThemeStoreMixin:
|
||||
self.themeStoreStatusLabel.setText(_("Failed to load themes"))
|
||||
|
||||
def _populate_theme_store_cards(self) -> None:
|
||||
self._cancel_theme_store_image_worker()
|
||||
self.themeStoreCards = {}
|
||||
self.themeStoreCardsByUrl = {}
|
||||
self.themeStoreLoadedUrls = set()
|
||||
@@ -394,7 +396,7 @@ class ThemeStoreMixin:
|
||||
if not pending:
|
||||
return
|
||||
if getattr(self, "themeStoreImageWorker", None) is not None:
|
||||
return
|
||||
self._cancel_theme_store_image_worker()
|
||||
worker = ThemeStoreImageWorker(pending)
|
||||
worker.loaded.connect(self._on_theme_store_preview_loaded)
|
||||
worker.finished.connect(
|
||||
@@ -424,7 +426,6 @@ class ThemeStoreMixin:
|
||||
item for item in getattr(self, "_imageWorkerPool", [])
|
||||
if item.isRunning()
|
||||
]
|
||||
QTimer.singleShot(0, self._schedule_visible_image_load)
|
||||
|
||||
def _get_visible_theme_urls(self) -> list[str]:
|
||||
viewport = self.themeStoreScrollArea.viewport()
|
||||
@@ -473,9 +474,6 @@ class ThemeStoreMixin:
|
||||
return max(1, width // self._themeStoreCardWidth)
|
||||
|
||||
def _on_theme_store_preview_loaded(self, url: str, data: bytes) -> None:
|
||||
sender = self.sender()
|
||||
if sender is not None and sender is not getattr(self, "themeStoreImageWorker", None):
|
||||
return
|
||||
self.themeStoreLoadedUrls.add(url)
|
||||
card = self.themeStoreCardsByUrl.get(url)
|
||||
pixmap = QPixmap()
|
||||
|
||||
@@ -14,7 +14,7 @@ from portprotonqt.downloader import get_requests_session
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
THEME_STORE_API_URL = "https://linux-gaming.ru/api/ppqt/themes"
|
||||
THEME_STORE_API_URL = "https://ppdb.linux-gaming.ru/api/ppqt/themes"
|
||||
THEME_STORE_TIMEOUT = 20
|
||||
THEME_STORE_DOWNLOAD_TIMEOUT = 60
|
||||
THEME_STORE_IMAGE_WORKERS = 8
|
||||
@@ -50,7 +50,7 @@ def _theme_store_variant_urls(theme_data: dict, variant: str) -> list[str]:
|
||||
def _theme_store_absolute_url(url: str) -> str:
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
return f"https://linux-gaming.ru/{url.lstrip('/')}"
|
||||
return f"https://ppdb.linux-gaming.ru/{url.lstrip('/')}"
|
||||
|
||||
|
||||
def _safe_theme_entry_name(name: str) -> bool:
|
||||
|
||||
@@ -3,9 +3,7 @@ import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import xml.etree.ElementTree as ET
|
||||
import orjson
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_security import (
|
||||
check_theme_directory_safety,
|
||||
@@ -25,31 +23,6 @@ _icon_dirs_cache = {}
|
||||
|
||||
logger = get_logger(__name__)
|
||||
SUPPORTED_IMAGE_EXTENSIONS = ('.svg', '.png', '.jpg', '.jpeg', '.webp', '.jxl')
|
||||
DMS_COLOR_FILE_MAX_SIZE = 128 * 1024
|
||||
DMS_COLOR_PATTERN = re.compile(r"#[0-9a-fA-F]{6}")
|
||||
DMS_COLOR_ROLES = (
|
||||
"background",
|
||||
"error",
|
||||
"on_background",
|
||||
"on_primary",
|
||||
"on_primary_container",
|
||||
"on_surface",
|
||||
"on_surface_variant",
|
||||
"outline",
|
||||
"outline_variant",
|
||||
"primary",
|
||||
"primary_container",
|
||||
"secondary",
|
||||
"shadow",
|
||||
"surface",
|
||||
"surface_bright",
|
||||
"surface_container",
|
||||
"surface_container_high",
|
||||
"surface_container_highest",
|
||||
"surface_container_low",
|
||||
"surface_container_lowest",
|
||||
"tertiary",
|
||||
)
|
||||
SVG_COLOR_PATTERN = re.compile(
|
||||
r"^(#[0-9a-fA-F]{3,8}|[A-Za-z][A-Za-z0-9_-]{0,31}|"
|
||||
r"(rgba?|hsla?)\([0-9A-Za-z.,% /+-]+\))$"
|
||||
@@ -77,94 +50,6 @@ THEMES_DIRS = [
|
||||
]
|
||||
_loaded_theme = None
|
||||
|
||||
|
||||
def _read_dms_color_file(colors_file: str) -> bytes | None:
|
||||
"""Read a stable, user-owned regular DMS color file."""
|
||||
flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW
|
||||
try:
|
||||
file_descriptor = os.open(colors_file, flags)
|
||||
except OSError as e:
|
||||
logger.warning("Cannot open DMS color palette: %s", e)
|
||||
return None
|
||||
try:
|
||||
before = os.fstat(file_descriptor)
|
||||
unsafe_mode = before.st_mode & 0o022
|
||||
if (
|
||||
not stat.S_ISREG(before.st_mode)
|
||||
or before.st_uid != os.getuid()
|
||||
or before.st_nlink != 1
|
||||
or unsafe_mode
|
||||
or not 0 < before.st_size <= DMS_COLOR_FILE_MAX_SIZE
|
||||
):
|
||||
logger.warning("Unsafe DMS color palette file: %s", colors_file)
|
||||
return None
|
||||
with os.fdopen(file_descriptor, "rb", closefd=False) as source_file:
|
||||
content = source_file.read(DMS_COLOR_FILE_MAX_SIZE + 1)
|
||||
after = os.fstat(file_descriptor)
|
||||
except OSError as e:
|
||||
logger.warning("Cannot read DMS color palette: %s", e)
|
||||
return None
|
||||
finally:
|
||||
os.close(file_descriptor)
|
||||
if (
|
||||
len(content) != before.st_size
|
||||
or before.st_size != after.st_size
|
||||
or before.st_mtime_ns != after.st_mtime_ns
|
||||
or before.st_ctime_ns != after.st_ctime_ns
|
||||
):
|
||||
logger.warning("DMS color palette changed while being read")
|
||||
return None
|
||||
return content
|
||||
|
||||
|
||||
def _is_valid_dms_document(document: object) -> bool:
|
||||
if not isinstance(document, dict):
|
||||
return False
|
||||
palettes = document.get("colors")
|
||||
if not isinstance(palettes, dict):
|
||||
return False
|
||||
for variant in ("dark", "light"):
|
||||
colors = palettes.get(variant)
|
||||
if not isinstance(colors, dict):
|
||||
return False
|
||||
if any(
|
||||
not isinstance(colors.get(role), str)
|
||||
or not DMS_COLOR_PATTERN.fullmatch(colors[role])
|
||||
for role in DMS_COLOR_ROLES
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def load_dms_palette(variant: str) -> dict[str, str]:
|
||||
"""Load a validated Material palette generated by Dank Material Shell."""
|
||||
if variant not in ("dark", "light"):
|
||||
raise ValueError(f"Unsupported DMS palette variant: {variant}")
|
||||
cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache"),
|
||||
)
|
||||
if not os.path.isabs(cache_home):
|
||||
logger.warning("XDG_CACHE_HOME is not an absolute path")
|
||||
return {}
|
||||
colors_file = os.path.join(
|
||||
cache_home,
|
||||
"DankMaterialShell",
|
||||
"dms-colors.json",
|
||||
)
|
||||
content = _read_dms_color_file(colors_file)
|
||||
if content is None:
|
||||
return {}
|
||||
try:
|
||||
document = orjson.loads(content)
|
||||
except orjson.JSONDecodeError as e:
|
||||
logger.warning("Cannot parse DMS color palette: %s", e)
|
||||
return {}
|
||||
if not _is_valid_dms_document(document):
|
||||
logger.warning("Invalid DMS color palette schema")
|
||||
return {}
|
||||
return {role: document["colors"][variant][role] for role in DMS_COLOR_ROLES}
|
||||
|
||||
def _load_icon(icon_path: str) -> QIcon:
|
||||
"""Load theme icon with device pixel ratio for crisp raster icons."""
|
||||
if icon_path.lower().endswith(".svg"):
|
||||
@@ -526,19 +411,6 @@ def build_icon_cache(theme_name):
|
||||
_icon_dirs_cache[theme_name] = image_map
|
||||
return image_map
|
||||
|
||||
def _resolve_sound_dirs(theme_name: str) -> list[str]:
|
||||
"""Return ordered list of sound directories from theme inheritance chain."""
|
||||
dirs = []
|
||||
for resource_theme_name in _get_theme_resource_chain(theme_name):
|
||||
theme_folder = _find_theme_folder(resource_theme_name)
|
||||
if not theme_folder:
|
||||
continue
|
||||
sounds_dir = os.path.join(theme_folder, "sounds")
|
||||
if os.path.isdir(sounds_dir):
|
||||
dirs.append(sounds_dir)
|
||||
return dirs
|
||||
|
||||
|
||||
def load_theme_fonts(theme_name):
|
||||
"""
|
||||
Load all fonts from selected theme if not already loaded.
|
||||
@@ -932,10 +804,6 @@ class ThemeManager:
|
||||
self.current_theme_name = theme_name
|
||||
self.current_theme_module = theme_module
|
||||
ui_config.set_theme(theme_name)
|
||||
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().set_sounds_dirs(_resolve_sound_dirs(theme_name))
|
||||
|
||||
logger.info(f"Theme '{theme_name}' successfully applied")
|
||||
return theme_module
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from portprotonqt.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
MAX_THEME_PY_FILE_SIZE = 512 * 1024
|
||||
MAX_AST_NODES = 20000
|
||||
MAX_SOUND_FILE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
|
||||
class ThemeSecurityChecker:
|
||||
@@ -569,9 +568,6 @@ def check_theme_directory_safety(theme_dir: str, allow_absolute_imports: bool =
|
||||
elif filename.endswith((".ttf", ".otf")):
|
||||
if not is_safe_font_file(file_path):
|
||||
return False
|
||||
elif filename.lower().endswith(".wav"):
|
||||
if not is_safe_sound_file(file_path):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -611,31 +607,6 @@ def is_safe_font_file(file_path: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def is_safe_sound_file(file_path: str) -> bool:
|
||||
"""Check a theme sound extension, size, and file signature."""
|
||||
_, ext = os.path.splitext(file_path.lower())
|
||||
if ext != ".wav":
|
||||
logger.error("Unsafe sound file extension for %s: %s", file_path, ext)
|
||||
return False
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size > MAX_SOUND_FILE_SIZE:
|
||||
logger.error("Sound file too large (%s bytes): %s", file_size, file_path)
|
||||
return False
|
||||
with open(file_path, "rb") as sound_file:
|
||||
header = sound_file.read(16)
|
||||
except OSError as e:
|
||||
logger.error("Error reading sound file %s: %s", file_path, e)
|
||||
return False
|
||||
|
||||
is_valid = False
|
||||
if ext == ".wav":
|
||||
is_valid = header.startswith(b"RIFF") and header[8:12] == b"WAVE"
|
||||
if not is_valid:
|
||||
logger.error("Sound file %s has invalid signature", file_path)
|
||||
return is_valid
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user