Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b60a79ae3
|
No files matched your search
@@ -31,11 +31,6 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
|
||||
|
||||
@@ -74,11 +69,6 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
|
||||
@@ -22,25 +22,37 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Install CI dependencies
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git nodejs npm
|
||||
pacman -Syu --noconfirm --needed git nodejs npm mesa libarchive libpulse sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Install project and check dependencies
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Activate .venv & run pre-commit
|
||||
shell: bash
|
||||
run: |
|
||||
source build-aux/PKGBUILD-git
|
||||
pacman -S --noconfirm --needed "${depends[@]}" python-qrcode libpulse mesa pre-commit pyright python-pytest python-pytest-mock
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- name: Run pre-commit
|
||||
run: pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
source .venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -80,12 +80,24 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -195,12 +207,24 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -266,7 +290,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20261005@sha256:913f6706df59a68922d1dd08f78c2476560a8d367897200a6005b00e5f67c2d5
|
||||
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
|
||||
@@ -9,9 +9,6 @@ jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
|
||||
env:
|
||||
UV_PYTHON_INSTALL_MIRROR: https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone/
|
||||
UV_DEFAULT_INDEX: https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
|
||||
@@ -45,4 +45,3 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
|
||||
.codex
|
||||
CODE_QUALITY_PLAN.md
|
||||
.repowise
|
||||
.pz
|
||||
@@ -24,7 +24,7 @@ repos:
|
||||
hooks:
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: pyright
|
||||
entry: uv run pyright
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
@@ -62,7 +62,7 @@ repos:
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: pytest tests/ -x -q --tb=line
|
||||
entry: uv run pytest tests/ -x -q --tb=line
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^(tests/|portprotonqt/)
|
||||
|
||||
@@ -419,7 +419,7 @@ tests/
|
||||
├── test_cli.py # normalize_launch_path, URL/resolution parsing
|
||||
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
|
||||
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
|
||||
├── test_input_manager.py # Gamepad input navigation, dialog keyboards, and focus regressions
|
||||
├── 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_boot_animation.py # Theme video inheritance, fullscreen boot rendering, gamepad skip, loading transitions, and shutdown
|
||||
├── test_main_window.py # Main window data processing and callback regressions
|
||||
@@ -433,10 +433,9 @@ tests/
|
||||
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
|
||||
├── test_file_explorer.py # File explorer themed icon state regressions
|
||||
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
|
||||
├── test_exe_metadata.py # PE version strings, shell output, and ExifTool fallback
|
||||
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
|
||||
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
|
||||
├── test_shortcuts.py # Desktop shortcuts, folder import, edited exe paths, and gamepad navigation
|
||||
├── test_shortcuts.py # Desktop shortcut creation, paths with spaces, .desktop entry
|
||||
├── test_theme_store.py # Theme store UI worker lifecycle and race regressions
|
||||
├── test_theme_manager.py # Theme AST injection, parent resolution, ThemeWrapper, style integrity
|
||||
├── test_theme_security.py # Theme security checker (allowlist AST, forbidden modules/methods, SVG/font/image safety)
|
||||
@@ -1058,8 +1057,6 @@ PortProtonQt/
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Use the existing `orjson` dependency instead of standard-library `json` for faster JSON parsing and serialization. For formatted output, use `orjson.OPT_INDENT_2`.
|
||||
|
||||
### Core
|
||||
|
||||
| Package | Purpose | License |
|
||||
|
||||
@@ -7,63 +7,13 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен CLI для получения локальных данных библиотеки
|
||||
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
|
||||
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
|
||||
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
|
||||
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
|
||||
- Добавлена возможность добавлять установленные игры GOG и EGS в Steam
|
||||
- Добавлен массовый импорт игр из выбранной папки
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: пробел включает и отменяет режим с очисткой выделения, ЛКМ и кнопка A геймпада переключают выделение карточек
|
||||
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
|
||||
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
|
||||
- Добавлено предупреждение о кириллице в путях к игре или PortProton
|
||||
- Добавлен резервный запрет сна через logind/elogind при недоступности ScreenSaver и портала, в том числе для дистрибутивов без systemd
|
||||
|
||||
### Changed
|
||||
|
||||
- В Run After / Wrapper выбор файлов ограничен `.exe` и файлами с правом исполнения в Linux
|
||||
- В режиме CLOUD данные ProtonDB и ссылки на обложки SteamGridDB загружаются через `deckyloader.ru`
|
||||
- Повторный запуск с `--silent` теперь запрашивает подтверждение: при согласии текущий PortProtonQt и запущенные игры завершаются, затем приложение запускается заново для новой игры: при отказе новый запуск полностью отменяется, текущая сессия продолжает работать
|
||||
- Найденные Steam AppID сохраняются в кеше, чтобы при повторной загрузке библиотеки пропускать чтение метаданных EXE и поиск по каталогу Steam
|
||||
- При определении языка Steam_EMU теперь учитывается переменная `LANGUAGE`
|
||||
- При возврате из карточки игры библиотека сохраняет прокрутку и фокус на этой карточке
|
||||
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
|
||||
- После добавления или удаления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог
|
||||
- В скриптах PortProton ускорено чтение метаданных exe с помощью pefile
|
||||
- Разбор ответа PPDB в диалоге отчёта и запись форматированного JSON-кеша переведены на orjson
|
||||
- Настройки exe и `user.conf` применяются автоматически при изменении
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
|
||||
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
|
||||
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
|
||||
- Исправлено зависание при смене сетки библиотеки
|
||||
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
|
||||
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
|
||||
- При включённом фильтре установленных игр метаданные GOG и EGS загружаются только для установленных игр
|
||||
- Исправлено отображение хвостиков источника игр в вертикальном режиме библиотеки
|
||||
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
|
||||
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
|
||||
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
|
||||
- Наведение на карточки больше не перехватывает фокус из строки поиска при вводе текста
|
||||
- Исправлена ошибка форматирования даты последнего запуска и времени игры при системной локали `C` или `POSIX`
|
||||
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
|
||||
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
|
||||
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
- Исправлено обрезание значений в избранных настройках
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Release" alt="Releases"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-17.9k-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-14.1k-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&logo=python" alt="Python 3.11+"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt"><img src="https://img.shields.io/badge/Platform-Linux-blue?style=flat-square&logo=linux" alt="Platform: Linux"></a>
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
<p>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Релиз" alt="Релизы"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-17.9k-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-14.1k-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&logo=python" alt="Python 3.11+"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"><img src="https://img.shields.io/badge/Платформа-Linux-blue?style=flat-square&logo=linux" alt="Платформа: Linux"></a>
|
||||
|
||||
@@ -2272,14 +2272,9 @@ manage_user_conf_value () {
|
||||
|
||||
use_exiftool () {
|
||||
if [[ ! -f "${PW_TMPFS_PATH}/exiftool.tmp" ]] \
|
||||
|| ! grep -Fqx -- "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
|| ! grep -q "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
printf '%s\n' "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
if [[ "${1,,}" == *.exe ]] \
|
||||
&& python_module exe_metadata "$1" >> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
echo "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
if ! command -v exiftool &>/dev/null \
|
||||
|| ! timeout 3 exiftool "$1" &>> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
@@ -2433,8 +2428,9 @@ pw_specialk_set_ini_value () {
|
||||
[[ -f "$ini_file" ]] && sed -i '/./,$!d' "$ini_file"
|
||||
|
||||
if grep -q "^\[$section\]" "$ini_file" ; then
|
||||
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" | grep -q "^$key=" ; then
|
||||
sed -i "/^\[$section\]/,/^\[/{s/^$key=.*/$key=$value/}" "$ini_file"
|
||||
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" \
|
||||
| grep -q "^[[:space:]]*$key[[:space:]]*=" ; then
|
||||
sed -i "/^\[$section\]/,/^\[/{s/^[[:space:]]*$key[[:space:]]*=.*/$key=$value/}" "$ini_file"
|
||||
else
|
||||
sed -i "/^\[$section\]/a $key=$value" "$ini_file"
|
||||
fi
|
||||
@@ -2527,6 +2523,171 @@ pw_specialk_sync_files () {
|
||||
|
||||
}
|
||||
|
||||
pw_reshade_sync_files () {
|
||||
local root="${PW_PLUGINS_PATH}/reshade_windows" runtime detect arch dll highest ogl vulkan source
|
||||
local manifest_reg package installed installed_arch installed_version extract_dir reshade_exe reshade_font font_win screenshot_path backup
|
||||
local PATH_TO_GAME="${PATH_TO_GAME:-}"
|
||||
runtime="$root/reshade/${PW_RESHADE_VER}_Addon"
|
||||
reshade_exe="${PW_RESHADE_EXE:-$PW_EXE_FILE}"
|
||||
if [[ ! -f "$reshade_exe" ]] ; then
|
||||
print_warning "ReShade executable not found: $reshade_exe. Using $PW_EXE_FILE."
|
||||
reshade_exe="$PW_EXE_FILE"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 ]] ; then
|
||||
detect=$(python_module graphics_detector "$reshade_exe")
|
||||
file "$reshade_exe" | grep -qE 'x86-64|PE32\+' && arch=64 || arch=32
|
||||
highest=$(echo "$detect" | grep '^HIGHEST_DX=' | cut -d= -f2)
|
||||
ogl=$(echo "$detect" | grep '^USES_OGL=' | cut -d= -f2)
|
||||
vulkan=$(echo "$detect" | grep '^USES_VULKAN=' | cut -d= -f2)
|
||||
source=$(echo "$detect" | grep '^SOURCE=' | cut -d= -f2-)
|
||||
[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] && vulkan=false
|
||||
fi
|
||||
if [[ -n "$PW_RESHADE_EXE" ]] ; then
|
||||
PATH_TO_GAME="$(dirname "$(realpath "$reshade_exe")")"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 ]] \
|
||||
&& [[ ! -f "$runtime/ReShade32.dll" || ! -f "$runtime/ReShade32.json" \
|
||||
|| ! -f "$runtime/ReShade64.dll" || ! -f "$runtime/ReShade64.json" ]] ; then
|
||||
try_download "https://reshade.me/downloads/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
|
||||
"${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
|
||||
create_new_dir "$runtime"
|
||||
unpack "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
|
||||
"$runtime" silent || fatal "Failed to extract ReShade."
|
||||
try_remove_file "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3dcompiler_47.dll" ]] ; then
|
||||
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/d3dcompiler_47.dll" "$root/d3dcompiler_47.dll"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3d8.dll" ]] ; then
|
||||
try_download "https://github.com/crosire/d3d8to9/releases/download/v${PW_D3D8TO9_VER}/d3d8.dll" "$root/d3d8.dll"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -d "$root/shaders/Shaders" ]] ; then
|
||||
try_remove_dir "$root/shaders"
|
||||
create_new_dir "$root/shaders/Shaders"
|
||||
create_new_dir "$root/shaders/Textures"
|
||||
for package in reshade_shaders sweetfx_shaders immerse_shaders astrayfx_shaders prod80_shaders retroarch_shaders ; do
|
||||
extract_dir="${PORT_DATA_PATH}/data/tmp/$package"
|
||||
if [[ "$package" == immerse_shaders ]] ; then
|
||||
try_download "https://github.com/martymcmodding/iMMERSE/archive/refs/heads/main.tar.gz" \
|
||||
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
else
|
||||
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/${package}.tar.gz" \
|
||||
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
fi
|
||||
try_remove_dir "$extract_dir"
|
||||
create_new_dir "$extract_dir"
|
||||
unpack "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" "$extract_dir" \
|
||||
silent || fatal "Failed to extract $package."
|
||||
find "$extract_dir" -type d -iname Shaders -exec cp -rf '{}/.' "$root/shaders/Shaders/" \;
|
||||
find "$extract_dir" -type d -iname Textures -exec cp -rf '{}/.' "$root/shaders/Textures/" \;
|
||||
try_remove_dir "$extract_dir"
|
||||
try_remove_file "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
done
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && "$vulkan" == true ]] ; then
|
||||
dll="Vulkan${arch}"
|
||||
elif [[ "$PW_USE_RESHADE" == 1 ]] ; then
|
||||
case "$highest" in
|
||||
"DirectX 10"|"DirectX 11"|"DirectX 12") dll=dxgi.dll ;;
|
||||
"DirectX 8"|"DirectX 9") dll=d3d9.dll ;;
|
||||
"DirectDraw") return 0 ;;
|
||||
*) [[ "$ogl" == true ]] && dll=opengl32.dll || dll=dxgi.dll ;;
|
||||
esac
|
||||
fi
|
||||
case "$PW_RESHADE_API" in
|
||||
vulkan) vulkan=true; dll="Vulkan${arch}" ;;
|
||||
dxgi.dll|d3d9.dll|opengl32.dll) vulkan=false; dll="$PW_RESHADE_API" ;;
|
||||
esac
|
||||
if [[ -f "$PATH_TO_GAME/reshade_version" ]] \
|
||||
&& { [[ "$PW_USE_RESHADE" != 1 ]] \
|
||||
|| [[ "$(<"$PATH_TO_GAME/reshade_version")" != "${PW_RESHADE_VER}_Addon"$'\n'"$dll" ]]; } ; then
|
||||
installed_version=$(sed -n '1p' "$PATH_TO_GAME/reshade_version")
|
||||
installed=$(sed -n '2p' "$PATH_TO_GAME/reshade_version")
|
||||
if [[ "$installed" == Vulkan32 || "$installed" == Vulkan64 ]] ; then
|
||||
installed_arch=${installed#Vulkan}
|
||||
manifest_reg="Z:${root//\//\\}\\reshade\\${installed_version}\\ReShade${installed_arch}.json"
|
||||
manifest_reg=${manifest_reg//\\/\\\\\\\\}
|
||||
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" user
|
||||
if [[ "$installed_arch" == 32 ]] ; then
|
||||
get_and_set_reg_file --delete 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" system
|
||||
else
|
||||
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" system
|
||||
fi
|
||||
fi
|
||||
try_remove_file "$PATH_TO_GAME/$installed"
|
||||
if [[ -L "$PATH_TO_GAME/${installed}.b" \
|
||||
&& $(readlink -f "$PATH_TO_GAME/${installed}.b") == "$root/reshade/"* ]] ; then
|
||||
unlink "$PATH_TO_GAME/${installed}.b"
|
||||
fi
|
||||
[[ -e "$PATH_TO_GAME/${installed}.b" ]] \
|
||||
&& mv -f "$PATH_TO_GAME/${installed}.b" "$PATH_TO_GAME/$installed"
|
||||
[[ -L "$PATH_TO_GAME/d3dcompiler_47.dll" ]] && unlink "$PATH_TO_GAME/d3dcompiler_47.dll"
|
||||
[[ -L "$PATH_TO_GAME/d3d8.dll" ]] && unlink "$PATH_TO_GAME/d3d8.dll"
|
||||
[[ -e "$PATH_TO_GAME/d3d8.dll.b" ]] \
|
||||
&& mv -f "$PATH_TO_GAME/d3d8.dll.b" "$PATH_TO_GAME/d3d8.dll"
|
||||
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
|
||||
try_remove_file "$PATH_TO_GAME/ReShade.ini"
|
||||
try_remove_file "$PATH_TO_GAME/ReShadePreset.ini"
|
||||
try_remove_file "$PATH_TO_GAME/ReShade.log"
|
||||
try_remove_file "$PATH_TO_GAME/reshade_version"
|
||||
fi
|
||||
[[ "$PW_USE_RESHADE" != 1 ]] && return 0
|
||||
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
|
||||
try_force_link_dir "$root/shaders" "$PATH_TO_GAME/ReShade_shaders"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "EffectSearchPaths" '.\\ReShade_shaders\\Shaders'
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "TextureSearchPaths" '.\\ReShade_shaders\\Textures'
|
||||
if [[ -n "$PW_RESHADE_FONT" ]] ; then
|
||||
reshade_font=$(fc-match --format='%{file}' "$PW_RESHADE_FONT")
|
||||
else
|
||||
reshade_font=/usr/share/fonts/ttf/dejavu/DejaVuSans.ttf
|
||||
fi
|
||||
if [[ -f "$reshade_font" ]] ; then
|
||||
font_win="Z:${reshade_font//\//\\\\}"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "Font" "$font_win"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "EditorFont" "$font_win"
|
||||
elif [[ -n "$PW_RESHADE_FONT" ]] ; then
|
||||
print_warning "ReShade font not found: $PW_RESHADE_FONT"
|
||||
fi
|
||||
screenshot_path='.\\'
|
||||
if [[ -n "$PW_RESHADE_SCREENSHOT_PATH" ]] ; then
|
||||
create_new_dir "$PW_RESHADE_SCREENSHOT_PATH"
|
||||
screenshot_path="Z:${PW_RESHADE_SCREENSHOT_PATH//\//\\\\}"
|
||||
fi
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyScreenshot" "$PW_RESHADE_SCREENSHOT_KEY"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyOverlay" "$PW_RESHADE_OVERLAY_KEY"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "SCREENSHOT" "SavePath" "$screenshot_path"
|
||||
if [[ "$vulkan" == true ]] ; then
|
||||
manifest_reg="Z:${runtime//\//\\}\\ReShade${arch}.json"
|
||||
manifest_reg=${manifest_reg//\\/\\\\\\\\}
|
||||
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 user
|
||||
if [[ "$arch" == 32 ]] ; then
|
||||
get_and_set_reg_file --add 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
|
||||
else
|
||||
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
|
||||
fi
|
||||
var_winedlloverride_update "vulkan-1=n,b"
|
||||
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
|
||||
return 0
|
||||
fi
|
||||
[[ ! -f "$PATH_TO_GAME/reshade_version" \
|
||||
&& -e "$PATH_TO_GAME/$dll" && ! -e "$PATH_TO_GAME/${dll}.b" \
|
||||
&& ( ! -L "$PATH_TO_GAME/$dll" \
|
||||
|| $(readlink -f "$PATH_TO_GAME/$dll") != "$root/reshade/"* ) ]] \
|
||||
&& mv -f "$PATH_TO_GAME/$dll" "$PATH_TO_GAME/${dll}.b"
|
||||
try_force_link_file "$runtime/ReShade${arch}.dll" "$PATH_TO_GAME/$dll"
|
||||
try_force_link_file "$root/d3dcompiler_47.dll" "$PATH_TO_GAME/d3dcompiler_47.dll"
|
||||
var_winedlloverride_update "d3dcompiler_47=n;${dll%.*}=n,b"
|
||||
if [[ "$highest" == "DirectX 8" && "$dll" == d3d9.dll ]] ; then
|
||||
[[ -e "$PATH_TO_GAME/d3d8.dll" && ! -e "$PATH_TO_GAME/d3d8.dll.b" ]] && mv -f "$PATH_TO_GAME/d3d8.dll" "$PATH_TO_GAME/d3d8.dll.b"
|
||||
try_force_link_file "$root/d3d8.dll" "$PATH_TO_GAME/d3d8.dll"
|
||||
var_winedlloverride_update "d3d8=n,b"
|
||||
fi
|
||||
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
|
||||
}
|
||||
|
||||
pw_prepare_gamescope_launch () {
|
||||
[[ "$PW_USE_NATIVE_WAYLAND" == "1" ]] && export PW_GAMESCOPE="0"
|
||||
|
||||
@@ -2962,6 +3123,7 @@ start_portproton () {
|
||||
|
||||
pw_optiscaler_sync_files
|
||||
pw_specialk_sync_files
|
||||
pw_reshade_sync_files
|
||||
|
||||
if [[ "${PW_USE_RAY_TRACING}" == "1" ]] ; then
|
||||
var_vkd3d_config_update dxr
|
||||
@@ -4012,25 +4174,6 @@ pw_launch_wrapper () {
|
||||
return
|
||||
fi
|
||||
|
||||
local -a wrapper_scripts=()
|
||||
local wrapper_script
|
||||
local wrapper_scripts_valid=1
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == *","* ]] ; then
|
||||
IFS=',' read -r -a wrapper_scripts <<< "${PW_RUN_AFTER_EXE}"
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
if [[ ! -f "${wrapper_script}" || "${wrapper_script,,}" == *.exe ]] ; then
|
||||
wrapper_scripts_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ "${wrapper_scripts_valid}" == 1 ]] ; then
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
"${wrapper_script}"
|
||||
done
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local -a wrapper_cmd
|
||||
eval "wrapper_cmd=(${PW_RUN_AFTER_EXE})"
|
||||
if [[ ${#wrapper_cmd[@]} -eq 0 ]] ; then
|
||||
@@ -4076,7 +4219,6 @@ steamplay_launch () {
|
||||
|
||||
pw_optiscaler_sync_files
|
||||
pw_specialk_sync_files
|
||||
|
||||
PORT_WINE_PREFIX="$STEAM_COMPAT_DATA_PATH/pfx"
|
||||
|
||||
pw_prepare_gamescope_launch
|
||||
@@ -4142,6 +4284,8 @@ steamplay_launch () {
|
||||
fi
|
||||
fi
|
||||
|
||||
pw_reshade_sync_files
|
||||
|
||||
# Winetricks
|
||||
if [[ -n "${PW_DLL_INSTALL:-}" ]] ; then
|
||||
update_winetricks
|
||||
@@ -4212,22 +4356,9 @@ portwine_launch () {
|
||||
fi
|
||||
done
|
||||
else
|
||||
local -a run_after_exes=() run_after_environment=()
|
||||
local run_after_entry run_after_index
|
||||
local run_after_valid=1
|
||||
if [[ -f "${PW_RUN_AFTER_EXE:-}" ]] ; then
|
||||
run_after_exes=("${PW_RUN_AFTER_EXE}")
|
||||
else
|
||||
IFS=',' read -r -a run_after_exes <<< "${PW_RUN_AFTER_EXE:-}"
|
||||
fi
|
||||
for run_after_entry in "${run_after_exes[@]}" ; do
|
||||
if [[ ! -f "${run_after_entry}" || "${run_after_entry,,}" != *.exe ]] ; then
|
||||
run_after_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
|
||||
&& [[ "${run_after_valid}" == 1 ]] \
|
||||
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
|
||||
then
|
||||
local wait_delay wait_delay_int run_after_bat
|
||||
@@ -4251,7 +4382,6 @@ portwine_launch () {
|
||||
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
|
||||
fi
|
||||
pw_exe_file_win="${pw_exe_file_win//\//\\}"
|
||||
local PW_RUN_AFTER_EXE="${run_after_exes[0]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
|
||||
@@ -4270,30 +4400,10 @@ timeout /t ${wait_delay_int} /nobreak
|
||||
start "" "%PW_RUN_AFTER_SECOND_PATH%"
|
||||
EOF
|
||||
|
||||
local run_after_first_win="${run_after_exe_win}"
|
||||
for ((run_after_index=1; run_after_index<${#run_after_exes[@]}; run_after_index++)) ; do
|
||||
run_after_entry="${run_after_exes[run_after_index]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${run_after_entry}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${run_after_entry}" == "${wine_drive_c}/"* ]] ; then
|
||||
run_after_exe_win="c:\\${run_after_entry#"${wine_drive_c}/"}"
|
||||
else
|
||||
run_after_exe_win="z:${run_after_entry//\//\\}"
|
||||
fi
|
||||
fi
|
||||
run_after_environment+=("PW_RUN_AFTER_PATH_${run_after_index}=${run_after_exe_win//\//\\}")
|
||||
printf 'start "" "%%PW_RUN_AFTER_PATH_%s%%"\r\n' "${run_after_index}" >> "${run_after_bat}"
|
||||
done
|
||||
|
||||
(
|
||||
if [[ ${#run_after_environment[@]} -gt 0 ]] ; then
|
||||
export "${run_after_environment[@]}"
|
||||
fi
|
||||
PW_RUN_AFTER_EXE="" LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_first_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
)
|
||||
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
try_remove_file "${run_after_bat}"
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
@@ -4389,7 +4499,9 @@ gui_edit_db () {
|
||||
KEY_EDIT_DB_GUI=$RANDOM
|
||||
DISABLE_EDIT_DB_LIST=""
|
||||
PW_EDIT_DB_LIST=(PW_MANGOHUD PW_MANGOHUD_USER_CONF PW_VKBASALT PW_VKBASALT_USER_CONF
|
||||
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK
|
||||
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK PW_USE_RESHADE
|
||||
PW_RESHADE_EXE PW_RESHADE_API PW_RESHADE_FONT PW_RESHADE_SCREENSHOT_PATH
|
||||
PW_RESHADE_SCREENSHOT_KEY PW_RESHADE_OVERLAY_KEY
|
||||
PW_USE_ESYNC PW_USE_FSYNC PW_USE_NTSYNC PW_USE_RAY_TRACING PW_USE_NVAPI_AND_DLSS PW_USE_OPTISCALER
|
||||
PW_USE_LS_FRAME_GEN PW_WINE_FULLSCREEN_FSR PW_HIDE_NVIDIA_GPU PW_VIRTUAL_DESKTOP PW_USE_TERMINAL
|
||||
PW_USE_GAMEMODE PW_USE_INHIBIT_SLEEP PW_USE_D3D_EXTRAS PW_FIX_VIDEO_IN_GAME PW_REDUCE_PULSE_LATENCY
|
||||
@@ -4423,6 +4535,10 @@ gui_edit_db () {
|
||||
DISABLE_EDIT_DB_LIST+=" PW_VKBASALT PW_VKBASALT_USER_CONF PW_USE_RAY_TRACING PW_USE_OBS_VKCAPTURE PW_USE_GALLIUM_ZINK PW_USE_WINED3D_VULKAN"
|
||||
fi
|
||||
[[ "${PW_USE_SPECIALK}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_DGVOODOO2"
|
||||
[[ "${PW_USE_RESHADE}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"
|
||||
if [[ "${PW_USE_OPTISCALER}" == "1" || "${PW_USE_SPECIALK}" == "1" ]] ; then
|
||||
DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"
|
||||
fi
|
||||
|
||||
[[ ! -e "/dev/ntsync" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_NTSYNC"
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
|
||||
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
|
||||
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
|
||||
export PW_SPECIALK_VER="SK_26_5_15"
|
||||
export PW_RESHADE_VER="6.8.0"
|
||||
export PW_D3D8TO9_VER="1.15.1"
|
||||
export PW_GALLIUM_NINE_VER="0.10"
|
||||
export DOTPFX_VER="48v9"
|
||||
export DEFPFX_VER="v1"
|
||||
@@ -33,6 +35,13 @@ check_variables PROTON_USE_XALIA "0"
|
||||
check_variables PW_USE_RUNTIME "1"
|
||||
check_variables PW_USE_D7VK "0"
|
||||
check_variables PW_USE_SPECIALK "0"
|
||||
check_variables PW_USE_RESHADE "0"
|
||||
check_variables PW_RESHADE_API "auto"
|
||||
check_variables PW_RESHADE_EXE ""
|
||||
check_variables PW_RESHADE_FONT ""
|
||||
check_variables PW_RESHADE_SCREENSHOT_PATH ""
|
||||
check_variables PW_RESHADE_SCREENSHOT_KEY "44,0,0,0"
|
||||
check_variables PW_RESHADE_OVERLAY_KEY "36,0,0,0"
|
||||
check_variables PW_USE_ESYNC "0"
|
||||
check_variables PW_USE_FSYNC "0"
|
||||
check_variables PW_USE_NTSYNC "0"
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "call of duty black ops cold war",
|
||||
"status": "Running",
|
||||
"status": "Broken",
|
||||
"slug": "call-of-duty-black-ops-cold-war"
|
||||
},
|
||||
{
|
||||
@@ -1286,7 +1286,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "nikke goddess of victory",
|
||||
"status": "Running",
|
||||
"status": "Broken",
|
||||
"slug": "nikke"
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
+796
-20228
File diff suppressed because it is too large.
Load diff
Binary file not shown.
+10
-205
@@ -384,16 +384,6 @@
|
||||
"normalized_name": "automobilista 2",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 8729,
|
||||
"normalized_name": "autonauts",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 8640,
|
||||
"normalized_name": "avatar frontiers of pandora",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3568,
|
||||
"normalized_name": "avowed",
|
||||
@@ -414,11 +404,6 @@
|
||||
"normalized_name": "ayasa shadows of silence",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 43802,
|
||||
"normalized_name": "ball x pit",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2352,
|
||||
"normalized_name": "biomutant",
|
||||
@@ -444,11 +429,6 @@
|
||||
"normalized_name": "bad day l.a.",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116179,
|
||||
"normalized_name": "baldo the guardian owls",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 27439,
|
||||
"normalized_name": "baldur's gate dark alliance ii",
|
||||
@@ -554,11 +534,6 @@
|
||||
"normalized_name": "battlefield v",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 6953,
|
||||
"normalized_name": "beacon pines",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3003,
|
||||
"normalized_name": "bendy and the dark revival",
|
||||
@@ -944,11 +919,6 @@
|
||||
"normalized_name": "call of juarez",
|
||||
"overall_rating": "silver"
|
||||
},
|
||||
{
|
||||
"id": 128972,
|
||||
"normalized_name": "captain contraption's chocolate factory",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2554,
|
||||
"normalized_name": "carx street",
|
||||
@@ -1019,16 +989,6 @@
|
||||
"normalized_name": "clair obscur expedition 33",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1389,
|
||||
"normalized_name": "clicker heroes",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 95679,
|
||||
"normalized_name": "clive barker's hellraiser revival",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 137209,
|
||||
"normalized_name": "clive barker's jericho",
|
||||
@@ -1639,11 +1599,6 @@
|
||||
"normalized_name": "doomsday × attack on titan",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 22403,
|
||||
"normalized_name": "down in bermuda",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 137816,
|
||||
"normalized_name": "downtown run",
|
||||
@@ -1834,11 +1789,6 @@
|
||||
"normalized_name": "everspace 2",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 32237,
|
||||
"normalized_name": "echo weaver",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 135061,
|
||||
"normalized_name": "eclipse",
|
||||
@@ -1879,11 +1829,6 @@
|
||||
"normalized_name": "enclave",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 142352,
|
||||
"normalized_name": "end of abyss",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1499,
|
||||
"normalized_name": "enshrouded",
|
||||
@@ -1954,21 +1899,6 @@
|
||||
"normalized_name": "expeditions a mudrunner game",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144749,
|
||||
"normalized_name": "eye of the beholder",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144750,
|
||||
"normalized_name": "eye of the beholder ii the legend of darkmoon",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144751,
|
||||
"normalized_name": "eye of the beholder iii assault on myth drannor",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2102,
|
||||
"normalized_name": "f.e.a.r.",
|
||||
@@ -1984,11 +1914,6 @@
|
||||
"normalized_name": "f.e.a.r. perseus mandate",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 6674,
|
||||
"normalized_name": "fatal frame / project zero maiden of black water",
|
||||
"overall_rating": "silver"
|
||||
},
|
||||
{
|
||||
"id": 122228,
|
||||
"normalized_name": "final fantasy xvi",
|
||||
@@ -1999,11 +1924,6 @@
|
||||
"normalized_name": "foundry",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 6470,
|
||||
"normalized_name": "fpv kamikaze drone",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 25751,
|
||||
"normalized_name": "front mission 1st remake",
|
||||
@@ -2019,11 +1939,6 @@
|
||||
"normalized_name": "ftl faster than light",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 57304,
|
||||
"normalized_name": "futa runner big dick",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2088,
|
||||
"normalized_name": "fable anniversary",
|
||||
@@ -2062,7 +1977,7 @@
|
||||
{
|
||||
"id": 3730,
|
||||
"normalized_name": "fallout tactics brotherhood of steel",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 1586,
|
||||
@@ -2097,7 +2012,7 @@
|
||||
{
|
||||
"id": 1509,
|
||||
"normalized_name": "far cry 4",
|
||||
"overall_rating": "silver"
|
||||
"overall_rating": "broken"
|
||||
},
|
||||
{
|
||||
"id": 112335,
|
||||
@@ -2134,11 +2049,6 @@
|
||||
"normalized_name": "fearmonium",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 142741,
|
||||
"normalized_name": "femboy futa house",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3557,
|
||||
"normalized_name": "final fantasy iii (3d remake)",
|
||||
@@ -2487,7 +2397,7 @@
|
||||
{
|
||||
"id": 134441,
|
||||
"normalized_name": "hard truck 2 king of the road",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 2141,
|
||||
@@ -2520,13 +2430,8 @@
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116886,
|
||||
"id": 135348,
|
||||
"normalized_name": "headquarters world war ii",
|
||||
"overall_rating": null
|
||||
},
|
||||
{
|
||||
"id": 95965,
|
||||
"normalized_name": "hearts of iron iv",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
@@ -2574,11 +2479,6 @@
|
||||
"normalized_name": "heroes of science and fiction",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1930,
|
||||
"normalized_name": "heroes of might & magic iii hd",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3035,
|
||||
"normalized_name": "hi fi rush",
|
||||
@@ -2879,11 +2779,6 @@
|
||||
"normalized_name": "kiborg",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144768,
|
||||
"normalized_name": "kamikaze strike fpv drone",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 118598,
|
||||
"normalized_name": "kane & lynch 2 dog days",
|
||||
@@ -3259,11 +3154,6 @@
|
||||
"normalized_name": "maid of the dead",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 117468,
|
||||
"normalized_name": "maneater",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3619,
|
||||
"normalized_name": "manhunt",
|
||||
@@ -3454,11 +3344,6 @@
|
||||
"normalized_name": "mirror's edge catalyst",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 73042,
|
||||
"normalized_name": "miss!don't affect my rank",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 71287,
|
||||
"normalized_name": "modern warships",
|
||||
@@ -3504,11 +3389,6 @@
|
||||
"normalized_name": "mudrunner",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2747,
|
||||
"normalized_name": "mutant year zero road to eden",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2061,
|
||||
"normalized_name": "my summer car",
|
||||
@@ -3804,11 +3684,6 @@
|
||||
"normalized_name": "overgrowth",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 121097,
|
||||
"normalized_name": "overlord",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 23742,
|
||||
"normalized_name": "overthrown",
|
||||
@@ -3994,11 +3869,6 @@
|
||||
"normalized_name": "praetorians hd remaster",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 5719,
|
||||
"normalized_name": "praey for the gods",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 83824,
|
||||
"normalized_name": "prehistorik",
|
||||
@@ -4014,11 +3884,6 @@
|
||||
"normalized_name": "prey",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 7107,
|
||||
"normalized_name": "prince of persia the lost crown",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3646,
|
||||
"normalized_name": "prince of persia the sands of time",
|
||||
@@ -4075,9 +3940,9 @@
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 28860,
|
||||
"id": 134787,
|
||||
"normalized_name": "quartet",
|
||||
"overall_rating": null
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 8765,
|
||||
@@ -4479,11 +4344,6 @@
|
||||
"normalized_name": "star wars zero company",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1673,
|
||||
"normalized_name": "star wars battlefront ii",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116330,
|
||||
"normalized_name": "star wars empire at war gold pack",
|
||||
@@ -4664,11 +4524,6 @@
|
||||
"normalized_name": "shadow gambit the cursed crew",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 9068,
|
||||
"normalized_name": "shadow man",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1745,
|
||||
"normalized_name": "shadow of the tomb raider",
|
||||
@@ -4759,11 +4614,6 @@
|
||||
"normalized_name": "sleeping dogs",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3137,
|
||||
"normalized_name": "sniper elite",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1624,
|
||||
"normalized_name": "sniper elite 3",
|
||||
@@ -4804,11 +4654,6 @@
|
||||
"normalized_name": "sonic heroes",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2065,
|
||||
"normalized_name": "sonic mania",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 4357,
|
||||
"normalized_name": "sorcerer king rivals",
|
||||
@@ -5049,11 +4894,6 @@
|
||||
"normalized_name": "stoneshard",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144782,
|
||||
"normalized_name": "stranger things 3 the game",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 142281,
|
||||
"normalized_name": "stranglehold",
|
||||
@@ -5199,11 +5039,6 @@
|
||||
"normalized_name": "tron 2.0",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3256,
|
||||
"normalized_name": "tails of iron",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2892,
|
||||
"normalized_name": "tainted grail the fall of avalon",
|
||||
@@ -5294,11 +5129,6 @@
|
||||
"normalized_name": "the alters",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144742,
|
||||
"normalized_name": "the amazing spider man (2012)",
|
||||
"overall_rating": "bronze"
|
||||
},
|
||||
{
|
||||
"id": 1873,
|
||||
"normalized_name": "the ascent",
|
||||
@@ -5334,11 +5164,6 @@
|
||||
"normalized_name": "the callisto protocol",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144752,
|
||||
"normalized_name": "the chronicles of riddick assault on dark athena",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 6140,
|
||||
"normalized_name": "the crust",
|
||||
@@ -5429,11 +5254,6 @@
|
||||
"normalized_name": "the gold river project",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 95161,
|
||||
"normalized_name": "the imperial gatekeeper",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 122565,
|
||||
"normalized_name": "the invincible",
|
||||
@@ -5447,7 +5267,7 @@
|
||||
{
|
||||
"id": 1898,
|
||||
"normalized_name": "the last remnant",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "silver"
|
||||
},
|
||||
{
|
||||
"id": 117639,
|
||||
@@ -5854,11 +5674,6 @@
|
||||
"normalized_name": "tropico 2 pirate cove",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 131874,
|
||||
"normalized_name": "tropico 5",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 87300,
|
||||
"normalized_name": "truck driver",
|
||||
@@ -5982,7 +5797,7 @@
|
||||
{
|
||||
"id": 143163,
|
||||
"normalized_name": "vk play",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "bronze"
|
||||
},
|
||||
{
|
||||
"id": 2997,
|
||||
@@ -6229,11 +6044,6 @@
|
||||
"normalized_name": "whiskerwood",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 107512,
|
||||
"normalized_name": "wild west pioneers",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116847,
|
||||
"normalized_name": "will rock",
|
||||
@@ -6257,7 +6067,7 @@
|
||||
{
|
||||
"id": 138835,
|
||||
"normalized_name": "witcher 3 wild hunt",
|
||||
"overall_rating": "gold"
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 6964,
|
||||
@@ -6299,11 +6109,6 @@
|
||||
"normalized_name": "world of tanks",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1335,
|
||||
"normalized_name": "world of tanks blitz",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 131549,
|
||||
"normalized_name": "world of warcraft",
|
||||
@@ -6472,7 +6277,7 @@
|
||||
{
|
||||
"id": 10633,
|
||||
"normalized_name": "калибр",
|
||||
"overall_rating": "silver"
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 138882,
|
||||
|
||||
Binary file not shown.
@@ -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 --list-games --all --game-info --edit-game --set --fields --pretty --raw --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 --list-games --all --game-info --edit-game --set --fields --pretty --raw --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 --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"
|
||||
|
||||
# Values for options with arguments
|
||||
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -73,19 +73,11 @@ _portprotonqt() {
|
||||
'--add-steam-compat-tool[Add as Steam compatibility tool]'
|
||||
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
|
||||
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
|
||||
'--list-games[Print local game libraries as JSON]'
|
||||
'--game-info[Print one game as JSON]:ID:'
|
||||
'--edit-game[Edit game settings]:ID:'
|
||||
'*--set[Set a game setting]:KEY=VALUE:'
|
||||
'--all[Include uninstalled games in the library list]'
|
||||
'--fields[Select JSON fields]:FIELD,...:'
|
||||
'--pretty[Format JSON with indentation]'
|
||||
'--raw[Print compact JSON]'
|
||||
'--clear-cache[Clear PortProtonQt cache and exit]'
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
'--silent[Launch Windows file or native game in tray]'
|
||||
'--log[Enable game logging]'
|
||||
'--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]'
|
||||
@@ -102,26 +94,18 @@ 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 --list-games --all --game-info --edit-game --set --fields --pretty --raw --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 --log --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"
|
||||
complete -c portprotonqt -l add-steam-compat-tool -d "Add as Steam compatibility tool"
|
||||
complete -c portprotonqt -l reinstall-steam-compat-tool -d "Reinstall Steam compatibility tool"
|
||||
complete -c portprotonqt -l remove-steam-compat-tool -d "Remove Steam compatibility tool"
|
||||
complete -c portprotonqt -l list-games -d "Print local game libraries as JSON"
|
||||
complete -c portprotonqt -l game-info -d "Print one game as JSON" -r -f
|
||||
complete -c portprotonqt -l edit-game -d "Edit game settings" -r -f
|
||||
complete -c portprotonqt -l set -d "Set a game setting (KEY=VALUE)" -r -f
|
||||
complete -c portprotonqt -l all -d "Include uninstalled games in the library list"
|
||||
complete -c portprotonqt -l fields -d "Select JSON fields (comma-separated)" -r -f
|
||||
complete -c portprotonqt -l pretty -d "Format JSON with indentation"
|
||||
complete -c portprotonqt -l raw -d "Print compact JSON"
|
||||
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 Windows file or native game in tray"
|
||||
complete -c portprotonqt -l log -d "Enable game logging"
|
||||
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"
|
||||
|
||||
@@ -4,7 +4,6 @@ import warnings
|
||||
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -168,8 +167,7 @@ class GameCardAnimations:
|
||||
"""Handle mouse enter event animations."""
|
||||
self.game_card._hovered = True
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, True)
|
||||
if not isinstance(QApplication.focusWidget(), QLineEdit):
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
+129
-155
@@ -2,7 +2,7 @@ import argparse
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import shlex
|
||||
import signal
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -11,21 +11,21 @@ from logging import Logger
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSystemTrayIcon,
|
||||
)
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtGui import QAction, QIcon
|
||||
|
||||
from portprotonqt.config import (
|
||||
consume_download_counter_skip,
|
||||
display_config,
|
||||
find_game_by_exe,
|
||||
get_portproton_start_command,
|
||||
get_portproton_location,
|
||||
save_portdata_path_to_config,
|
||||
@@ -34,7 +34,6 @@ from portprotonqt.config import (
|
||||
window_config,
|
||||
)
|
||||
from portprotonqt.logger import get_logger, setup_logger
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, create_native_desktop_file
|
||||
from portprotonqt.cli import (
|
||||
parse_args,
|
||||
is_portproton_url,
|
||||
@@ -67,8 +66,6 @@ if APP_VERSION == "@APP" "_VERSION@":
|
||||
|
||||
__app_version__ = APP_VERSION
|
||||
|
||||
RESTART_STOP_TIMEOUT_SECONDS = 30
|
||||
|
||||
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
|
||||
|
||||
def get_version():
|
||||
@@ -85,6 +82,103 @@ def get_version():
|
||||
return __app_version__
|
||||
|
||||
|
||||
def stop_portproton_game(start_sh: list[str], logger: Logger) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
start_sh + ["cli", "--stop"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("Failed to execute PortProton stop command: %s", e)
|
||||
|
||||
|
||||
def get_portproton_tray_icon() -> QIcon:
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
icon = ThemeManager().get_icon("tray_portproton", ui_config.get_theme())
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
|
||||
"""Launch a game with minimal tray game controls."""
|
||||
logger = get_logger(__name__)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
|
||||
tray_icon.setToolTip(__app_name__)
|
||||
|
||||
from datetime import datetime
|
||||
from portprotonqt.time_utils import save_last_launch, save_playtime
|
||||
save_last_launch(os.path.splitext(os.path.basename(exe_path))[0], datetime.now())
|
||||
start_time = datetime.now()
|
||||
|
||||
process = subprocess.Popen(start_sh + [exe_path], env=os.environ.copy(), shell=False)
|
||||
tray_menu = QMenu()
|
||||
|
||||
def end_silent_run():
|
||||
monitor_timer.stop()
|
||||
elapsed = int((datetime.now() - start_time).total_seconds())
|
||||
if elapsed > 0:
|
||||
save_playtime(exe_path, elapsed)
|
||||
tray_icon.hide()
|
||||
app.quit()
|
||||
|
||||
def stop_game() -> None:
|
||||
stop_portproton_game(start_sh, logger)
|
||||
end_silent_run()
|
||||
|
||||
def close_when_game_exits(_tray_menu: QMenu = tray_menu) -> None:
|
||||
if process.poll() is not None:
|
||||
end_silent_run()
|
||||
|
||||
def update_pause_action() -> list[psutil.Process]:
|
||||
try:
|
||||
children = psutil.Process(process.pid).children(recursive=True)
|
||||
paused = bool(children) and all(
|
||||
child.status() == psutil.STATUS_STOPPED for child in children
|
||||
)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
children = []
|
||||
paused = False
|
||||
pause_action.setEnabled(bool(children))
|
||||
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
|
||||
return children
|
||||
|
||||
def toggle_pause() -> None:
|
||||
children = update_pause_action()
|
||||
if not children:
|
||||
return
|
||||
try:
|
||||
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
|
||||
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
|
||||
for child in children:
|
||||
try:
|
||||
child.send_signal(pause_signal)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
|
||||
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
|
||||
logger.warning("Failed to inspect game pause state: %s", error)
|
||||
update_pause_action()
|
||||
|
||||
pause_action = QAction(_("Pause Game"), tray_menu)
|
||||
pause_action.triggered.connect(toggle_pause)
|
||||
tray_menu.addAction(pause_action)
|
||||
tray_menu.aboutToShow.connect(update_pause_action)
|
||||
stop_action = QAction(_("Stop Game"), tray_menu)
|
||||
stop_action.triggered.connect(stop_game)
|
||||
tray_menu.addAction(stop_action)
|
||||
tray_icon.setContextMenu(tray_menu)
|
||||
tray_icon.show()
|
||||
|
||||
monitor_timer = QTimer(app)
|
||||
monitor_timer.timeout.connect(close_when_game_exits)
|
||||
monitor_timer.start(1000)
|
||||
|
||||
|
||||
def restore_prefix_backup(start_sh: list[str], backup_path: str) -> int:
|
||||
"""Restore a PortProton prefix backup."""
|
||||
logger = get_logger(__name__)
|
||||
@@ -142,38 +236,6 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
if parsed_args.list_games or parsed_args.game_info or parsed_args.edit_game:
|
||||
import orjson
|
||||
|
||||
from portprotonqt.cli import edit_game, list_games
|
||||
|
||||
try:
|
||||
if parsed_args.edit_game:
|
||||
result = edit_game(parsed_args.edit_game, parsed_args.settings)
|
||||
elif parsed_args.game_info:
|
||||
games = list_games()
|
||||
result = next((game for game in games if game["id"] == parsed_args.game_info), None)
|
||||
if result is None:
|
||||
get_logger(__name__).error("Game not found: %s", parsed_args.game_info)
|
||||
return 1
|
||||
else:
|
||||
result = [game for game in list_games() if parsed_args.all_games or game.get("installed", False)]
|
||||
if parsed_args.fields:
|
||||
entries = result if isinstance(result, list) else [result]
|
||||
selected = [{field: game.get(field) for field in parsed_args.fields} for game in entries]
|
||||
result = selected if isinstance(result, list) else selected[0]
|
||||
option = orjson.OPT_INDENT_2 if parsed_args.pretty else 0
|
||||
sys.stdout.write(orjson.dumps(result, option=option).decode("utf-8") + "\n")
|
||||
except OSError as error:
|
||||
get_logger(__name__).error("Library command failed: %s", error)
|
||||
return 1
|
||||
except ValueError as error:
|
||||
get_logger(__name__).error("Invalid library command: %s", error)
|
||||
return 1
|
||||
except RuntimeError as error:
|
||||
get_logger(__name__).error("Failed to edit game settings: %s", error)
|
||||
return 1
|
||||
return 0
|
||||
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
@@ -273,17 +335,6 @@ def main():
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
args = parsed_args
|
||||
native_launch_uri = None
|
||||
if args.file_or_url and args.file_or_url.startswith(NATIVE_URI_PREFIX):
|
||||
native_launch_uri = args.file_or_url
|
||||
elif args.file_or_url and not is_launch_file(args.file_or_url):
|
||||
target = normalize_launch_path(args.file_or_url)
|
||||
if os.path.isfile(target) and os.access(target, os.X_OK) and not is_autoinstall_file(target) and not is_prefix_backup_file(target):
|
||||
entry = find_game_by_exe(target)
|
||||
name = entry.get("Name", os.path.basename(target)) if entry is not None else os.path.basename(target)
|
||||
result = create_native_desktop_file(target, name)
|
||||
if result and os.path.isfile(result[1]):
|
||||
native_launch_uri = NATIVE_URI_PREFIX + result[1]
|
||||
setup_logger(args.debug_level)
|
||||
logger = get_logger(__name__)
|
||||
if consume_download_counter_skip():
|
||||
@@ -315,9 +366,6 @@ def main():
|
||||
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 native_launch_uri:
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{quote(native_launch_uri, safe='')}"
|
||||
elif args.file_or_url and is_launch_file(args.file_or_url):
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
@@ -345,7 +393,7 @@ def main():
|
||||
if theme_store_id is not None:
|
||||
ipc_message = f"theme:{theme_store_id}"
|
||||
|
||||
from PySide6.QtCore import QLockFile, QStandardPaths, QThread, Signal
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from requests import RequestException
|
||||
from portprotonqt.appimage_updater import (
|
||||
@@ -371,10 +419,7 @@ def main():
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
|
||||
# --- Single-instance logic ---
|
||||
runtime_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
|
||||
server_name = os.path.join(runtime_dir, __app_id__)
|
||||
instance_lock = QLockFile(server_name + ".lock")
|
||||
owns_instance = instance_lock.tryLock(0)
|
||||
server_name = __app_id__
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
@@ -387,11 +432,7 @@ def main():
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
if not owns_instance:
|
||||
logger.warning("PortProtonQt is already running but its IPC server is unavailable")
|
||||
return
|
||||
|
||||
# Only the lock owner can remove a stale socket.
|
||||
# Remove old socket if it exists
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
@@ -415,7 +456,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 and not egs_launch_uri and not native_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
|
||||
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)
|
||||
@@ -500,19 +541,22 @@ def main():
|
||||
|
||||
launch_path = exe_path or autoinstall_path
|
||||
silent_game_request = is_silent_launch and not args.log and bool(
|
||||
exe_path or gog_launch_uri or egs_launch_uri or native_launch_uri
|
||||
exe_path or gog_launch_uri or egs_launch_uri
|
||||
)
|
||||
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
|
||||
window.silent_launch_mode = silent_game_request
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
window.tray_manager.set_minimal_mode()
|
||||
if exe_path:
|
||||
window.tray_manager.tray_icon.hide()
|
||||
else:
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = NATIVE_URI_PREFIX + app_id if source == "native" else f"{source}://launch/{app_id}"
|
||||
exec_line = f"{source}://launch/{app_id}"
|
||||
if log_mode:
|
||||
window._pending_log_exe = exec_line if source == "native" else getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
|
||||
def open_card(games: list[tuple]) -> None:
|
||||
game = next((item for item in games if item[5] == exec_line), None)
|
||||
@@ -526,44 +570,17 @@ def main():
|
||||
"anticheat_status": game[9], "playtime_seconds": game[11],
|
||||
"game_source": game[12], "anticheat_slug": game[13],
|
||||
"ppdb_id": game[14], "ppdb_rating": game[15],
|
||||
"protondb_appid": game[16] if len(game) > 16 else "",
|
||||
"protondb_appid": game[16],
|
||||
})
|
||||
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
loader(open_card)
|
||||
|
||||
restart_target = None
|
||||
|
||||
def handle_game_request(
|
||||
target: str, silent: bool, log_mode: bool = False, confirm: bool = False,
|
||||
) -> None:
|
||||
nonlocal restart_target
|
||||
if restart_target is not None:
|
||||
return
|
||||
if silent and (confirm or window.game_processes):
|
||||
message_box = QMessageBox(window)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(__app_name__)
|
||||
message_box.setText(_("PortProtonQt is already running. Stop it and all games, then restart for the new launch?"))
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
restart_target = target
|
||||
app.quit()
|
||||
return
|
||||
if target.startswith(NATIVE_URI_PREFIX):
|
||||
if silent and not log_mode:
|
||||
window.toggleGame(target)
|
||||
else:
|
||||
open_store_game_card("native", target.removeprefix(NATIVE_URI_PREFIX), log_mode)
|
||||
return
|
||||
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
|
||||
if is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
window.toggleGame(shlex.quote(path))
|
||||
run_silent_tray(app, start_sh, path)
|
||||
return
|
||||
window.handle_launch_exe(path, log_mode=log_mode)
|
||||
return
|
||||
@@ -574,11 +591,7 @@ def main():
|
||||
open_store_game_card(source, app_id, log_mode)
|
||||
|
||||
# Handle launch file if provided
|
||||
if native_launch_uri:
|
||||
def handle_native_launch() -> None:
|
||||
handle_game_request(native_launch_uri, is_silent_launch and not args.log, args.log)
|
||||
QTimer.singleShot(0, handle_native_launch)
|
||||
elif exe_path:
|
||||
if exe_path:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe() -> None:
|
||||
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
|
||||
@@ -652,15 +665,15 @@ def main():
|
||||
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True, confirm=True)
|
||||
handle_game_request(target, True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
handle_game_request(launch_path, False, log_mode)
|
||||
elif launch_path.startswith(NATIVE_URI_PREFIX) or (log_mode and launch_path.startswith(("gog://launch/", "egs://launch/"))):
|
||||
handle_game_request(unquote(launch_path), False, log_mode)
|
||||
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
|
||||
handle_game_request(unquote(launch_path), False, True)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -794,14 +807,13 @@ def main():
|
||||
return
|
||||
QMessageBox.warning(window, _("Error"), _("Failed to update AppImage."))
|
||||
|
||||
if not is_silent_launch:
|
||||
window.appimageUpdateWorker = AppImageUpdateWorker("check")
|
||||
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
|
||||
window.appimageUpdateWorker.finished.connect(
|
||||
lambda: setattr(window, "appimageUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
window.appimageUpdateWorker = AppImageUpdateWorker("check")
|
||||
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
|
||||
window.appimageUpdateWorker.finished.connect(
|
||||
lambda: setattr(window, "appimageUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
|
||||
class GOGDLUpdateWorker(QThread):
|
||||
"""Update gogdl without blocking the UI thread."""
|
||||
@@ -888,15 +900,14 @@ def main():
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
# Initial setup can stop Wine and must not race a silent game launch.
|
||||
if start_sh and not silent_game_request:
|
||||
if start_sh:
|
||||
worker = InitialCommandWorker(start_sh)
|
||||
window.initialCommandWorker = worker
|
||||
worker.finished.connect(
|
||||
lambda: setattr(window, "initialCommandWorker", None)
|
||||
)
|
||||
worker.start()
|
||||
elif not start_sh:
|
||||
else:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
# --- Cleanup ---
|
||||
@@ -911,44 +922,7 @@ def main():
|
||||
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
exit_code = app.exec()
|
||||
if restart_target is not None:
|
||||
if window.game_processes:
|
||||
window._terminate_game_processes()
|
||||
window.resetPlayButton()
|
||||
window._stopBackgroundWorkers()
|
||||
try:
|
||||
children = psutil.Process().children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(children, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
for child in alive:
|
||||
try:
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(alive, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
if alive:
|
||||
logger.error("Cannot restart while child processes are still running")
|
||||
sys.exit(1)
|
||||
if start_sh:
|
||||
subprocess.run(start_sh + ["cli", "--stop"], check=True,
|
||||
timeout=RESTART_STOP_TIMEOUT_SECONDS)
|
||||
instance_lock.unlock()
|
||||
os.execv(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", restart_target])
|
||||
except OSError as error:
|
||||
logger.error("Failed to restart PortProtonQt: %s", error)
|
||||
exit_code = 1
|
||||
except psutil.Error as error:
|
||||
logger.error("Failed to stop PortProtonQt child processes: %s", error)
|
||||
exit_code = 1
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.error("Failed to stop PortProton before restart: %s", error)
|
||||
exit_code = 1
|
||||
sys.exit(exit_code)
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+3
-291
@@ -2,7 +2,6 @@ import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -18,27 +17,6 @@ def parse_args():
|
||||
Parses command-line arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
|
||||
library = parser.add_mutually_exclusive_group()
|
||||
library.add_argument(
|
||||
"--list-games", action="store_true",
|
||||
help="Print local PortProton, Steam, GOG and EGS library data as JSON and exit"
|
||||
)
|
||||
library.add_argument(
|
||||
"--game-info", metavar="ID",
|
||||
help="Print one game as JSON using its source-qualified ID from --list-games"
|
||||
)
|
||||
library.add_argument("--edit-game", metavar="ID", help="Edit a game's PortProton settings and print its updated data")
|
||||
parser.add_argument("--set", dest="settings", action="append", default=[], metavar="KEY=VALUE",
|
||||
help="Set a game setting; repeat to change multiple values with --edit-game")
|
||||
parser.add_argument("--all", dest="all_games", action="store_true",
|
||||
help="Include uninstalled games from cached libraries in --list-games")
|
||||
parser.add_argument("--fields", metavar="FIELD,...",
|
||||
help="Select JSON fields for --list-games or --game-info (e.g. executable,icon,name)")
|
||||
formatting = parser.add_mutually_exclusive_group()
|
||||
formatting.add_argument("--pretty", dest="pretty", action="store_true", default=True,
|
||||
help="Format JSON with indentation (default)")
|
||||
formatting.add_argument("--raw", dest="pretty", action="store_false",
|
||||
help="Print compact JSON without indentation")
|
||||
parser.add_argument(
|
||||
"--fullscreen",
|
||||
action="store_true",
|
||||
@@ -89,12 +67,12 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--silent",
|
||||
action="store_true",
|
||||
help="Launch supported Windows file or native game in tray without showing the main window"
|
||||
help="Launch supported Windows file in tray without showing the main window"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log",
|
||||
action="store_true",
|
||||
help="Enable logging for the launched Windows file or native game"
|
||||
help="Enable PortProton logging for the launched Windows file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore-prefix",
|
||||
@@ -111,7 +89,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
'file_or_url',
|
||||
nargs='?',
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg), native executable, or portproton:// URL"
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg) or portproton:// URL"
|
||||
)
|
||||
if os.environ.get("STEAM_COMPAT") == "1" and "--" in sys.argv[1:]:
|
||||
separator_index = sys.argv.index("--")
|
||||
@@ -128,276 +106,10 @@ def parse_args():
|
||||
args = parser.parse_args()
|
||||
launch_args = []
|
||||
|
||||
if args.settings and not args.edit_game:
|
||||
parser.error("--set requires --edit-game")
|
||||
if args.edit_game and not args.settings:
|
||||
parser.error("--edit-game requires at least one --set KEY=VALUE")
|
||||
if args.all_games and not args.list_games:
|
||||
parser.error("--all requires --list-games")
|
||||
if args.fields is not None:
|
||||
if not (args.list_games or args.game_info):
|
||||
parser.error("--fields requires --list-games or --game-info")
|
||||
args.fields = [field.strip() for field in args.fields.split(",")]
|
||||
if any(not re.fullmatch(r"[a-z][a-z_0-9]*", field) for field in args.fields):
|
||||
parser.error("--fields requires comma-separated field names without empty entries")
|
||||
args.launch_args = launch_args
|
||||
return args
|
||||
|
||||
|
||||
def _read_game_settings(path: Path) -> dict[str, str]:
|
||||
"""Read literal shell assignments without executing configuration code."""
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
settings: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return settings
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
get_logger(__name__).warning("Failed to read %s: %s", path, error)
|
||||
return settings
|
||||
for line in content.splitlines():
|
||||
match = re.fullmatch(r"(?:export\s+)?([A-Za-z_][A-Za-z_0-9]*)=(.*)", line)
|
||||
if not match:
|
||||
match = re.fullmatch(r"check_variables ([A-Za-z_][A-Za-z_0-9]*) (.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
value = match[2]
|
||||
if any(token in value for token in ("$", "`")):
|
||||
continue
|
||||
try:
|
||||
lexer = shlex.shlex(value, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
parts = list(lexer)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(parts) <= 1:
|
||||
settings[match[1]] = parts[0] if parts else ""
|
||||
return settings
|
||||
|
||||
|
||||
def _get_game_metadata(name: str, exec_line: str, executable: str | None) -> dict:
|
||||
"""Read custom artwork and cached metadata without fetching remote resources."""
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.steam_api.api import get_cached_steam_game_info
|
||||
from portprotonqt.time_utils import (
|
||||
get_last_launch_timestamp, get_playtime_for_exe, get_statistics_path,
|
||||
)
|
||||
|
||||
metadata = get_cached_steam_game_info(name, exec_line)
|
||||
metadata.setdefault("name", name)
|
||||
metadata.setdefault("description", "")
|
||||
metadata.setdefault("cover", "")
|
||||
if not executable:
|
||||
return metadata
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
folder = Path(resolve_custom_data_dir(str(data_home / "PortProtonQt/custom_data"), executable))
|
||||
metadata["custom_data_directory"] = str(folder)
|
||||
metadata_file = folder / "metadata.txt"
|
||||
if metadata_file.is_file():
|
||||
translations = read_metadata_translations(str(metadata_file), get_metadata_language())
|
||||
metadata.update({key: value for key, value in translations.items() if value})
|
||||
for extension in COVER_IMAGE_EXTENSIONS:
|
||||
cover = folder / f"cover{extension}"
|
||||
if cover.is_file():
|
||||
metadata["cover"] = str(cover)
|
||||
break
|
||||
metadata["custom_icon"] = get_custom_icon_path(executable)
|
||||
metadata["playtime_seconds"] = get_playtime_for_exe(get_statistics_path(), executable)
|
||||
metadata["last_launch_timestamp"] = get_last_launch_timestamp(Path(executable).stem)
|
||||
return metadata
|
||||
|
||||
|
||||
def _get_game_runtime(
|
||||
location: Path, executable: str | None, variables: dict[str, str], overrides: dict[str, str]
|
||||
) -> dict:
|
||||
"""Export configured runtime values; never source PPDB files or launch Wine."""
|
||||
user_conf = location / "data/user.conf"
|
||||
ppdb = Path(f"{executable}.ppdb") if executable else None
|
||||
settings = _read_game_settings(ppdb) if ppdb else {}
|
||||
runner = settings.get("PW_WINE_USE", overrides.get("PW_DEFAULT_WINE_USE", variables.get("PW_WINE_USE")))
|
||||
prefix = settings.get("PW_PREFIX_NAME", overrides.get("PW_DEFAULT_PREFIX_NAME", variables.get("PW_PREFIX_NAME")))
|
||||
settings.update(overrides)
|
||||
runner = overrides.get("PW_WINE_VER") or overrides.get("PW_WINE_USE", runner)
|
||||
prefix = overrides.get("PW_PREFIX_NAME", prefix)
|
||||
resolved_runner = runner
|
||||
if runner == "PROTON_LG":
|
||||
resolved_runner = variables.get("PW_PROTON_LG_VER")
|
||||
elif runner and (runner == "WINE_LG" or runner.startswith("WINE_") and runner.endswith("_LG")):
|
||||
resolved_runner = variables.get("PW_WINE_LG_VER")
|
||||
runner_path = None
|
||||
if resolved_runner and resolved_runner != "USE_SYSTEM_WINE":
|
||||
runner_path = Path(resolved_runner) if Path(resolved_runner).is_absolute() else (
|
||||
location / "data/dist" / resolved_runner.upper()
|
||||
)
|
||||
elif resolved_runner == "USE_SYSTEM_WINE":
|
||||
wine = shutil.which("wine")
|
||||
runner_path = Path(wine).parent.parent if wine else None
|
||||
wine_directory = runner_path
|
||||
if wine_directory and (wine_directory / "files").is_dir() and not (wine_directory / "dist").is_dir():
|
||||
wine_directory /= "files"
|
||||
wine_bin = "bin-wow64" if settings.get("PW_USE_WOW64") == "1" and wine_directory and (
|
||||
wine_directory / "bin-wow64"
|
||||
).is_dir() else "bin"
|
||||
prefix_path = location / "data/prefixes" / prefix if prefix else None
|
||||
return {
|
||||
"wine_path": str(wine_directory / wine_bin / "wine") if wine_directory else None,
|
||||
"wineserver_path": str(wine_directory / wine_bin / "wineserver") if wine_directory else None,
|
||||
"runner": resolved_runner,
|
||||
"runner_path": str(runner_path) if runner_path else None,
|
||||
"runner_installed": runner_path.is_dir() if runner_path else False,
|
||||
"prefix": prefix, "prefix_path": str(prefix_path) if prefix_path else None,
|
||||
"settings": settings, "user_config": str(user_conf),
|
||||
"ppdb_exists": ppdb.is_file() if ppdb else False,
|
||||
}
|
||||
|
||||
|
||||
def _list_store_games(source: str, location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Export a cached GOG or Epic library using the existing store backends."""
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
api = GOGAPI() if source == "gog" else EGSAPI()
|
||||
installed = api.load_installed()
|
||||
games = []
|
||||
for record in api.load_library():
|
||||
app_id = str(record.get("app_id", ""))
|
||||
if not app_id:
|
||||
continue
|
||||
is_installed = api.is_game_installed(app_id, installed)
|
||||
target = api.get_launch_target(app_id) if is_installed else None
|
||||
name, cover = MainWindow._get_custom_store_data(source, app_id, str(record.get("title", app_id)))
|
||||
metadata = _get_game_metadata(name, target, target) if target else {}
|
||||
metadata.pop("name", None)
|
||||
icon = metadata.pop("custom_icon", "") or record.get("icon", "")
|
||||
games.append({
|
||||
**{key: value for key, value in record.items() if key not in {"app_id", "title"}},
|
||||
**metadata,
|
||||
"id": f"{source}:{app_id}", "source": source, "name": name,
|
||||
"description": record.get("description") or metadata.get("description", ""),
|
||||
"cover": cover or record.get("cover") or metadata.get("cover", ""),
|
||||
"icon": icon, "installed": is_installed,
|
||||
"exec": f"{source}://{'launch' if is_installed else 'install'}/{app_id}",
|
||||
"executable": target, "directory": str(Path(target).parent) if target else None,
|
||||
"runtime": _get_game_runtime(Path(location), target, variables, overrides) if location and target else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_steam_games() -> list[dict]:
|
||||
"""Export installed Steam games and their locally cached metadata."""
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import get_steam_installed_games
|
||||
from portprotonqt.steam_api.api import get_cached_full_steam_game_info
|
||||
|
||||
games = []
|
||||
for name, appid, last_played, playtime in get_steam_installed_games():
|
||||
metadata = get_cached_full_steam_game_info(appid, name)
|
||||
name, cover = MainWindow._get_custom_store_data("steam", appid, name)
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"steam:{appid}", "source": "steam", "name": name,
|
||||
"cover": cover or metadata.get("cover", ""),
|
||||
"installed": True, "exec": f"steam://rungameid/{appid}",
|
||||
"last_launch_timestamp": last_played, "playtime_seconds": playtime,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_portproton_games(location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Return local PortProton shortcuts without launching games or downloading metadata."""
|
||||
from portprotonqt.config.portproton import extract_exec_target_path, parse_desktop_entry
|
||||
|
||||
if not location:
|
||||
return []
|
||||
games = []
|
||||
for desktop_path in sorted(Path(location).glob("*.desktop")):
|
||||
entry = parse_desktop_entry(str(desktop_path))
|
||||
if entry is None:
|
||||
continue
|
||||
exec_line = entry.get("Exec", "")
|
||||
executable = extract_exec_target_path(exec_line)
|
||||
metadata = _get_game_metadata(entry.get("Name", desktop_path.stem), exec_line, executable)
|
||||
icon = metadata.pop("custom_icon", "") or entry.get("Icon", "")
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"portproton:{desktop_path.name}", "source": "portproton",
|
||||
"installed": bool(executable and Path(executable).is_file()),
|
||||
"name": metadata.get("name") or entry.get("Name", desktop_path.stem),
|
||||
"runtime": _get_game_runtime(Path(location), executable, variables, overrides),
|
||||
"desktop_file": str(desktop_path),
|
||||
"exec": exec_line,
|
||||
"executable": executable,
|
||||
"directory": str(Path(executable).parent) if executable else None,
|
||||
"icon": icon,
|
||||
"ppdb": f"{executable}.ppdb" if executable else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def list_games() -> list[dict]:
|
||||
"""Return locally available library data from all supported game sources."""
|
||||
from portprotonqt.config.portproton import get_portproton_location, get_portproton_scripts_path
|
||||
|
||||
location = get_portproton_location()
|
||||
scripts = get_portproton_scripts_path()
|
||||
variables = _read_game_settings(Path(scripts) / "var") if scripts else {}
|
||||
overrides = _read_game_settings(Path(location) / "data/user.conf") if location else {}
|
||||
games = _list_portproton_games(location, variables, overrides)
|
||||
games.extend(_list_steam_games())
|
||||
for source in ("gog", "egs"):
|
||||
games.extend(_list_store_games(source, location, variables, overrides))
|
||||
return games
|
||||
|
||||
|
||||
def edit_game(game_id: str, changes: list[str]) -> dict:
|
||||
"""Update a game's PPDB through PortProton and verify the saved settings."""
|
||||
import subprocess
|
||||
|
||||
from portprotonqt.config.portproton import WINDOWS_LAUNCH_EXTENSIONS, get_portproton_start_command
|
||||
from portprotonqt.settings_manager import ADVANCED_SETTING_KEYS, get_toggle_settings
|
||||
|
||||
game = next((game for game in list_games() if game["id"] == game_id), None)
|
||||
if game is None:
|
||||
raise ValueError(f"Game not found: {game_id}")
|
||||
executable = game.get("executable")
|
||||
if (game.get("source") not in {"portproton", "gog", "egs"} or not executable
|
||||
or not Path(executable).is_file() or Path(executable).suffix.lower() not in WINDOWS_LAUNCH_EXTENSIONS):
|
||||
raise ValueError("Editing requires an installed Windows game managed by PortProton, GOG or EGS")
|
||||
toggles = set(get_toggle_settings()) | {"PW_MANGOHUD", "PW_VKBASALT", "PW_GAMESCOPE"}
|
||||
allowed = set(ADVANCED_SETTING_KEYS) | toggles | set((game.get("runtime") or {}).get("settings", {}))
|
||||
values: dict[str, str] = {}
|
||||
for change in changes:
|
||||
key, separator, value = change.partition("=")
|
||||
if not separator or key not in allowed or not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key):
|
||||
raise ValueError(f"Unknown or invalid game setting: {key}")
|
||||
if any(character in value for character in ("$", "`", "|", "\n", "\r", "\0")):
|
||||
raise ValueError(f"Unsupported shell syntax in setting: {key}")
|
||||
if key in toggles and value not in {"0", "1"}:
|
||||
raise ValueError(f"{key} must be 0 or 1")
|
||||
if key == "PW_PREFIX_NAME" and (not value or value in {".", ".."} or "/" in value or "\\" in value):
|
||||
raise ValueError("PW_PREFIX_NAME must be a prefix name, not a path")
|
||||
values[key] = value
|
||||
if not values:
|
||||
raise ValueError("At least one setting is required")
|
||||
command = get_portproton_start_command()
|
||||
if not command:
|
||||
raise ValueError("PortProton start.sh was not found")
|
||||
result = subprocess.run(
|
||||
[*command, "cli", "--edit-db", executable, *[f"{key}={value}" for key, value in values.items()]],
|
||||
capture_output=True, text=True, errors="replace", check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"PortProton failed to edit game settings: {result.stderr.strip()}")
|
||||
saved = _read_game_settings(Path(f"{executable}.ppdb"))
|
||||
if any(saved.get(key) != value for key, value in values.items()):
|
||||
raise RuntimeError("PortProton did not save the requested settings; check PPDB permissions")
|
||||
return next(game for game in list_games() if game["id"] == game_id)
|
||||
|
||||
|
||||
def add_steam_compat_tool(force_install: bool = False) -> bool:
|
||||
"""
|
||||
Add PortProtonQt as a Steam compatibility tool.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Cache configuration and management."""
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -117,7 +118,7 @@ class CacheManager:
|
||||
temp_file = cache_file.with_name(f".{cache_file.name}.{time.time_ns()}.tmp")
|
||||
try:
|
||||
if pretty:
|
||||
content = orjson.dumps(data, option=orjson.OPT_INDENT_2)
|
||||
content = json.dumps(data, indent=2).encode("utf-8")
|
||||
else:
|
||||
content = orjson.dumps(data)
|
||||
with _CACHE_FILE_LOCK:
|
||||
|
||||
@@ -581,14 +581,6 @@ def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
|
||||
return hashed_dir
|
||||
|
||||
|
||||
def get_custom_icon_path(exe_path: str) -> str:
|
||||
"""Return the existing custom icon for an executable."""
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
path = os.path.join(folder, "icon.png")
|
||||
return path if os.path.isfile(path) else ""
|
||||
|
||||
|
||||
def create_desktop_file(
|
||||
exe_path: str,
|
||||
game_name: str | None = None,
|
||||
@@ -614,7 +606,6 @@ def create_desktop_file(
|
||||
"applications-games" if is_store_uri and not has_icon_source
|
||||
else os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
)
|
||||
icon_path = get_custom_icon_path(exe_path) or icon_path
|
||||
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)
|
||||
|
||||
@@ -519,15 +519,6 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "crash_reports_enabled")
|
||||
self._save_value("crash_reports_enabled", enabled, "bool")
|
||||
|
||||
def get_disable_cyrillic_path_check(self) -> bool:
|
||||
"""Get Cyrillic launch path check setting."""
|
||||
return self._get_bool("disable_cyrillic_path_check", False)
|
||||
|
||||
def set_disable_cyrillic_path_check(self, enabled: bool) -> None:
|
||||
"""Set Cyrillic launch path check setting."""
|
||||
validate_bool(enabled, "disable_cyrillic_path_check")
|
||||
self._save_value("disable_cyrillic_path_check", enabled, "bool")
|
||||
|
||||
def get_disable_alt_i586_dependency_check(self) -> bool:
|
||||
"""Get ALT i586 dependency check setting."""
|
||||
return self._get_bool("disable_alt_i586_dependency_check", False)
|
||||
|
||||
@@ -2,16 +2,13 @@ import os
|
||||
import glob
|
||||
import shutil
|
||||
import tempfile
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
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, QThread
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
get_portproton_location,
|
||||
extract_exec_target_path,
|
||||
favorites_config,
|
||||
favorites_folders_config,
|
||||
@@ -32,7 +29,6 @@ from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, build_native_shortcut_command, quote_native_arguments
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
@@ -72,14 +68,6 @@ class ContextMenuManager:
|
||||
self.theme = theme
|
||||
self.theme_manager = ThemeManager()
|
||||
self.game_library_manager = game_library_manager
|
||||
self._batch_action = False
|
||||
self.card_selection_mode = False
|
||||
self._card_actions: list[QAction] = []
|
||||
self._card_action_menus: list[QMenu] = []
|
||||
self._card_actions_stopping = False
|
||||
self._card_action_timer = QTimer(parent)
|
||||
self._card_action_timer.setSingleShot(True)
|
||||
self._card_action_timer.timeout.connect(self._run_next_card_action)
|
||||
self.update_game_grid = game_library_manager.update_game_grid
|
||||
self.signals = ContextMenuSignals()
|
||||
self.signals.show_warning_dialog.connect(
|
||||
@@ -131,8 +119,6 @@ class ContextMenuManager:
|
||||
Returns:
|
||||
bool: True if the game is running, False otherwise.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
return getattr(self.parent, "target_exe", None) == os.path.basename(game_card.exec_line.removeprefix("native://"))
|
||||
if game_card.game_source == "steam":
|
||||
return False
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -369,8 +355,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -398,8 +384,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -463,92 +449,7 @@ class ContextMenuManager:
|
||||
return self.theme_manager.get_colored_icon_path(icon_name, color)
|
||||
return None
|
||||
|
||||
def clear_card_selection(self) -> None:
|
||||
self.card_selection_mode = False
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if card.batch_selected:
|
||||
card.batch_selected = False
|
||||
card.update()
|
||||
|
||||
def start_card_selection(self, game_card: "GameCard") -> None:
|
||||
self.clear_card_selection()
|
||||
self.card_selection_mode = True
|
||||
game_card.batch_selected = True
|
||||
game_card.update()
|
||||
game_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _start_card_actions(self, actions: list[QAction], menus: list[QMenu]) -> None:
|
||||
if self._batch_action or self._card_actions_stopping:
|
||||
return
|
||||
self.clear_card_selection()
|
||||
self._batch_action = True
|
||||
self._card_actions = list(reversed(actions))
|
||||
self._card_action_menus = menus
|
||||
for menu in menus:
|
||||
menu.setParent(self.parent, menu.windowFlags())
|
||||
self._run_next_card_action()
|
||||
|
||||
def _run_next_card_action(self) -> None:
|
||||
if not self._batch_action:
|
||||
return
|
||||
jobs = [getattr(self.parent, name, None) for name in (
|
||||
"egs_process", "gog_process", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker",
|
||||
)]
|
||||
jobs.extend(getattr(self.parent, "gog_support_workers", []))
|
||||
for job in jobs:
|
||||
if job is None or not isinstance(job, (QProcess, QThread)):
|
||||
continue
|
||||
job.finished.connect(lambda *args: self._card_action_timer.start() if not self._card_actions_stopping else None)
|
||||
if ((isinstance(job, QProcess) and job.state() == QProcess.ProcessState.NotRunning)
|
||||
or (isinstance(job, QThread) and not job.isRunning())):
|
||||
self._card_action_timer.start()
|
||||
return
|
||||
if self._card_actions_stopping or not self._card_actions:
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
self._card_actions = []
|
||||
return
|
||||
self._card_actions.pop().trigger()
|
||||
self._card_action_timer.start()
|
||||
|
||||
def stop_card_actions(self) -> None:
|
||||
self._card_actions_stopping = True
|
||||
self._card_action_timer.stop()
|
||||
self._card_actions = []
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
|
||||
def _show_card_selection_menu(self, menu: QMenu, game_card: "GameCard", pos: QPoint) -> None:
|
||||
grouped: dict[str, list[QAction]] = {}
|
||||
menus: list[QMenu] = []
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if not card.batch_selected:
|
||||
continue
|
||||
card_menu = self.show_context_menu(card, pos, build_only=True)
|
||||
if card_menu is None:
|
||||
continue
|
||||
card_menu.setParent(menu, card_menu.windowFlags())
|
||||
menus.append(card_menu)
|
||||
for action in card_menu.actions():
|
||||
if not action.isSeparator() and action.isEnabled():
|
||||
grouped.setdefault(action.text(), []).append(action)
|
||||
for text, actions in grouped.items():
|
||||
action = menu.addAction(actions[0].icon(), text)
|
||||
action.setEnabled(not self._batch_action)
|
||||
action.triggered.connect(
|
||||
lambda checked=False, actions=actions: self._start_card_actions(actions, menus)
|
||||
)
|
||||
menu.addSeparator()
|
||||
cancel_action = menu.addAction(self._get_safe_icon("cancel"), _("Cancel"))
|
||||
cancel_action.triggered.connect(self.clear_card_selection)
|
||||
menu.setActiveAction(next(action for action in menu.actions() if action.isEnabled() and not action.isSeparator()))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint, build_only: bool = False):
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
"""
|
||||
Show the context menu for a game card at the specified position.
|
||||
|
||||
@@ -562,16 +463,6 @@ class ContextMenuManager:
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
if not build_only:
|
||||
if self.card_selection_mode or game_card.batch_selected:
|
||||
self._show_card_selection_menu(menu, game_card, pos)
|
||||
return
|
||||
self.clear_card_selection()
|
||||
selection_action = menu.addAction(self._get_safe_icon("edit"), _("Select multiple"))
|
||||
selection_action.triggered.connect(lambda: self.start_card_selection(game_card))
|
||||
menu.setActiveAction(selection_action)
|
||||
menu.addSeparator()
|
||||
|
||||
# For non-Steam games, check if exe exists
|
||||
if game_card.game_source == "portproton":
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -580,8 +471,6 @@ class ContextMenuManager:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
if build_only:
|
||||
return menu
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
@@ -737,9 +626,6 @@ class ContextMenuManager:
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "native":
|
||||
self._add_native_actions(menu, game_card)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
@@ -812,24 +698,16 @@ class ContextMenuManager:
|
||||
if os.path.exists(menu_path)
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
|
||||
if (
|
||||
game_card.game_source in ("portproton", "gog", "egs")
|
||||
and not is_uninstalled_store_game
|
||||
and get_steam_home() is not None
|
||||
):
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
steam_icon_name = "delete" if is_in_steam else "menu_steam"
|
||||
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
if is_in_steam
|
||||
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if build_only:
|
||||
return menu
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
steam_icon_name = "delete" if is_in_steam else "menu_steam"
|
||||
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
if is_in_steam
|
||||
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
@@ -838,96 +716,6 @@ class ContextMenuManager:
|
||||
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def _add_native_actions(self, menu: QMenu, card: "GameCard") -> None:
|
||||
source = Path(card.exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
managed = str(source.parent) == get_portproton_location() or source.parent == data / "PortProtonQt/native_games"
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
for directory, add_text, remove_text, icon in (
|
||||
(desktop, _("Add to Desktop"), _("Remove from Desktop"), "desktop"),
|
||||
(self._get_applications_dir() if managed else "", _("Add to Menu"), _("Remove from Menu"), "menu"),
|
||||
):
|
||||
if not directory:
|
||||
continue
|
||||
exists = (Path(directory) / source.name).is_file()
|
||||
action = menu.addAction(self._get_safe_icon("delete" if exists else icon), remove_text if exists else add_text)
|
||||
action.triggered.connect(lambda _checked=False, target=directory: self._toggle_native_shortcut(card.exec_line, target))
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(card.name)
|
||||
action = menu.addAction(self._get_safe_icon("delete" if is_in_steam else "menu_steam"),
|
||||
_("Remove from Steam") if is_in_steam else _("Add to Steam"))
|
||||
action.triggered.connect(lambda: self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
if is_in_steam else self.add_to_steam(card.name, card.exec_line, card.cover_path))
|
||||
action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
action.triggered.connect(lambda: self._edit_native_shortcut(card))
|
||||
if managed:
|
||||
action = menu.addAction(self._get_safe_icon("delete"), _("Delete"))
|
||||
action.triggered.connect(lambda: self.delete_game(card.name, card.exec_line))
|
||||
action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
action.triggered.connect(lambda: self.parent.detail_page_manager._open_executable_folder(card.exec_line))
|
||||
|
||||
def _toggle_native_shortcut(self, exec_line: str, directory: str) -> None:
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
target = Path(directory) / source.name
|
||||
try:
|
||||
if target.is_file():
|
||||
if target == source:
|
||||
retained = Path(get_portproton_location() or source.parent) / source.name
|
||||
retained.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, retained)
|
||||
target.unlink()
|
||||
else:
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(source.read_text(encoding="utf-8"))
|
||||
command = quote_native_arguments([exec_line])
|
||||
parser["Desktop Entry"]["Exec"] = build_native_shortcut_command(command)
|
||||
for action, mode in (("RunSilent", "--silent"), ("RunLog", "--log")):
|
||||
section = f"Desktop Action {action}"
|
||||
if parser.has_section(section):
|
||||
parser[section]["Exec"] = build_native_shortcut_command(command, mode)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with target.open("w", encoding="utf-8") as output:
|
||||
parser.write(output, space_around_delimiters=False)
|
||||
target.chmod(0o755)
|
||||
if directory == self._get_applications_dir():
|
||||
self._update_desktop_database()
|
||||
self.parent.refreshGames()
|
||||
except Exception as error:
|
||||
logger.warning("Cannot update native shortcut: %s", error)
|
||||
self.signals.show_warning_dialog.emit(_("Error"), str(error))
|
||||
|
||||
def _edit_native_shortcut(self, card: "GameCard") -> None:
|
||||
source = card.exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=card.name, exe_path=source, cover_path=card.cover_path)
|
||||
dialog.add_to_steam_checkbox.setChecked(is_game_in_steam(card.name))
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
name = dialog.nameEdit.text().strip()
|
||||
if not name:
|
||||
return
|
||||
cover = dialog.coverEdit.text().strip()
|
||||
if not os.path.isfile(cover) and dialog.last_cover_path:
|
||||
cover = dialog.last_cover_path
|
||||
try:
|
||||
self._save_local_shortcut_name(source, name)
|
||||
data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
directory = resolve_custom_data_dir(os.path.join(data, "PortProtonQt/custom_data"), source)
|
||||
if os.path.isfile(cover) and Path(cover).suffix.lower() in COVER_IMAGE_EXTENSIONS:
|
||||
target = Path(directory) / ("cover" + Path(cover).suffix.lower())
|
||||
if os.path.abspath(cover) != str(target):
|
||||
self._remove_old_cover_files(directory)
|
||||
shutil.copyfile(cover, target)
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(card.name):
|
||||
self.add_to_steam(name, card.exec_line, cover)
|
||||
elif is_game_in_steam(card.name):
|
||||
self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
self.parent.refreshGames()
|
||||
except OSError as error:
|
||||
logger.warning("Cannot edit native metadata: %s", error)
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
Launch or stop a game based on its current state.
|
||||
@@ -935,9 +723,6 @@ class ContextMenuManager:
|
||||
Args:
|
||||
game_card: The GameCard instance containing game data.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
self.parent.toggleGame(game_card.exec_line, game_name=game_card.name)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
@@ -1020,9 +805,7 @@ class ContextMenuManager:
|
||||
if not entry:
|
||||
continue
|
||||
exec_line = entry.get("Exec", entry.get("exec", ""))
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if (exe_path and resolve_custom_data_dir(custom_root, exe_path)
|
||||
== os.path.dirname(metadata_path)) or script_name.lower() in exec_line.lower():
|
||||
if script_name.lower() in exec_line.lower():
|
||||
return file_path
|
||||
except OSError:
|
||||
continue
|
||||
@@ -1098,9 +881,6 @@ class ContextMenuManager:
|
||||
|
||||
def _get_exec_line(self, game_name, exec_line):
|
||||
"""Retrieve and validate exec_line from .desktop file if necessary."""
|
||||
if exec_line and exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
entry = parse_desktop_entry(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
return entry.get("Exec") if entry else None
|
||||
if exec_line and exec_line.strip() != "full":
|
||||
return exec_line
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -1232,20 +1012,6 @@ class ContextMenuManager:
|
||||
|
||||
def _delete_game_without_confirm(self, game_name: str, exec_line: str) -> None:
|
||||
card_exec_line = exec_line
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
paths = [str(source), str(data / "PortProtonQt/native_games" / source.name),
|
||||
str(Path(self._get_applications_dir()) / source.name)]
|
||||
if desktop:
|
||||
paths.append(str(Path(desktop) / source.name))
|
||||
self._remove_installed_shortcuts(game_name, paths)
|
||||
self._remove_statistics_entry(str(source), game_name)
|
||||
self.game_library_manager.remove_game_incremental(game_name, exec_line)
|
||||
for key in ("all", "native", "favorites"):
|
||||
getattr(self.parent, "_loaded_library_cache", {}).pop(key, None)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -1288,17 +1054,14 @@ class ContextMenuManager:
|
||||
self.game_library_manager.remove_game_incremental(game_name, card_exec_line or resolved_exec_line)
|
||||
|
||||
def delete_game(self, game_name, exec_line):
|
||||
if self._batch_action:
|
||||
self._delete_game_without_confirm(game_name, exec_line)
|
||||
return
|
||||
msg_box = QMessageBox(self.parent)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
msg_box.setWindowTitle(_("Confirm Deletion"))
|
||||
msg_box.setText(_("Are you sure you want to delete '{game_name}'? This will remove the .desktop file and custom data.").format(game_name=game_name))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -1548,8 +1311,7 @@ class ContextMenuManager:
|
||||
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
|
||||
return
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id,
|
||||
exe_path=f"{source}://launch/{app_id}" if source in {"gog", "egs"} else None)
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
@@ -1663,27 +1425,6 @@ class ContextMenuManager:
|
||||
except OSError as error:
|
||||
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
|
||||
|
||||
def _save_local_shortcut_name(self, exe_path: str, name: str) -> None:
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
|
||||
game_dir = resolve_custom_data_dir(custom_root, exe_path)
|
||||
os.makedirs(game_dir, exist_ok=True)
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
metadata_lines = []
|
||||
if os.path.isfile(metadata_path):
|
||||
with open(metadata_path, encoding="utf-8") as metadata_file:
|
||||
metadata_lines = [line for line in metadata_file if not line.startswith(("name=", "name_"))]
|
||||
clean_name = " ".join(name.splitlines()).strip()
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=game_dir, delete=False) as metadata_file:
|
||||
temp_path = metadata_file.name
|
||||
metadata_file.write(f"name={clean_name}\n" + "".join(metadata_lines))
|
||||
os.replace(temp_path, metadata_path)
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
|
||||
"""
|
||||
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
|
||||
@@ -1724,12 +1465,7 @@ class ContextMenuManager:
|
||||
_("Game name and executable path are required")
|
||||
)
|
||||
return
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
old_entry = parse_desktop_entry(old_path)
|
||||
original_name = old_entry.get("Name", game_name) if old_entry else game_name
|
||||
dialog.nameEdit.setText(original_name)
|
||||
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
||||
dialog.nameEdit.setText(new_name)
|
||||
if not desktop_entry or not new_desktop_path:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1737,15 +1473,10 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
|
||||
new_desktop_path = old_path
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
moved_paths = []
|
||||
try:
|
||||
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
|
||||
if exe_path == new_exe_path and os.path.isfile(old_path):
|
||||
with open(old_path, encoding="utf-8") as source:
|
||||
desktop_entry = source.read()
|
||||
if new_name != game_name:
|
||||
self._save_local_shortcut_name(new_exe_path, new_name)
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
@@ -1764,6 +1495,13 @@ class ContextMenuManager:
|
||||
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
|
||||
if exe_path != new_exe_path:
|
||||
self._relocate_shortcut_statistics(exe_path, new_exe_path)
|
||||
if game_name != new_name and os.path.exists(old_path):
|
||||
self._remove_file(
|
||||
old_path,
|
||||
_("Failed to delete old .desktop file: {error}"),
|
||||
_("Removed old .desktop file for '{game_name}'"),
|
||||
game_name
|
||||
)
|
||||
saved_entry = parse_desktop_entry(new_desktop_path)
|
||||
updated_exec_line = new_exe_path
|
||||
if saved_entry:
|
||||
@@ -1783,9 +1521,11 @@ class ContextMenuManager:
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = resolve_custom_data_dir(
|
||||
os.path.join(xdg_data_home, "PortProtonQt", "custom_data"),
|
||||
new_exe_path,
|
||||
custom_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
get_custom_data_dir_name(new_exe_path),
|
||||
)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
|
||||
@@ -1873,30 +1613,26 @@ class ContextMenuManager:
|
||||
"""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
if not exec_line.startswith((NATIVE_URI_PREFIX, "gog://launch/", "egs://launch/")):
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Adding game '%s' to Steam", game_name)
|
||||
try:
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path, icon_source)
|
||||
else:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1918,25 +1654,27 @@ class ContextMenuManager:
|
||||
def on_remove_from_steam_result(result: tuple[bool, str]):
|
||||
success, message = result
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_(message).format(game_name=game_name)
|
||||
)
|
||||
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
if not exec_line.startswith(NATIVE_URI_PREFIX) and not extract_exec_target_path(exec_line):
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
from itertools import accumulate
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
|
||||
from PySide6.QtCore import QObject, Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QMouseEvent, QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -18,19 +18,6 @@ class CustomComboBox(QComboBox):
|
||||
super().__init__(parent)
|
||||
self.theme = theme
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
line_edit = self.lineEdit()
|
||||
if (
|
||||
watched is line_edit and line_edit is not None and line_edit.isReadOnly()
|
||||
and isinstance(event, QMouseEvent) and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
if event.type() == QEvent.Type.MouseButtonPress:
|
||||
return True
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
self.showPopup()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
line_edit = self.lineEdit()
|
||||
if line_edit is None:
|
||||
|
||||
@@ -6,9 +6,6 @@ import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
from typing import IO
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import get_portproton_location
|
||||
@@ -21,7 +18,7 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DebugLogManager:
|
||||
"""Manage debug logs for Wine and native game launches."""
|
||||
"""Manages debug log creation with game launch and Wine output capture."""
|
||||
|
||||
def __init__(self):
|
||||
self.process: subprocess.Popen | None = None
|
||||
@@ -29,19 +26,15 @@ class DebugLogManager:
|
||||
self.start_command: list[str] | None = None
|
||||
self.wine_output: list[str] = []
|
||||
self.runtime_env: dict[str, str] | None = None
|
||||
self.native_output: IO[str] | None = None
|
||||
self.native_directory: str | None = None
|
||||
self.is_running = False
|
||||
self.output_queue = queue.Queue()
|
||||
self.output_thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self, exe_path: str, start_command: list[str]) -> bool:
|
||||
"""Capture game output, enabling PW_LOG for PortProton launches."""
|
||||
"""Start game with PW_LOG=1 and capture output."""
|
||||
if self.is_running:
|
||||
return False
|
||||
if exe_path.startswith("native://"):
|
||||
return self._start_native(exe_path)
|
||||
|
||||
self.exe_path = exe_path
|
||||
self.start_command = start_command
|
||||
@@ -93,52 +86,6 @@ class DebugLogManager:
|
||||
logger.error("Failed to start debug session: %s", e)
|
||||
return False
|
||||
|
||||
def _start_native(self, uri: str) -> bool:
|
||||
from portprotonqt.native_games import get_native_launch
|
||||
|
||||
try:
|
||||
command, directory = get_native_launch(uri.removeprefix("native://"))
|
||||
self.native_output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace")
|
||||
self.process = subprocess.Popen(
|
||||
command, cwd=directory, stdout=self.native_output,
|
||||
stderr=subprocess.STDOUT, start_new_session=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to start native debug session: %s", error)
|
||||
if self.native_output is not None:
|
||||
self.native_output.close()
|
||||
self.native_output = None
|
||||
return False
|
||||
self.exe_path = uri.removeprefix("native://")
|
||||
self.start_command = command
|
||||
self.native_directory = directory
|
||||
self.is_running = True
|
||||
return True
|
||||
|
||||
def _save_native_log(self) -> str | None:
|
||||
output = self.native_output
|
||||
if output is None:
|
||||
return None
|
||||
from portprotonqt.native_games import load_native_settings, get_native_ppdb_path
|
||||
|
||||
try:
|
||||
settings = load_native_settings(str(self.exe_path))
|
||||
destination = get_native_ppdb_path(str(self.exe_path), settings).with_suffix(".log")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.seek(0)
|
||||
with destination.open("w", encoding="utf-8") as log:
|
||||
log.write(generate_system_info("native://" + str(self.exe_path), self.start_command))
|
||||
log.write(f"\nCommand: {self.start_command!r}\nWorking directory: {self.native_directory}\n\n")
|
||||
shutil.copyfileobj(output, log)
|
||||
return str(destination)
|
||||
except OSError as error:
|
||||
logger.error("Failed to save native debug log: %s", error)
|
||||
return None
|
||||
finally:
|
||||
output.close()
|
||||
self.native_output = None
|
||||
self.native_directory = None
|
||||
|
||||
def _read_output(self):
|
||||
"""Read output from subprocess in a separate thread."""
|
||||
if self.process and self.process.stdout:
|
||||
@@ -151,20 +98,19 @@ class DebugLogManager:
|
||||
logger.debug("Error reading output: %s", e)
|
||||
|
||||
def stop(self) -> str | None:
|
||||
"""Stop the game and save its diagnostic log."""
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
"""Stop game and save debug log with captured Wine output."""
|
||||
self._capture_env_vars()
|
||||
self._stop_event.set()
|
||||
|
||||
if self.is_running and self.process:
|
||||
try:
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
|
||||
if self.process.poll() is None:
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -177,7 +123,7 @@ class DebugLogManager:
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
log_file = self._save_native_log() if self.native_output is not None else self._save_log()
|
||||
log_file = self._save_log()
|
||||
|
||||
if self.process and self.process.stdout:
|
||||
try:
|
||||
@@ -200,8 +146,7 @@ class DebugLogManager:
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
self._capture_env_vars()
|
||||
return True
|
||||
|
||||
def _capture_env_vars(self) -> None:
|
||||
|
||||
@@ -290,8 +290,7 @@ def get_filesystem_info(exe_path: str | None, portproton_path: str) -> str:
|
||||
game_dir = os.path.dirname(exe_path)
|
||||
lines.append(f"Filesystem {game_dir} - {get_fs_type(game_dir)}")
|
||||
|
||||
if portproton_path:
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
|
||||
tmp_dir = f"/tmp/PortProton_{os.environ.get('USER', 'user')}"
|
||||
if os.path.exists(tmp_dir):
|
||||
@@ -314,17 +313,8 @@ def generate_system_info(
|
||||
get_d3d_extras_status,
|
||||
)
|
||||
|
||||
from portprotonqt.native_games import load_native_settings, get_native_launch, get_native_ppdb_path
|
||||
|
||||
native = bool(exe_path and exe_path.startswith("native://"))
|
||||
desktop_path = exe_path.removeprefix("native://") if native and exe_path else None
|
||||
if desktop_path:
|
||||
runtime_env = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
exe_path = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), directory)
|
||||
portproton_path = get_portproton_location() or ""
|
||||
if not portproton_path and not native:
|
||||
portproton_path = get_portproton_location()
|
||||
if not portproton_path:
|
||||
return "Error: PortProton location not found"
|
||||
|
||||
lines = []
|
||||
@@ -354,17 +344,16 @@ def generate_system_info(
|
||||
lines.append(get_libc_version())
|
||||
lines.append("-" * 61)
|
||||
|
||||
if not native:
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Date and time of start debug for PortProton:")
|
||||
lines.append(datetime.now().strftime("%c %z"))
|
||||
@@ -430,28 +419,26 @@ def generate_system_info(
|
||||
lines.append(locale_avail)
|
||||
lines.append("-" * 61)
|
||||
|
||||
if not native:
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
if exe_path:
|
||||
ppdb_path = str(get_native_ppdb_path(desktop_path, runtime_env)).removesuffix(".ppdb") if desktop_path and runtime_env else exe_path
|
||||
ppdb_content = get_file_content(ppdb_path + ".ppdb") if native else get_ppdb_content(exe_path, start_cmd)
|
||||
ppdb_content = get_ppdb_content(exe_path, start_cmd)
|
||||
if ppdb_content:
|
||||
lines.append(f"Use {ppdb_path}.ppdb db file:")
|
||||
lines.append(f"Use {exe_path}.ppdb db file:")
|
||||
if ppdb_log_vars:
|
||||
lines.append("\n".join(ppdb_log_vars))
|
||||
lines.append(ppdb_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
user_overrides = "" if native else get_user_overrides(portproton_path)
|
||||
user_overrides = get_user_overrides(portproton_path)
|
||||
if user_overrides:
|
||||
lines.append(user_overrides)
|
||||
lines.append("-" * 61)
|
||||
|
||||
@@ -19,8 +19,6 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from portprotonqt.native_games import get_native_launch, load_native_settings
|
||||
|
||||
from portprotonqt.detail_pages.widgets import (
|
||||
create_scroll_area,
|
||||
create_back_button,
|
||||
@@ -95,9 +93,6 @@ class DetailPageManager:
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open detailed game information page."""
|
||||
self._current_detail_source = ("game", dict(game_data))
|
||||
self._return_to_library_card_key = (
|
||||
game_data.get("name", ""), game_data.get("exec_line", "")
|
||||
)
|
||||
detail_page = QWidget()
|
||||
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
|
||||
@@ -657,7 +652,7 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() not in {"gog", "egs", "native"}
|
||||
str(game_source).lower() not in {"gog", "egs"}
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
@@ -672,24 +667,7 @@ class DetailPageManager:
|
||||
buttons_layout.addWidget(add_button)
|
||||
|
||||
# Show settings button for PortProton games or Steam games using PortProtonQt
|
||||
if source == "native":
|
||||
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_native_settings(exec_line))
|
||||
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(exec_line, log_button))
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
if os.path.dirname(exec_line.removeprefix("native://")) in (
|
||||
getattr(self.main_window, "portproton_location", None), os.path.join(data_home, "PortProtonQt/native_games")
|
||||
):
|
||||
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(exec_line))
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "portproton":
|
||||
if str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
source in {"gog", "egs"}
|
||||
@@ -744,8 +722,6 @@ class DetailPageManager:
|
||||
|
||||
def _get_current_exe(self, exec_line: str) -> str | None:
|
||||
"""Extract current executable from exec line."""
|
||||
if exec_line.startswith("native://"):
|
||||
return os.path.basename(exec_line.removeprefix("native://"))
|
||||
file_to_check = extract_exec_target_path(exec_line)
|
||||
return os.path.basename(file_to_check) if file_to_check else None
|
||||
|
||||
@@ -843,26 +819,9 @@ class DetailPageManager:
|
||||
|
||||
def _open_executable_folder(self, exe_path: str | None) -> None:
|
||||
"""Open the folder containing an executable."""
|
||||
if not exe_path:
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
return
|
||||
if exe_path.startswith("native://"):
|
||||
try:
|
||||
desktop_path = exe_path.removeprefix("native://")
|
||||
settings = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
executable = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), None)
|
||||
folder_path = os.path.dirname(executable) if executable else settings.get("Path", "")
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
raise ValueError("Game directory is not available")
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native game folder: %s", error)
|
||||
QMessageBox.warning(self.main_window, _("Error"), str(error))
|
||||
return
|
||||
else:
|
||||
if not os.path.exists(exe_path):
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
|
||||
def _has_game_shortcut(self, game_name: str) -> bool:
|
||||
@@ -1492,15 +1451,14 @@ class DetailPageManager:
|
||||
"""Start debug logging session."""
|
||||
if exe_path is None:
|
||||
return
|
||||
native = exe_path.startswith("native://")
|
||||
resolved_exe_path = exe_path if native else self.main_window.resolve_launch_file_path(exe_path)
|
||||
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
|
||||
if resolved_exe_path is None:
|
||||
return
|
||||
if not native and not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
if not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
|
||||
start_command = [] if native else get_portproton_start_command()
|
||||
if start_command is None:
|
||||
start_command = get_portproton_start_command()
|
||||
if not start_command:
|
||||
return
|
||||
|
||||
if self.debug_log_manager.start(resolved_exe_path, start_command):
|
||||
@@ -1522,7 +1480,7 @@ class DetailPageManager:
|
||||
msg_box.setWindowTitle(_("Log Saved"))
|
||||
msg_box.setText(_("Debug log saved to:") + f"\n{log_file}")
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
|
||||
|
||||
result = msg_box.exec()
|
||||
|
||||
@@ -1680,15 +1638,10 @@ class DetailPageManager:
|
||||
container = getattr(self.main_window, "gamesListWidget", None)
|
||||
if container is None:
|
||||
return
|
||||
manager = getattr(self.main_window, "game_library_manager", None)
|
||||
card_key = getattr(self, "_return_to_library_card_key", None)
|
||||
card = getattr(manager, "game_card_cache", {}).get(card_key)
|
||||
cards = [
|
||||
widget for widget in container.findChildren(QWidget)
|
||||
if widget.__class__.__name__ == "GameCard"
|
||||
]
|
||||
if card is not None and card.isVisible():
|
||||
cards = [card]
|
||||
if not cards:
|
||||
return
|
||||
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
"""Base dialogs for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
import tempfile
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication, QMessageBox
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize
|
||||
import psutil
|
||||
import orjson
|
||||
import pefile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
from portprotonqt.config import (
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
THEMED_LAUNCH_ICON_NAMES,
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
get_portproton_location,
|
||||
parse_desktop_entry,
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.native_games import create_native_desktop_file, is_native_executable
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
@@ -40,7 +28,6 @@ from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS, set_animated_cover
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
|
||||
logger = get_logger(__name__)
|
||||
theme_manager = ThemeManager()
|
||||
@@ -138,241 +125,16 @@ class GameLaunchDialog(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
class GameFolderScanWorker(QThread):
|
||||
"""Find executable files recursively and report scanned directory counts."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, folder: str, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.folder = folder
|
||||
self.games: list[tuple[str, str]] = []
|
||||
self.errors: list[str] = []
|
||||
|
||||
MAX_SCAN_DEPTH = 5
|
||||
MAX_SCAN_DIRECTORIES = 400
|
||||
GAME_SUBDIRECTORIES = frozenset((
|
||||
"bin", "bin32", "bin64", "binaries", "win32", "win64", "x64", "x86", "game", "disccontentpc", "api",
|
||||
))
|
||||
|
||||
def run(self) -> None:
|
||||
for directory, paths in self._find_executables(self.folder).items():
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
executable = max(paths, key=lambda path: self._executable_score(path, directory))
|
||||
self.games.append((self._game_name(executable, directory), executable))
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
self.games.sort(key=lambda game: game[1].casefold())
|
||||
|
||||
def _executable_score(self, executable: str, directory: str) -> tuple[int, int, int]:
|
||||
name = Path(executable).stem.casefold()
|
||||
folder = re.sub(r"[\W_]", "", os.path.basename(directory).casefold())
|
||||
key = re.sub(r"[\W_]", "", name)
|
||||
match = int(key == folder) + int(bool(key and folder and (key in folder or folder in key)))
|
||||
launcher = name.endswith(("launcher", "redirector")) or name in ("play", "start", "run")
|
||||
depth = len(Path(executable).relative_to(directory).parts)
|
||||
return match - int(launcher), -depth, os.path.getsize(executable)
|
||||
|
||||
def _executable_name(self, executable: str) -> str:
|
||||
try:
|
||||
with pefile.PE(executable, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
tables = [table for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", [])]
|
||||
metadata = {key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for table in tables for key, value in table.entries.items()}
|
||||
except pefile.PEFormatError:
|
||||
return ""
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", executable, error)
|
||||
return ""
|
||||
for field in ("ProductName", "FileDescription"):
|
||||
value = metadata.get(field, "").strip()
|
||||
if value and not re.fullmatch(
|
||||
r"(?i)(?:BootstrapPackagedGame|game|application|launcher|.*launcher.*|.*redistributable.*)", value,
|
||||
):
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _game_name(self, executable: str, directory: str) -> str:
|
||||
name = ""
|
||||
for info in sorted(Path(directory).glob("goggame-*.info")):
|
||||
try:
|
||||
data = orjson.loads(info.read_bytes().removeprefix(b"\xef\xbb\xbf"))
|
||||
if (isinstance(data, dict) and isinstance(data.get("name"), str)
|
||||
and data.get("gameId") == data.get("rootGameId")):
|
||||
name = data["name"]
|
||||
break
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read game metadata %s: %s", info, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid game metadata %s: %s", info, error)
|
||||
if not name:
|
||||
name = self._executable_name(executable) or Path(directory).name
|
||||
name = re.sub(r"(?i)[._ ]+(?:v?\d+(?:[._]\d+)+|build[._ ]?\d+).*$", "", name)
|
||||
name = name.replace(".", " ")
|
||||
name = re.sub(r"[\\/\r\n]", " ", name).replace(":", " - ")
|
||||
return " ".join(name.replace("_", " ").split()).strip(" .") or Path(executable).stem
|
||||
|
||||
def _record_error(self, error: OSError) -> None:
|
||||
logger.warning("Failed to scan game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _is_runtime_directory(self, path: str) -> bool:
|
||||
name = os.path.basename(path).casefold()
|
||||
parent = os.path.basename(os.path.dirname(path)).casefold()
|
||||
runtime = r"(?:i386-windows|x86_64-windows|mono|monobleedingedge|wine-mono.*|jre[\d._-]*|jdk[\d._-]*|java-runtime.*|_?commonredist|__installer)"
|
||||
if re.fullmatch(runtime, name) or name in ("$recycle.bin", "system volume information", "config.msi", "msdownld.tmp"):
|
||||
return True
|
||||
if name == "windows" and parent == "drive_c":
|
||||
return True
|
||||
if name in ("windows media player", "internet explorer", "windows nt") and parent.startswith("program files"):
|
||||
return True
|
||||
if "proton" in name or name.startswith("wine"):
|
||||
return os.path.isfile(os.path.join(path, "proton")) or os.path.isdir(os.path.join(path, "lib", "wine"))
|
||||
return False
|
||||
|
||||
def _find_executables(self, folder: str) -> dict[str, list[str]]:
|
||||
games: dict[str, list[str]] = {}
|
||||
pending = deque([] if self._is_runtime_directory(folder) else [(folder, 0, folder)])
|
||||
visited: dict[str, int] = {}
|
||||
completed, total = 0, len(pending)
|
||||
ignored = r"(?i)(?:unins.*|setup.*|install.*|.*redist.*|dxsetup.*|directx.*|dotnet.*|.*crash.*|.*report.*|.*prereq.*|.*updater?.*|.*config.*|.*settings.*|.*helper.*|.*benchmark.*|.*-win(?:32|64)-shipping|.*easyanticheat.*|.*battleye.*|oalinst|overlay_test.*|.*[-_](?:setup|installer).*|.*errormonitor|pluginhost|.*_api)"
|
||||
skipped = {"engine", "melonloader", "mods", "plugins", "userdata", "userlibs", "reshade_shaders",
|
||||
"assets", "data", "resources", "res", "res_mods", "replays", "movies", "cache", "logs"}
|
||||
while pending:
|
||||
if self.isInterruptionRequested():
|
||||
return {}
|
||||
root, depth, branch = pending.popleft()
|
||||
label = _("Scanning: {folder}").format(folder=root)
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
try:
|
||||
with os.scandir(root) as entries:
|
||||
entries_list = sorted(entries, key=lambda entry: entry.name.casefold())
|
||||
files = [entry.path for entry in entries_list if entry.name.casefold().endswith(".exe")
|
||||
and not re.fullmatch(ignored, entry.name[:-len(".exe")])
|
||||
and not entry.is_symlink() and entry.is_file(follow_symlinks=False)]
|
||||
directory = root
|
||||
while directory != folder and Path(directory).name.casefold() in self.GAME_SUBDIRECTORIES:
|
||||
directory = os.path.dirname(directory)
|
||||
if files:
|
||||
games.setdefault(directory, []).extend(files)
|
||||
for entry in entries_list:
|
||||
name = entry.name.casefold()
|
||||
if (depth >= self.MAX_SCAN_DEPTH or entry.is_symlink() or not entry.is_dir(follow_symlinks=False)
|
||||
or name.startswith((".", "$")) or name in skipped or name.endswith("_data")
|
||||
or re.fullmatch(ignored, name) or self._is_runtime_directory(entry.path)):
|
||||
continue
|
||||
if files and root != folder and name not in self.GAME_SUBDIRECTORIES:
|
||||
continue
|
||||
owner = entry.path if root == folder or Path(root).name.casefold() == "common" else branch
|
||||
if visited.get(owner, 0) >= self.MAX_SCAN_DIRECTORIES:
|
||||
self._record_error(OSError(f"Game folder scan directory limit reached: {owner}"))
|
||||
break
|
||||
visited[owner] = visited.get(owner, 0) + 1
|
||||
next_depth = 0 if owner == entry.path or name in ("steamapps", "common") else depth + 1
|
||||
pending.append((entry.path, next_depth, owner))
|
||||
total += 1
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
completed += 1
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
return games
|
||||
|
||||
|
||||
class GameFolderImportWorker(QThread):
|
||||
"""Create selected shortcuts without overwriting existing entries."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, games: list[tuple[str, str]], parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.games = games
|
||||
self.added = 0
|
||||
self.skipped = 0
|
||||
self.errors: list[str] = []
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
raise FileNotFoundError("PortProton directory not found")
|
||||
existing = set()
|
||||
with os.scandir(location) as entries:
|
||||
paths = [entry.path for entry in entries if entry.name.endswith(".desktop") and entry.is_file()]
|
||||
for index, path in enumerate(paths):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(paths), _("Checking existing shortcuts: {path}").format(path=path)))
|
||||
entry = parse_desktop_entry(path)
|
||||
target = extract_exec_target_path(entry.get("Exec", "")) if entry else None
|
||||
if target:
|
||||
existing.add(os.path.realpath(target))
|
||||
for index, (name, executable) in enumerate(self.games):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
self._import_game(name, executable, existing)
|
||||
self.progressChanged.emit((index + 1, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _import_game(self, name: str, executable: str, existing: set[str]) -> None:
|
||||
target = os.path.realpath(executable)
|
||||
if target in existing:
|
||||
self.skipped += 1
|
||||
return
|
||||
created = False
|
||||
desktop_path = ""
|
||||
try:
|
||||
name = " ".join(re.sub(r"[\\/]", " ", name).split()).strip(" .")
|
||||
result = create_desktop_file(executable, name)
|
||||
if result is None:
|
||||
raise OSError(_("Failed to create shortcut: {name}").format(name=name))
|
||||
content, desktop_path, icon_path = result
|
||||
if os.path.exists(desktop_path):
|
||||
self.skipped += 1
|
||||
return
|
||||
if not os.path.exists(icon_path) and not generate_thumbnail(executable, icon_path, size=128):
|
||||
content = content.replace(f"Icon={icon_path}\n", "Icon=\n")
|
||||
with open(desktop_path, "x", encoding="utf-8") as desktop:
|
||||
created = True
|
||||
desktop.write(content)
|
||||
os.chmod(desktop_path, 0o755)
|
||||
existing.add(target)
|
||||
self.added += 1
|
||||
except FileExistsError:
|
||||
self.skipped += 1
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import '%s': %s", executable, error)
|
||||
self.errors.append(f"{name}: {error}")
|
||||
if created:
|
||||
try:
|
||||
os.unlink(desktop_path)
|
||||
except OSError as cleanup_error:
|
||||
logger.warning("Failed to remove incomplete shortcut: %s", cleanup_error)
|
||||
|
||||
|
||||
class AddGameDialog(DraggableDialog):
|
||||
"""Dialog for adding or editing a game."""
|
||||
|
||||
gamesAdded = Signal()
|
||||
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None, steam_appid=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())
|
||||
self.edit_mode = edit_mode
|
||||
if exe_path is None and steam_appid is not None:
|
||||
exe_path = f"steam://rungameid/{steam_appid}"
|
||||
self.last_exe_path = exe_path
|
||||
self.last_cover_path = cover_path
|
||||
refresh_games = getattr(parent, "refreshGames", None)
|
||||
if callable(refresh_games):
|
||||
self.gamesAdded.connect(refresh_games)
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
|
||||
self.setWindowTitle(_("Edit Shortcut") if edit_mode else _("Add a shortcut"))
|
||||
@@ -433,19 +195,6 @@ class AddGameDialog(DraggableDialog):
|
||||
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
|
||||
layout.addRow(cover_button_label, coverBrowseButton)
|
||||
|
||||
self.iconEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.iconEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.iconEdit.setText(get_custom_icon_path(exe_path) if exe_path else "")
|
||||
icon_label = QLabel(_("Custom Icon:"))
|
||||
icon_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
icon_row = QHBoxLayout()
|
||||
icon_row.addWidget(self.iconEdit)
|
||||
icon_browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("search", as_path=True))
|
||||
icon_browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
icon_browse.clicked.connect(self.browseIcon)
|
||||
icon_row.addWidget(icon_browse)
|
||||
layout.addRow(icon_label, icon_row)
|
||||
|
||||
self.add_to_steam_checkbox = QCheckBox()
|
||||
self.add_to_menu_checkbox = QCheckBox()
|
||||
self.add_to_desktop_checkbox = QCheckBox()
|
||||
@@ -484,9 +233,6 @@ class AddGameDialog(DraggableDialog):
|
||||
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
|
||||
self.add_to_menu_checkbox.setChecked(self._menu_shortcut_exists(current_name))
|
||||
self.add_to_desktop_checkbox.setChecked(self._desktop_shortcut_exists(current_name))
|
||||
if not edit_mode and is_native_executable(self.exeEdit.text().strip()):
|
||||
self.add_to_menu_checkbox.setChecked(True)
|
||||
self.add_to_desktop_checkbox.setChecked(True)
|
||||
|
||||
self.coverPreview = QLabel(self)
|
||||
self.coverPreview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -581,60 +327,17 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
def accept(self):
|
||||
"""Handle Apply button."""
|
||||
icon_path = self.iconEdit.text().strip()
|
||||
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
|
||||
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
image = QPixmap(icon_path)
|
||||
if image.isNull():
|
||||
QMessageBox.warning(self, _("Error"), _("Invalid image"))
|
||||
return
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
temporary_path = None
|
||||
try:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
|
||||
temporary_path = temporary.name
|
||||
if not image.save(temporary_path, "PNG"):
|
||||
raise OSError("Failed to save icon")
|
||||
os.replace(temporary_path, os.path.join(folder, "icon.png"))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save custom icon: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
finally:
|
||||
if temporary_path:
|
||||
Path(temporary_path).unlink(missing_ok=True)
|
||||
if hasattr(self, 'keyboard'):
|
||||
self.keyboard.hide()
|
||||
super().accept()
|
||||
|
||||
def browseIcon(self) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
|
||||
initial_path=os.path.dirname(self.iconEdit.text()) or None)
|
||||
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
|
||||
explorer.show()
|
||||
|
||||
def browseExe(self):
|
||||
"""Open file manager to select executable or disc image."""
|
||||
try:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
def file_filter(path: str) -> bool:
|
||||
return path.lower().endswith(LAUNCH_FILE_EXTENSIONS) or is_native_executable(path)
|
||||
|
||||
file_explorer = FileExplorer(self, file_filter=file_filter, initial_path=initial_path)
|
||||
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
|
||||
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
|
||||
if not self.edit_mode:
|
||||
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
folder_button.setObjectName("gamepadAddButton")
|
||||
folder_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
folder_button.clicked.connect(partial(self._select_game_folder, file_explorer))
|
||||
file_explorer.button_layout.insertWidget(0, folder_button)
|
||||
|
||||
parent_widget = self.parentWidget()
|
||||
if parent_widget:
|
||||
@@ -648,195 +351,6 @@ class AddGameDialog(DraggableDialog):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in browseExe: {e}")
|
||||
|
||||
def _select_game_folder(self, explorer: "FileExplorer") -> None:
|
||||
folder = explorer.current_path
|
||||
item = explorer.file_list.currentItem()
|
||||
if item and item.text() != "../":
|
||||
selected = os.path.normpath(os.path.join(folder, item.text()))
|
||||
if os.path.isdir(selected):
|
||||
folder = selected
|
||||
self._game_folder_root = folder
|
||||
explorer.accept()
|
||||
self._start_folder_worker(GameFolderScanWorker(folder, self.parentWidget() or self))
|
||||
|
||||
def _start_folder_worker(self, worker: GameFolderScanWorker | GameFolderImportWorker) -> None:
|
||||
owner = cast(Any, self.parentWidget() or self)
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
owner._gameFolderWorkers = workers
|
||||
workers.append(worker)
|
||||
progress = QProgressDialog(_("Processing games…"), _("Cancel"), 0, 1, self)
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
progress.setValue(0)
|
||||
progress.canceled.connect(worker.requestInterruption)
|
||||
worker.progressChanged.connect(partial(self._update_folder_progress, progress), Qt.ConnectionType.QueuedConnection)
|
||||
worker.finished.connect(partial(self._finish_folder_worker, worker, progress), Qt.ConnectionType.QueuedConnection)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _update_folder_progress(self, progress: QProgressDialog, state: tuple[int, int, str]) -> None:
|
||||
if progress.wasCanceled():
|
||||
return
|
||||
completed, total, current = state
|
||||
progress.setLabelText(_("Processed {completed}/{total}\n{current}").format(
|
||||
completed=completed, total=total, current=current,
|
||||
))
|
||||
progress.setRange(0, max(total, 1))
|
||||
progress.setValue(completed)
|
||||
|
||||
def _finish_folder_worker(self, worker: QThread, progress: QProgressDialog) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
owner = self.parentWidget() or self
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
cancelled = progress.wasCanceled()
|
||||
progress.close()
|
||||
progress.deleteLater()
|
||||
worker.deleteLater()
|
||||
if worker not in workers:
|
||||
return
|
||||
workers.remove(worker)
|
||||
if isinstance(worker, GameFolderImportWorker):
|
||||
if worker.added:
|
||||
self.gamesAdded.emit()
|
||||
if cancelled:
|
||||
return
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
self.reject()
|
||||
elif isinstance(worker, GameFolderScanWorker) and not cancelled:
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
if worker.games:
|
||||
self._show_folder_games(worker.games)
|
||||
else:
|
||||
QMessageBox.information(self, _("Information"), _("No games found in the selected folder."))
|
||||
|
||||
def _create_folder_game_row(
|
||||
self, layout: QVBoxLayout, game: tuple[str, str],
|
||||
) -> tuple[QLineEdit, QLineEdit, QCheckBox]:
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
|
||||
name, executable = game
|
||||
content = layout.parentWidget()
|
||||
row = QHBoxLayout()
|
||||
checkbox = QCheckBox(content)
|
||||
checkbox.setObjectName("gameFolderExecutable")
|
||||
checkbox.setAccessibleName(executable)
|
||||
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
checkbox.setChecked(True)
|
||||
title = CustomLineEdit(name, content, theme=self.theme)
|
||||
title.setAccessibleName(_("Name:"))
|
||||
title.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
path = CustomLineEdit(executable, content, theme=self.theme)
|
||||
path.setObjectName("gameFolderPath")
|
||||
path.setAccessibleName(_("Path to Executable:"))
|
||||
path.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
browse.setAutoDefault(False)
|
||||
browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
browse.clicked.connect(partial(self._browse_folder_exe, path))
|
||||
path_row = QHBoxLayout()
|
||||
path_row.addWidget(path)
|
||||
path_row.addWidget(browse)
|
||||
details = QVBoxLayout()
|
||||
details.addWidget(title)
|
||||
details.addLayout(path_row)
|
||||
row.addWidget(checkbox)
|
||||
row.addLayout(details)
|
||||
layout.addLayout(row)
|
||||
QWidget.setTabOrder(checkbox, title)
|
||||
QWidget.setTabOrder(title, path)
|
||||
QWidget.setTabOrder(path, browse)
|
||||
return title, path, checkbox
|
||||
|
||||
def _browse_folder_exe(self, field: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(
|
||||
field.window(), theme=self.theme, file_filter=(".exe",),
|
||||
initial_path=os.path.dirname(os.path.expanduser(field.text().strip())),
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(field.setText)
|
||||
explorer.exec()
|
||||
field.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
explorer.deleteLater()
|
||||
|
||||
def _accept_folder_games(
|
||||
self, dialog: QDialog, rows: list[tuple[QLineEdit, QLineEdit, QCheckBox]],
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
for title, path, checkbox in rows:
|
||||
if not checkbox.isChecked():
|
||||
continue
|
||||
if not title.text().strip():
|
||||
QMessageBox.warning(dialog, _("Error"), _("Game name and executable path are required"))
|
||||
title.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
executable = os.path.expanduser(path.text().strip())
|
||||
if not os.path.isfile(executable):
|
||||
QMessageBox.warning(dialog, _("Error"), _("Executable not found: {0}").format(executable))
|
||||
path.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
path.setText(executable)
|
||||
dialog.accept()
|
||||
|
||||
def _scroll_folder_focus(self, scroll: QScrollArea, _old: QWidget | None, focused: QWidget | None) -> None:
|
||||
if focused is not None and scroll.isAncestorOf(focused):
|
||||
scroll.ensureWidgetVisible(focused)
|
||||
|
||||
def _show_folder_games(self, games: list[tuple[str, str]]) -> None:
|
||||
dialog = cast(Any, DraggableDialog(self))
|
||||
dialog.setWindowTitle(_("Select games"))
|
||||
dialog.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
dialog.resize(*self.theme.GAME_FOLDER_IMPORT_DIALOG_SIZE)
|
||||
layout = QVBoxLayout(dialog)
|
||||
scroll = QScrollArea(dialog)
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget(scroll)
|
||||
rows_layout = QVBoxLayout(content)
|
||||
rows = [self._create_folder_game_row(rows_layout, game) for game in games]
|
||||
rows_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
buttons = QHBoxLayout()
|
||||
apply_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply", as_path=True))
|
||||
apply_button.setObjectName("gamepadAddButton")
|
||||
cancel_button = AutoSizeButton(_("Close"), icon=theme_manager.get_icon("cancel", as_path=True))
|
||||
for button in (apply_button, cancel_button):
|
||||
button.setAutoDefault(False)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
buttons.addWidget(button)
|
||||
apply_button.clicked.connect(partial(self._accept_folder_games, dialog, rows))
|
||||
cancel_button.clicked.connect(dialog.reject)
|
||||
layout.addLayout(buttons)
|
||||
main_window = self.parentWidget()
|
||||
input_manager = getattr(main_window, "input_manager", None)
|
||||
hints, labels = create_dialog_hints_widget(self.theme, main_window, input_manager, context='folder_import')
|
||||
layout.addWidget(hints)
|
||||
update_dialog_hints(labels, main_window, input_manager, theme_manager, ui_config.get_theme())
|
||||
dialog.keyboard = VirtualKeyboard(dialog, theme=self.theme)
|
||||
dialog.finished.connect(dialog.keyboard.hide)
|
||||
if rows:
|
||||
QTimer.singleShot(0, rows[0][2].setFocus)
|
||||
app = cast(QApplication, QApplication.instance())
|
||||
scroll_focus = partial(self._scroll_folder_focus, scroll)
|
||||
app.focusChanged.connect(scroll_focus)
|
||||
try:
|
||||
accepted = dialog.exec() == QDialog.DialogCode.Accepted
|
||||
finally:
|
||||
app.focusChanged.disconnect(scroll_focus)
|
||||
if accepted:
|
||||
selected = [(title.text().strip(), path.text().strip()) for title, path, checkbox in rows
|
||||
if checkbox.isChecked()]
|
||||
if selected:
|
||||
self._start_folder_worker(GameFolderImportWorker(selected, self.parentWidget() or self))
|
||||
dialog.deleteLater()
|
||||
|
||||
def onExeSelected(self, file_path):
|
||||
"""Handle file selection in FileExplorer."""
|
||||
self.exeEdit.setText(file_path)
|
||||
@@ -967,13 +481,6 @@ class AddGameDialog(DraggableDialog):
|
||||
if not name:
|
||||
return None, None
|
||||
|
||||
if is_native_executable(exe_path):
|
||||
cover = self.last_cover_path or self.coverEdit.text().strip()
|
||||
result = create_native_desktop_file(exe_path, name, cover)
|
||||
if result is None:
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to create shortcut: {name}").format(name=name))
|
||||
return result or (None, None)
|
||||
|
||||
if exe_path.lower().endswith(DISC_IMAGE_EXTENSIONS):
|
||||
parent_widget = cast("MainWindow | None", self.parentWidget())
|
||||
if not parent_widget or not hasattr(parent_widget, "resolve_launch_file_path"):
|
||||
@@ -990,8 +497,6 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
file_icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(launch_path)[1].lower())
|
||||
file_icon_path = theme_manager.get_icon(file_icon_name, as_path=True) if file_icon_name else ""
|
||||
if get_custom_icon_path(exe_path):
|
||||
return desktop_entry, desktop_path
|
||||
if not self.last_cover_path and file_icon_path:
|
||||
desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", f"Icon={file_icon_path}\n")
|
||||
elif not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128):
|
||||
|
||||
@@ -3,14 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
import orjson
|
||||
|
||||
from PySide6.QtCore import QIODevice, QPoint, QSaveFile, Qt, QUrl
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager, QNetworkDiskCache, QNetworkProxy, QNetworkReply, QNetworkRequest,
|
||||
)
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -20,12 +14,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.config import CACHE_DIR, proxy_config, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CompatibilityReportDialog(DraggableDialog):
|
||||
@@ -80,11 +70,6 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
def _create_buttons(self, theme) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
layout.addStretch()
|
||||
self.ppdb_button = AutoSizeButton(_("Download") + " PPDB")
|
||||
self.ppdb_button.setStyleSheet(theme.ACTION_BUTTON_STYLE)
|
||||
self.ppdb_button.hide()
|
||||
self.ppdb_button.clicked.connect(self._download_ppdb)
|
||||
layout.addWidget(self.ppdb_button)
|
||||
close_button = AutoSizeButton(_("Close"))
|
||||
close_button.setStyleSheet(theme.ACTION_BUTTON_ACTIVE_STYLE)
|
||||
close_button.clicked.connect(self.accept)
|
||||
@@ -92,81 +77,6 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
layout.addWidget(close_button)
|
||||
return layout
|
||||
|
||||
def check_ppdb(self, executable: str) -> None:
|
||||
if ui_config.get_auto_download_ppdb() or not os.path.isfile(executable):
|
||||
return
|
||||
self.ppdb_path = executable + ".ppdb"
|
||||
self.ppdb_network = QNetworkAccessManager(self)
|
||||
self.finished.connect(self.ppdb_network.deleteLater)
|
||||
self.ppdb_network.setTransferTimeout(
|
||||
QNetworkRequest.TransferTimeoutConstant.DefaultTransferTimeoutConstant.value
|
||||
)
|
||||
cache = QNetworkDiskCache(self.ppdb_network)
|
||||
cache.setCacheDirectory(str(CACHE_DIR / "ppdb_lookup"))
|
||||
self.ppdb_network.setCache(cache)
|
||||
proxies = proxy_config.get_proxy() or {}
|
||||
proxy_url = QUrl(proxies.get("https") or proxies.get("http") or "")
|
||||
if proxy_url.host():
|
||||
proxy_type = (QNetworkProxy.ProxyType.Socks5Proxy
|
||||
if proxy_url.scheme().startswith("socks")
|
||||
else QNetworkProxy.ProxyType.HttpProxy)
|
||||
self.ppdb_network.setProxy(QNetworkProxy(
|
||||
proxy_type, proxy_url.host(), max(proxy_url.port(), 0),
|
||||
proxy_url.userName(), proxy_url.password(),
|
||||
))
|
||||
url = QUrl("https://linux-gaming.ru/api/lookup/exe/")
|
||||
url.setPath(url.path() + os.path.basename(executable))
|
||||
reply = self.ppdb_network.get(QNetworkRequest(url))
|
||||
reply.finished.connect(lambda: self._ppdb_lookup_finished(reply))
|
||||
|
||||
def _ppdb_lookup_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB lookup failed: %s", reply.errorString())
|
||||
return
|
||||
try:
|
||||
metadata = orjson.loads(bytes(reply.readAll().data()))
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid PPDB metadata: %s", error)
|
||||
return
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
url = metadata.get("ppdb_url")
|
||||
if not isinstance(url, str):
|
||||
return
|
||||
self.ppdb_url = QUrl(url)
|
||||
if self.ppdb_url.scheme() in {"https", "http"} and self.ppdb_url.host():
|
||||
self.ppdb_button.show()
|
||||
|
||||
def _download_ppdb(self) -> None:
|
||||
self.ppdb_button.setEnabled(False)
|
||||
self.ppdb_button.setText(_("Downloading…"))
|
||||
request = QNetworkRequest(self.ppdb_url)
|
||||
request.setAttribute(
|
||||
QNetworkRequest.Attribute.CacheLoadControlAttribute,
|
||||
QNetworkRequest.CacheLoadControl.AlwaysNetwork,
|
||||
)
|
||||
reply = self.ppdb_network.get(request)
|
||||
reply.finished.connect(lambda: self._ppdb_download_finished(reply))
|
||||
|
||||
def _ppdb_download_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
self.ppdb_button.setText(_("Download") + " PPDB")
|
||||
self.ppdb_button.setEnabled(True)
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB download failed: %s", reply.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
destination = QSaveFile(self.ppdb_path)
|
||||
data = reply.readAll()
|
||||
if (data.isEmpty() or not destination.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
or destination.write(data) != data.size() or not destination.commit()):
|
||||
logger.warning("Failed to save PPDB: %s", destination.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
self.ppdb_button.setText(_("Downloading Complete"))
|
||||
self.ppdb_button.setEnabled(False)
|
||||
|
||||
def _show_context_menu(
|
||||
self, report_view: QPlainTextEdit, theme: Any, position: QPoint
|
||||
) -> None:
|
||||
|
||||
@@ -25,12 +25,7 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
|
||||
dialog_actions = []
|
||||
|
||||
if context == 'folder_import':
|
||||
dialog_actions = [
|
||||
("add_game", _("Apply")),
|
||||
("back", _("Close")),
|
||||
]
|
||||
elif context == 'file_explorer':
|
||||
if context == 'file_explorer':
|
||||
dialog_actions = [
|
||||
("confirm", _("Open")),
|
||||
("add_game", _("Select Dir")),
|
||||
@@ -50,9 +45,10 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
elif context == 'settings':
|
||||
dialog_actions = [
|
||||
("confirm", _("Toggle")),
|
||||
("add_game", _("Save")),
|
||||
("prev_dir", _("Search")),
|
||||
("context_menu", _("Menu")),
|
||||
("back", _("Close")),
|
||||
("back", _("Cancel")),
|
||||
("prev_tab", _("Prev Tab")),
|
||||
("next_tab", _("Next Tab")),
|
||||
]
|
||||
|
||||
@@ -25,7 +25,7 @@ from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.scripts_utils.mount_points import list_mounted_drive_paths
|
||||
@@ -116,7 +116,7 @@ class FileExplorer(DraggableDialog):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
else:
|
||||
logger.warning("Failed to load image: %s", self.file_path)
|
||||
elif self.file_path.lower().endswith((".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
elif self.file_path.lower().endswith((".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
thumbnail_source = self.file_path
|
||||
if self.file_path.lower().endswith(DISC_IMAGE_EXTENSIONS) and callable(self.resolve_launch_file_path):
|
||||
resolved_path = self.resolve_launch_file_path(self.file_path)
|
||||
@@ -125,14 +125,6 @@ class FileExplorer(DraggableDialog):
|
||||
return
|
||||
thumbnail_source = resolved_path
|
||||
|
||||
if thumbnail_source.lower().endswith(".appimage"):
|
||||
icon_path = get_exe_icon_cache_path(thumbnail_source)
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
image = QImage()
|
||||
if os.path.isfile(icon_path) or generate_thumbnail(thumbnail_source, icon_path):
|
||||
image = QImage(icon_path)
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
return
|
||||
if not thumbnail_source.lower().endswith(".exe"):
|
||||
return
|
||||
|
||||
@@ -150,8 +142,6 @@ class FileExplorer(DraggableDialog):
|
||||
os.unlink(tmp_name)
|
||||
except Exception as e:
|
||||
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
|
||||
if self.file_path.lower().endswith(".appimage"):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QImage())
|
||||
|
||||
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
|
||||
super().__init__(parent)
|
||||
@@ -224,7 +214,7 @@ class FileExplorer(DraggableDialog):
|
||||
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
|
||||
continue
|
||||
mime_type = mime_db.mimeTypeForFile(file_path).name()
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
self.pending_thumbnails.add(file_path)
|
||||
resolver = None
|
||||
main_window = cast("MainWindow | None", self.main_window)
|
||||
@@ -256,8 +246,6 @@ class FileExplorer(DraggableDialog):
|
||||
def _set_thumbnail_icon(self, file_path: str, image: QImage) -> None:
|
||||
icon = QIcon(QPixmap.fromImage(image))
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
if image.isNull():
|
||||
return
|
||||
item = self.file_items.get(file_path)
|
||||
if item:
|
||||
item.setData(THEMED_ICON_ROLE, None)
|
||||
@@ -619,9 +607,7 @@ class FileExplorer(DraggableDialog):
|
||||
if not self.directory_only:
|
||||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||||
if self.file_filter:
|
||||
if callable(self.file_filter):
|
||||
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
|
||||
elif isinstance(self.file_filter, str):
|
||||
if isinstance(self.file_filter, str):
|
||||
files = [f for f in files if f.lower().endswith(self.file_filter)]
|
||||
elif isinstance(self.file_filter, tuple):
|
||||
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
|
||||
|
||||
@@ -59,7 +59,7 @@ class ProtonManager(DraggableDialog):
|
||||
self.main_window = None
|
||||
parent_widget = self.parent()
|
||||
while parent_widget:
|
||||
if hasattr(parent_widget, 'get_button_icon'):
|
||||
if hasattr(parent_widget, 'input_manager'):
|
||||
self.main_window = parent_widget
|
||||
break
|
||||
parent_widget = parent_widget.parent()
|
||||
@@ -69,14 +69,6 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
if self.input_manager:
|
||||
self.enable_proton_manager_mode()
|
||||
if self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_hints()
|
||||
|
||||
@staticmethod
|
||||
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
|
||||
@@ -286,6 +278,18 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
self.tab_widget.currentChanged.connect(self.tab_changed)
|
||||
|
||||
if self.input_manager and self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
|
||||
def _open_wine_folder(self) -> None:
|
||||
if not self.portproton_location:
|
||||
return
|
||||
@@ -352,9 +356,14 @@ class ProtonManager(DraggableDialog):
|
||||
logger.error(f"Wine data loading failed: {error_msg}")
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
error_label = QLabel(_("Error loading WINE/Proton: {error}").format(error=error_msg))
|
||||
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||||
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if hasattr(self, 'tab_widget'):
|
||||
self.tab_widget.clear()
|
||||
self.create_installed_tab()
|
||||
error_tab = QWidget()
|
||||
error_layout = QVBoxLayout(error_tab)
|
||||
error_layout.addWidget(error_label)
|
||||
self.tab_widget.addTab(error_tab, _("Error"))
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
self.cpu_level = get_cpu_level()
|
||||
@@ -674,7 +683,7 @@ class ProtonManager(DraggableDialog):
|
||||
version_item = QTableWidgetItem(version_name)
|
||||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 1, version_item)
|
||||
size_item = QTableWidgetItem(_("Loading…"))
|
||||
size_item = QTableWidgetItem(_("Unknown"))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
@@ -953,8 +962,8 @@ class ProtonManager(DraggableDialog):
|
||||
msg_box.setText(_("Are you sure you want to delete {} selected WINE/Proton?\n\nThis action cannot be undone.").format(len(versions_to_remove)))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -29,8 +29,6 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSlider,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QTabWidget,
|
||||
QTableWidget,
|
||||
@@ -57,10 +55,10 @@ from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update
|
||||
from portprotonqt.dialogs.proton_manager import show_proton_manager
|
||||
from portprotonqt.dialogs.settings_mangohud import MANGOHUD_ENV_KEYS, MangoHudSettingsMixin
|
||||
from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, GamescopeSettingsMixin
|
||||
from portprotonqt.dialogs.settings_reshade import RESHADE_ENV_KEYS, ReShadeSettingsMixin
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
|
||||
from portprotonqt.localization import _, format_setting_name_for_display
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_games import NATIVE_TOGGLE_KEYS, NATIVE_ADVANCED_KEYS, load_native_settings, save_native_settings, get_native_ppdb_path
|
||||
from portprotonqt.preloader import Preloader
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
from portprotonqt.settings_manager import (
|
||||
@@ -88,6 +86,8 @@ DEFAULT_WINE_SETTING_KEYS = {
|
||||
'PW_PREFIX_NAME': 'PW_DEFAULT_PREFIX_NAME',
|
||||
'PW_VULKAN_USE': 'PW_DEFAULT_VULKAN_USE',
|
||||
}
|
||||
|
||||
|
||||
def _normalize_prefix_directories(prefixes_dir):
|
||||
if not os.path.isdir(prefixes_dir):
|
||||
return
|
||||
@@ -152,6 +152,7 @@ class ExeSettingsDialog(
|
||||
DraggableDialog,
|
||||
MangoHudSettingsMixin,
|
||||
GamescopeSettingsMixin,
|
||||
ReShadeSettingsMixin,
|
||||
VkBasaltSettingsMixin,
|
||||
):
|
||||
"""Dialog for configuring executable-specific settings."""
|
||||
@@ -167,11 +168,11 @@ class ExeSettingsDialog(
|
||||
if not self.user_conf and not self.exe_path and not self.appid:
|
||||
return
|
||||
self.portproton_path = get_portproton_location()
|
||||
if self.portproton_path is None and self.game_source != "native":
|
||||
if self.portproton_path is None:
|
||||
logger.error("PortProton location not found")
|
||||
return
|
||||
self.start_sh = get_portproton_start_command()
|
||||
if self.start_sh is None and self.game_source != "native":
|
||||
if self.start_sh is None:
|
||||
logger.error("PortProton start command not found")
|
||||
return
|
||||
|
||||
@@ -179,7 +180,7 @@ class ExeSettingsDialog(
|
||||
self.lg_dist_aliases = {}
|
||||
self.plugins_ver = ""
|
||||
self.prefix_options = []
|
||||
if self.portproton_path and self.game_source != "native":
|
||||
if self.portproton_path:
|
||||
scripts_path = get_portproton_scripts_path()
|
||||
if scripts_path:
|
||||
var_path = os.path.join(scripts_path, "var")
|
||||
@@ -201,12 +202,6 @@ class ExeSettingsDialog(
|
||||
_normalize_prefix_directories(prefixes_dir)
|
||||
self.prefix_options = get_available_prefix_options(self.portproton_path)
|
||||
|
||||
self.saving = False
|
||||
self.settings_loading = True
|
||||
self.close_after_save = False
|
||||
self.save_timer = QTimer(self)
|
||||
self.save_timer.setSingleShot(True)
|
||||
self.save_timer.timeout.connect(self.apply_changes)
|
||||
self.current_settings = {}
|
||||
self.value_widgets = {}
|
||||
self.original_values = {}
|
||||
@@ -216,6 +211,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_settings_by_key = {}
|
||||
self.init_mangohud_state()
|
||||
self.init_gamescope_state()
|
||||
self.init_reshade_state()
|
||||
self.init_vkbasalt_state()
|
||||
self.blocked_keys = set()
|
||||
self.numa_nodes = {}
|
||||
@@ -230,9 +226,6 @@ class ExeSettingsDialog(
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
self.toggle_settings = get_toggle_settings()
|
||||
if self.game_source == "native":
|
||||
self.toggle_settings = {key: description for key, description in self.toggle_settings.items() if key in NATIVE_TOGGLE_KEYS}
|
||||
self.setWindowTitle(_("Native") + " — " + _("Settings"))
|
||||
|
||||
self.setup_ui()
|
||||
if self.user_conf:
|
||||
@@ -294,11 +287,6 @@ class ExeSettingsDialog(
|
||||
"""Resolve run-after executable relative to the main executable."""
|
||||
if not exe_path:
|
||||
return exe_path
|
||||
if "," in exe_path and not os.path.isfile(os.path.expanduser(exe_path)):
|
||||
return ",".join(
|
||||
self._resolve_run_after_exe_path(path.strip())
|
||||
for path in exe_path.split(",") if path.strip()
|
||||
)
|
||||
normalized = os.path.normpath(os.path.expanduser(exe_path))
|
||||
if os.path.isabs(normalized):
|
||||
return normalized
|
||||
@@ -309,70 +297,6 @@ class ExeSettingsDialog(
|
||||
return normalized
|
||||
return os.path.normpath(os.path.join(game_dir, normalized))
|
||||
|
||||
def _toggle_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
row = next(index for index in range(table.rowCount())
|
||||
if table.cellWidget(index, 1) is target.parentWidget())
|
||||
expanded = bool(target.property("ppqt_run_after_expanded"))
|
||||
target.setProperty("ppqt_run_after_expanded", not expanded)
|
||||
if expanded:
|
||||
while row + 1 < table.rowCount():
|
||||
item = table.item(row + 1, 0)
|
||||
if item is None or item.data(Qt.ItemDataRole.UserRole) != "run_after_entry":
|
||||
break
|
||||
table.removeRow(row + 1)
|
||||
return
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
if not target.text():
|
||||
entries = [""]
|
||||
for index, path in enumerate(entries):
|
||||
entry_row = row + index + 1
|
||||
table.insertRow(entry_row)
|
||||
item = QTableWidgetItem(path)
|
||||
item.setData(Qt.ItemDataRole.UserRole, "run_after_entry")
|
||||
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
table.setItem(entry_row, 0, item)
|
||||
entry_edit = QLineEdit(path)
|
||||
entry_edit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
entry_edit.installEventFilter(self)
|
||||
entry_edit.textChanged.connect(
|
||||
lambda value, offset=index, widget=target: self._update_run_after_entry(widget, offset, value)
|
||||
)
|
||||
table.setCellWidget(entry_row, 0, entry_edit)
|
||||
button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, offset=index, widget=target: self._remove_run_after_entry(widget, offset)
|
||||
)
|
||||
table.setCellWidget(entry_row, 1, button)
|
||||
table.resizeRowsToContents()
|
||||
|
||||
def _update_run_after_entry(self, target: QLineEdit, index: int, value: str) -> None:
|
||||
entries = target.text().split(",")
|
||||
if index < len(entries):
|
||||
entries[index] = value
|
||||
target.setText(",".join(entries))
|
||||
|
||||
def _remove_run_after_entry(self, target: QLineEdit, index: int) -> None:
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
entries.pop(index)
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setText(",".join(entries))
|
||||
if entries:
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _clear_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
for row in reversed(range(table.rowCount())):
|
||||
item = table.item(row, 0)
|
||||
if item and item.data(Qt.ItemDataRole.UserRole) == "run_after_entry":
|
||||
table.removeRow(row)
|
||||
target.setProperty("ppqt_run_after_expanded", False)
|
||||
target.clear()
|
||||
if target.isVisible():
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _get_setting_file_selector_path(self, current_path: str) -> str:
|
||||
"""Get initial path for setting file selectors."""
|
||||
initial_path = os.path.expanduser("~")
|
||||
@@ -413,6 +337,8 @@ class ExeSettingsDialog(
|
||||
self.mangohud_tab_layout = QVBoxLayout(self.mangohud_tab)
|
||||
self.vkbasalt_tab = QWidget()
|
||||
self.vkbasalt_tab_layout = QVBoxLayout(self.vkbasalt_tab)
|
||||
self.reshade_tab = QWidget()
|
||||
self.reshade_tab_layout = QVBoxLayout(self.reshade_tab)
|
||||
self.gamescope_tab = QWidget()
|
||||
self.gamescope_tab_layout = QVBoxLayout(self.gamescope_tab)
|
||||
|
||||
@@ -420,6 +346,7 @@ class ExeSettingsDialog(
|
||||
self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
|
||||
self.tab_widget.addTab(self.mangohud_tab, "MangoHud")
|
||||
self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt")
|
||||
self.tab_widget.addTab(self.reshade_tab, "ReShade")
|
||||
if self.gamescope_available:
|
||||
self.tab_widget.addTab(self.gamescope_tab, "Gamescope")
|
||||
|
||||
@@ -476,7 +403,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, 230)
|
||||
self.advanced_table.setWordWrap(True)
|
||||
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
@@ -543,6 +470,7 @@ class ExeSettingsDialog(
|
||||
|
||||
self.setup_mangohud_tab()
|
||||
self.setup_vkbasalt_tab()
|
||||
self.setup_reshade_tab()
|
||||
if self.gamescope_available:
|
||||
self.setup_gamescope_tab()
|
||||
|
||||
@@ -553,6 +481,7 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip.setStyleSheet(self.theme.TOOLTIP_STYLE)
|
||||
self.gamepad_tooltip.setVisible(False)
|
||||
self.gamepad_tooltip.setParent(self)
|
||||
self.gamepad_tooltip.setWindowFlags(Qt.WindowType.ToolTip)
|
||||
self.gamepad_tooltip.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.gamepad_tooltip.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
self.gamepad_tooltip_timer = QTimer(self)
|
||||
@@ -560,20 +489,25 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip_timer.timeout.connect(lambda: self.gamepad_tooltip.setVisible(False))
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.cancel_button = AutoSizeButton(_("Close"), icon=ThemeManager().get_icon("cancel", as_path=True))
|
||||
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply", as_path=True))
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel", as_path=True))
|
||||
self.open_ppdb_button = AutoSizeButton(_("Edit PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
self.clear_ppdb_button = AutoSizeButton(_("Clear PPDB"), icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.open_ppdb_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.clear_ppdb_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.apply_button.installEventFilter(self)
|
||||
self.cancel_button.installEventFilter(self)
|
||||
self.open_ppdb_button.installEventFilter(self)
|
||||
self.clear_ppdb_button.installEventFilter(self)
|
||||
button_layout.addWidget(self.apply_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
button_layout.addWidget(self.open_ppdb_button)
|
||||
button_layout.addWidget(self.clear_ppdb_button)
|
||||
self.main_layout.addLayout(button_layout)
|
||||
|
||||
self.apply_button.clicked.connect(self.apply_changes)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.open_ppdb_button.clicked.connect(self.open_ppdb_file)
|
||||
self.clear_ppdb_button.clicked.connect(self.clear_ppdb_file)
|
||||
@@ -581,14 +515,9 @@ class ExeSettingsDialog(
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load available toggles and current settings."""
|
||||
self.settings_loading = True
|
||||
self.settings_container.setCurrentIndex(0)
|
||||
self.advanced_container.setCurrentIndex(0)
|
||||
|
||||
if self.game_source == "native":
|
||||
self.on_show_ppdb_finished(0, QProcess.ExitStatus.NormalExit)
|
||||
return
|
||||
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_show_ppdb_finished)
|
||||
command = ["cli", "--get-user-conf"] if self.user_conf else [
|
||||
@@ -598,7 +527,7 @@ class ExeSettingsDialog(
|
||||
process.start(args[0], args[1:])
|
||||
|
||||
def on_show_ppdb_finished(self, exit_code, exit_status):
|
||||
"""Populate shared settings controls from the selected game's data."""
|
||||
"""Handle --show-ppdb output."""
|
||||
process = cast(QProcess, self.sender())
|
||||
self.current_settings = {}
|
||||
self.blocked_keys = set()
|
||||
@@ -613,15 +542,7 @@ class ExeSettingsDialog(
|
||||
self.reject()
|
||||
return
|
||||
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
self.current_settings = load_native_settings(cast(str, self.exe_path))
|
||||
except Exception as error:
|
||||
logger.warning("Cannot load native settings: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
self.reject()
|
||||
return
|
||||
elif exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
|
||||
for line in output.splitlines():
|
||||
line_stripped = line.strip()
|
||||
@@ -645,6 +566,7 @@ class ExeSettingsDialog(
|
||||
or key in MANGOHUD_ENV_KEYS
|
||||
or key in GAMESCOPE_ENV_KEYS
|
||||
or key in VKBASALT_ENV_KEYS
|
||||
or key in RESHADE_ENV_KEYS
|
||||
or key in TOGGLE_BOOL_KEYS
|
||||
):
|
||||
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
|
||||
@@ -679,6 +601,8 @@ class ExeSettingsDialog(
|
||||
self.current_settings[key] = ''
|
||||
for key in VKBASALT_ENV_KEYS:
|
||||
self.current_settings[key] = ''
|
||||
for key in RESHADE_ENV_KEYS:
|
||||
self.current_settings[key] = ''
|
||||
elif not self.user_conf:
|
||||
self.current_settings.setdefault('PW_MANGOHUD', '0')
|
||||
self.current_settings.setdefault('PW_VKBASALT', '0')
|
||||
@@ -707,14 +631,13 @@ class ExeSettingsDialog(
|
||||
self.populate_advanced()
|
||||
self.populate_mangohud()
|
||||
self.populate_vkbasalt()
|
||||
self.populate_reshade()
|
||||
if self.gamescope_available:
|
||||
self.populate_gamescope()
|
||||
self.populate_favorites(select_tab=True)
|
||||
|
||||
self.settings_container.setCurrentIndex(1)
|
||||
self.advanced_container.setCurrentIndex(1)
|
||||
self.settings_loading = False
|
||||
self._connect_autosave()
|
||||
|
||||
def open_ppdb_file(self):
|
||||
"""Open the PPDB file for the current executable."""
|
||||
@@ -723,16 +646,6 @@ class ExeSettingsDialog(
|
||||
return
|
||||
|
||||
db_path = self.exe_path + ".ppdb"
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
settings = load_native_settings(self.exe_path)
|
||||
db_path = str(get_native_ppdb_path(self.exe_path, settings))
|
||||
if not os.path.exists(db_path):
|
||||
save_native_settings(self.exe_path, {})
|
||||
except Exception as error:
|
||||
logger.warning("Cannot create native PPDB: %s", error)
|
||||
QMessageBox.critical(self, _("Error"), str(error))
|
||||
return
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
QMessageBox.critical(self, _("Error"), _("PPDB file does not exist at: ") + db_path)
|
||||
@@ -756,14 +669,6 @@ class ExeSettingsDialog(
|
||||
return
|
||||
|
||||
db_path = self.exe_path + ".ppdb"
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
settings = load_native_settings(self.exe_path)
|
||||
db_path = str(get_native_ppdb_path(self.exe_path, settings))
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native PPDB: %s", error)
|
||||
QMessageBox.critical(self, _("Error"), str(error))
|
||||
return
|
||||
if not os.path.exists(db_path):
|
||||
QMessageBox.information(self, _("Information"), _("PPDB file does not exist at: ") + db_path)
|
||||
self.load_current_settings()
|
||||
@@ -775,8 +680,8 @@ class ExeSettingsDialog(
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -796,8 +701,8 @@ class ExeSettingsDialog(
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -819,8 +724,8 @@ class ExeSettingsDialog(
|
||||
self.user_conf_changes = [
|
||||
change for change in self.user_conf_changes if change.split('=', 1)[1]
|
||||
]
|
||||
self.saving = True
|
||||
self.close_after_user_conf_changes = False
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
|
||||
def populate_table(self):
|
||||
@@ -924,14 +829,6 @@ class ExeSettingsDialog(
|
||||
dist_options=self.dist_options,
|
||||
prefix_options=self.prefix_options
|
||||
)
|
||||
if self.game_source == "native":
|
||||
advanced_settings = [setting for setting in advanced_settings if setting['key'] in NATIVE_ADVANCED_KEYS]
|
||||
for setting in advanced_settings:
|
||||
if setting["key"] == "PW_WINE_CPU_TOPOLOGY":
|
||||
setting["options"] = [disabled_text] + [str(count) for count in range(1, len(os.sched_getaffinity(0)) + 1)]
|
||||
for setting in advanced_settings:
|
||||
if setting['key'] == 'LAUNCH_PARAMETERS':
|
||||
setting['description'] = _("Launch Arguments")
|
||||
if self.user_conf:
|
||||
for setting in advanced_settings:
|
||||
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
|
||||
@@ -975,19 +872,13 @@ class ExeSettingsDialog(
|
||||
prefix_line_edit = combo.lineEdit()
|
||||
if prefix_line_edit is not None:
|
||||
prefix_line_edit.setPlaceholderText(_("Enter prefix name"))
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
|
||||
current_raw = current.get(setting['key'], '') if self.user_conf else (
|
||||
current.get(setting['key'], setting['default'])
|
||||
@@ -1009,9 +900,7 @@ class ExeSettingsDialog(
|
||||
current_val = reverse_map[current_raw]
|
||||
|
||||
current_val_text = current_val if isinstance(current_val, str) else ''
|
||||
if setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE') and current_val_text == _('Download Wine/Proton...'):
|
||||
current_val_text = setting['options'][0]
|
||||
if current_val_text and combo.findText(current_val_text) < 0:
|
||||
if current_val_text and current_val_text not in setting['options']:
|
||||
combo.addItem(current_val_text)
|
||||
combo.setCurrentText(current_val_text)
|
||||
|
||||
@@ -1062,12 +951,10 @@ class ExeSettingsDialog(
|
||||
if setting['key'] == 'PW_RUN_AFTER_EXE':
|
||||
text_container = QWidget()
|
||||
text_container.setProperty("ppqt_run_after_exe_widget", True)
|
||||
container_layout = QVBoxLayout(text_container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.addWidget(line_edit)
|
||||
line_edit.hide()
|
||||
text_layout = QHBoxLayout()
|
||||
container_layout.addLayout(text_layout)
|
||||
text_layout = QHBoxLayout(text_container)
|
||||
text_layout.setContentsMargins(0, 0, 0, 0)
|
||||
text_layout.setSpacing(6)
|
||||
text_layout.addWidget(line_edit)
|
||||
|
||||
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
@@ -1079,38 +966,26 @@ class ExeSettingsDialog(
|
||||
def open_run_after_exe_selector(
|
||||
_checked: bool = False,
|
||||
target_line_edit=line_edit,
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
file_filter="",
|
||||
):
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
initial_path = self._get_setting_file_selector_path(
|
||||
target_line_edit.text().strip()
|
||||
)
|
||||
|
||||
file_explorer = FileExplorer(
|
||||
self,
|
||||
theme=self.theme,
|
||||
file_filter=file_filter,
|
||||
initial_path=self._get_setting_file_selector_path(
|
||||
target_line_edit.text().rsplit(",", 1)[-1].strip()
|
||||
),
|
||||
initial_path=initial_path,
|
||||
)
|
||||
file_explorer.file_signal.file_selected.connect(
|
||||
lambda path, target=target_line_edit: target.setText(
|
||||
",".join(filter(None, [target.text().strip(), os.path.normpath(path)]))
|
||||
)
|
||||
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
|
||||
)
|
||||
file_explorer.exec()
|
||||
|
||||
browse_button.clicked.connect(open_run_after_exe_selector)
|
||||
expand_button = AutoSizeButton("≡")
|
||||
expand_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
expand_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._toggle_run_after_entries(target)
|
||||
)
|
||||
clear_button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
clear_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._clear_run_after_entries(target)
|
||||
)
|
||||
text_layout.addWidget(clear_button)
|
||||
text_layout.addWidget(expand_button)
|
||||
text_layout.addWidget(browse_button)
|
||||
self.advanced_table.setCellWidget(row, 1, text_container)
|
||||
else:
|
||||
@@ -1233,8 +1108,6 @@ class ExeSettingsDialog(
|
||||
break
|
||||
|
||||
source_header = source_table.horizontalHeader()
|
||||
if source_header.sectionResizeMode(1) == QHeaderView.ResizeMode.ResizeToContents:
|
||||
source_table.resizeColumnToContents(1)
|
||||
target_header = self.favorites_table.horizontalHeader()
|
||||
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
target_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
@@ -1340,8 +1213,8 @@ class ExeSettingsDialog(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
combo.setEditable(source_widget.isEditable())
|
||||
combo.setInsertPolicy(source_widget.insertPolicy())
|
||||
combo.setModel(source_widget.model())
|
||||
for index in range(source_widget.count()):
|
||||
combo.addItem(source_widget.itemText(index), source_widget.itemData(index))
|
||||
combo.setCurrentText(source_widget.currentText())
|
||||
combo.setEnabled(source_widget.isEnabled())
|
||||
settings_combo_style = getattr(self.theme, "SETTINGS_TABLE_COMBOBOX_STYLE", "")
|
||||
@@ -1351,14 +1224,9 @@ class ExeSettingsDialog(
|
||||
+ self.theme.SCROLL_STYLE
|
||||
)
|
||||
combo.currentTextChanged.connect(source_widget.setCurrentText)
|
||||
source_widget.currentTextChanged.connect(combo.setCurrentText)
|
||||
combo.activated.connect(source_widget.activated.emit)
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
combo.highlighted.connect(
|
||||
lambda row_idx, c=combo: self._on_combo_highlighted(row_idx, c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
self.favorites_table.setCellWidget(row, 1, combo)
|
||||
|
||||
def _add_favorite_line_edit(self, row: int, source_widget: QLineEdit) -> None:
|
||||
@@ -1394,7 +1262,9 @@ class ExeSettingsDialog(
|
||||
|
||||
def _on_combo_highlighted(self, row: int, combo: QComboBox) -> None:
|
||||
view = combo.view()
|
||||
view.installEventFilter(self)
|
||||
if not self.isVisible() or not combo.isVisible() or not view.isVisible():
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
index = view.model().index(row, 0)
|
||||
if not index.isValid():
|
||||
@@ -1410,31 +1280,25 @@ class ExeSettingsDialog(
|
||||
if text_width <= item_rect.intersected(view.viewport().rect()).width():
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
self.gamepad_tooltip.setText(text)
|
||||
self.gamepad_tooltip.setWordWrap(False)
|
||||
w = min(500, self.gamepad_tooltip.sizeHint().width())
|
||||
self.gamepad_tooltip.setWordWrap(True)
|
||||
h = min(300, self.gamepad_tooltip.heightForWidth(w))
|
||||
text_rect = fm.boundingRect(0, 0, 480, 1000, Qt.TextFlag.TextWordWrap, text)
|
||||
w = min(500, text_rect.width() + 25)
|
||||
h = min(300, text_rect.height() + 25)
|
||||
item_center_y = view.viewport().mapToGlobal(item_rect.center()).y()
|
||||
combo_right_x = combo.mapToGlobal(combo.rect().topRight()).x()
|
||||
pos = QPoint(combo_right_x + 4, item_center_y - h // 2)
|
||||
screen = combo.screen()
|
||||
screen = QGuiApplication.screenAt(pos) or QGuiApplication.primaryScreen()
|
||||
if screen:
|
||||
ar = screen.availableGeometry().intersected(self.rect().translated(self.mapToGlobal(QPoint())))
|
||||
w, h = min(w, ar.width()), min(h, ar.height())
|
||||
ar = screen.availableGeometry()
|
||||
if pos.x() + w > ar.right():
|
||||
pos.setX(combo.mapToGlobal(combo.rect().topLeft()).x() - w)
|
||||
if pos.x() < ar.left():
|
||||
pos.setX(ar.left())
|
||||
pos.setX(ar.right() - w)
|
||||
if pos.y() + h > ar.bottom():
|
||||
pos.setY(ar.bottom() - h)
|
||||
if pos.y() < ar.top():
|
||||
pos.setY(ar.top())
|
||||
self.gamepad_tooltip.hide()
|
||||
self.gamepad_tooltip.setText(text)
|
||||
self.gamepad_tooltip.setFixedSize(w, h)
|
||||
self.gamepad_tooltip.move(self.mapFromGlobal(pos))
|
||||
self.gamepad_tooltip.move(pos)
|
||||
self.gamepad_tooltip.setVisible(True)
|
||||
self.gamepad_tooltip.raise_()
|
||||
self.gamepad_tooltip_timer.start(max(2500, min(12000, 1500 + len(text) * 30)))
|
||||
|
||||
def _open_wine_manager_from_combo(self, index: int, combo: QComboBox, key: str) -> None:
|
||||
@@ -1466,7 +1330,6 @@ class ExeSettingsDialog(
|
||||
) if setting['key'] == 'PW_WINE_USE')
|
||||
if self.user_conf:
|
||||
options = [_('Default')] + options
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItems(options)
|
||||
if current_text and current_text not in options:
|
||||
@@ -1474,7 +1337,6 @@ class ExeSettingsDialog(
|
||||
combo.insertSeparator(combo.count())
|
||||
combo.addItem(_('Download Wine/Proton...'), 'download_wine')
|
||||
combo.setCurrentText(current_text)
|
||||
combo.blockSignals(False)
|
||||
|
||||
def _select_checkbox_row(self, widget: QCheckBox) -> bool:
|
||||
for (row, column), checkbox in self.value_widgets.items():
|
||||
@@ -1542,61 +1404,11 @@ class ExeSettingsDialog(
|
||||
|
||||
self._filter_mangohud_settings(search_text)
|
||||
self._filter_vkbasalt_settings(search_text)
|
||||
self._filter_reshade_settings(search_text)
|
||||
self._filter_gamescope_settings(search_text)
|
||||
|
||||
def _connect_autosave(self) -> None:
|
||||
for widget in self.findChildren(QWidget):
|
||||
if widget.property("ppqt_autosave") or widget is self.search_edit:
|
||||
continue
|
||||
if isinstance(widget, QLineEdit):
|
||||
if isinstance(widget.parent(), QComboBox):
|
||||
continue
|
||||
widget.textChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QComboBox):
|
||||
widget.currentTextChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QCheckBox):
|
||||
widget.toggled.connect(self._schedule_save)
|
||||
elif isinstance(widget, QSlider):
|
||||
widget.valueChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QPushButton):
|
||||
widget.clicked.connect(self._schedule_save)
|
||||
else:
|
||||
continue
|
||||
widget.setProperty("ppqt_autosave", True)
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if not self.settings_loading:
|
||||
self.save_timer.start(0)
|
||||
|
||||
def _on_save_error(self, error: QProcess.ProcessError) -> None:
|
||||
if error != QProcess.ProcessError.FailedToStart:
|
||||
return
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
logger.error("Failed to start settings save process: %s", cast(QProcess, self.sender()).errorString())
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
|
||||
def _finish_save(self) -> None:
|
||||
for key, value in self.saved_values.items():
|
||||
if self.current_settings.get(key) == self.saved_current_settings.get(key):
|
||||
self.current_settings[key] = value
|
||||
self.original_values.update(self.saved_values)
|
||||
self.original_display_values.update(self.saved_display_values)
|
||||
self.mangohud_original_values.update(self.saved_values)
|
||||
self.vkbasalt_original_values.update(self.saved_values)
|
||||
self.gamescope_original_values.update(self.saved_values)
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
self.saving = False
|
||||
self.apply_changes()
|
||||
if self.close_after_save and not self.saving:
|
||||
self.close()
|
||||
|
||||
def apply_changes(self):
|
||||
"""Apply changes by collecting diffs from both main and advanced tabs."""
|
||||
if self.settings_loading or self.saving:
|
||||
return
|
||||
self.save_timer.stop()
|
||||
changes = []
|
||||
mangohud_enabled = False
|
||||
|
||||
@@ -1628,8 +1440,6 @@ class ExeSettingsDialog(
|
||||
for key, widget in self.advanced_widgets.items():
|
||||
orig_val = self.original_display_values.get(key, '')
|
||||
if isinstance(widget, QComboBox):
|
||||
if widget.currentData() == 'download_wine':
|
||||
continue
|
||||
new_val = widget.currentText()
|
||||
if self.user_conf and new_val == _('Default'):
|
||||
new_val = ''
|
||||
@@ -1669,6 +1479,7 @@ class ExeSettingsDialog(
|
||||
if self.gamescope_available:
|
||||
gamescope_changes = self._collect_gamescope_changes()
|
||||
vkbasalt_changes = self._collect_vkbasalt_changes()
|
||||
reshade_changes = self._collect_reshade_changes()
|
||||
if self.user_conf:
|
||||
if self.current_settings.get('PW_MANGOHUD') != '1':
|
||||
mangohud_changes = [
|
||||
@@ -1685,7 +1496,14 @@ class ExeSettingsDialog(
|
||||
change for change in gamescope_changes
|
||||
if change.startswith('PW_GAMESCOPE=')
|
||||
]
|
||||
specialized_changes = mangohud_changes + vkbasalt_changes + gamescope_changes
|
||||
if self.current_settings.get('PW_USE_RESHADE') != '1':
|
||||
reshade_changes = [
|
||||
change for change in reshade_changes
|
||||
if change.startswith('PW_USE_RESHADE=')
|
||||
]
|
||||
specialized_changes = (
|
||||
mangohud_changes + vkbasalt_changes + reshade_changes + gamescope_changes
|
||||
)
|
||||
if self.user_conf:
|
||||
defaults = ('', '0', '0.00', 'Home')
|
||||
specialized_changes = [
|
||||
@@ -1714,53 +1532,26 @@ class ExeSettingsDialog(
|
||||
if not changes:
|
||||
return
|
||||
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
values = dict(change.split('=', 1) for change in changes)
|
||||
save_native_settings(cast(str, self.exe_path), values)
|
||||
except Exception as error:
|
||||
logger.warning("Cannot save native settings: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
self.load_current_settings()
|
||||
return
|
||||
|
||||
self.saving = True
|
||||
self.clear_ppdb_button.setEnabled(False)
|
||||
self.saved_current_settings = self.current_settings.copy()
|
||||
self.saved_values = dict(change.split('=', 1) for change in changes)
|
||||
self.saved_display_values = {
|
||||
key: widget.currentText() if isinstance(widget, QComboBox) else widget.text().strip()
|
||||
for key, widget in self.advanced_widgets.items()
|
||||
if isinstance(widget, (QComboBox, QLineEdit))
|
||||
}
|
||||
for key in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
|
||||
if key in self.saved_display_values:
|
||||
self.saved_display_values[key] = re.sub(
|
||||
r"[ \t]", "_", self.saved_display_values[key].strip()
|
||||
).upper()
|
||||
if 'PW_RUN_AFTER_EXE' in self.saved_values:
|
||||
self.saved_display_values['PW_RUN_AFTER_EXE'] = self.saved_values['PW_RUN_AFTER_EXE']
|
||||
if self.user_conf:
|
||||
self.user_conf_changes = changes
|
||||
self.close_after_user_conf_changes = True
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
return
|
||||
|
||||
self.save_process = QProcess(self)
|
||||
process = self.save_process
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_edit_db_finished)
|
||||
process.errorOccurred.connect(self._on_save_error)
|
||||
process_args = ["cli", "--edit-db", self.exe_path] + changes
|
||||
args = self._get_process_args(process_args)
|
||||
process.start(args[0], args[1:])
|
||||
self.apply_button.setEnabled(False)
|
||||
|
||||
def _apply_next_user_conf_change(self) -> None:
|
||||
if not self.user_conf_changes:
|
||||
self.apply_button.setEnabled(True)
|
||||
if self.close_after_user_conf_changes:
|
||||
self._finish_save()
|
||||
self.close()
|
||||
else:
|
||||
self.saving = False
|
||||
self.load_current_settings()
|
||||
return
|
||||
|
||||
@@ -1771,45 +1562,33 @@ class ExeSettingsDialog(
|
||||
command.append(value)
|
||||
self.user_conf_process = QProcess(self)
|
||||
self.user_conf_process.finished.connect(self._on_user_conf_change_finished)
|
||||
self.user_conf_process.errorOccurred.connect(self._on_save_error)
|
||||
args = self._get_process_args(command)
|
||||
self.user_conf_process.start(args[0], args[1:])
|
||||
|
||||
def _on_user_conf_change_finished(self, exit_code, exit_status) -> None:
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
error_output = bytes(self.user_conf_process.readAllStandardError().data()).decode(
|
||||
'utf-8', 'ignore'
|
||||
)
|
||||
self.apply_button.setEnabled(True)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
logger.error("Failed to update user.conf: %s", error_output)
|
||||
return
|
||||
self.user_conf_process.deleteLater()
|
||||
self._apply_next_user_conf_change()
|
||||
|
||||
def on_edit_db_finished(self, exit_code, exit_status):
|
||||
"""Handle --edit-db output."""
|
||||
process = cast(QProcess, self.sender())
|
||||
process.deleteLater()
|
||||
self.apply_button.setEnabled(True)
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
logger.error(f"Failed to apply changes: {error_output}")
|
||||
else:
|
||||
self._finish_save()
|
||||
self.load_current_settings()
|
||||
self.close()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if hasattr(self, 'save_timer'):
|
||||
self.apply_changes()
|
||||
if self.saving:
|
||||
self.close_after_save = True
|
||||
event.ignore()
|
||||
return
|
||||
app = QApplication.instance()
|
||||
if isinstance(app, QApplication):
|
||||
app.removeEventFilter(self)
|
||||
@@ -1868,20 +1647,15 @@ class ExeSettingsDialog(
|
||||
item = table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
widget = table.cellWidget(row, 0)
|
||||
if widget is not None and not isinstance(widget, QLabel):
|
||||
continue
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
if text is None:
|
||||
text = item.text()
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
label = cast(QLabel | None, table.cellWidget(row, 0))
|
||||
if label is None:
|
||||
text = item.text()
|
||||
label = QLabel()
|
||||
label.setTextFormat(Qt.TextFormat.RichText)
|
||||
label.setWordWrap(True)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
table.setCellWidget(row, 0, label)
|
||||
label.setAutoFillBackground(False)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
@@ -1894,8 +1668,8 @@ class ExeSettingsDialog(
|
||||
palette.setBrush(QPalette.ColorRole.Text, item.foreground())
|
||||
label.setAutoFillBackground(selected)
|
||||
label.setPalette(palette)
|
||||
if label.foregroundRole() != QPalette.ColorRole.Text:
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
name, separator, description = text.partition("\n")
|
||||
formatted_text = escape(name)
|
||||
if separator:
|
||||
@@ -1911,11 +1685,7 @@ class ExeSettingsDialog(
|
||||
if label.text() != formatted_text:
|
||||
label.setText(formatted_text)
|
||||
width = max(1, table.columnWidth(0))
|
||||
height_key = [width, formatted_text, label.font().toString()]
|
||||
if label.property("ppqt_height_key") != height_key:
|
||||
height = label.heightForWidth(width) + table.fontMetrics().height()
|
||||
label.setProperty("ppqt_height_key", height_key)
|
||||
table.setRowHeight(row, height)
|
||||
table.setRowHeight(row, label.heightForWidth(width) + table.fontMetrics().height())
|
||||
for row in range(table.rowCount()):
|
||||
widget = table.cellWidget(row, 1)
|
||||
if widget is None:
|
||||
@@ -1929,11 +1699,13 @@ class ExeSettingsDialog(
|
||||
)
|
||||
return False
|
||||
|
||||
if isinstance(obj, QWidget) and self._handle_reshade_key_button_event(obj, event):
|
||||
return True
|
||||
if isinstance(obj, QWidget) and self._handle_vkbasalt_key_button_event(obj, event):
|
||||
self._schedule_save()
|
||||
return True
|
||||
|
||||
action_buttons = (
|
||||
getattr(self, "apply_button", None),
|
||||
getattr(self, "cancel_button", None),
|
||||
getattr(self, "open_ppdb_button", None),
|
||||
getattr(self, "clear_ppdb_button", None),
|
||||
@@ -2011,9 +1783,8 @@ class ExeSettingsDialog(
|
||||
y = available_rect.top()
|
||||
|
||||
self.gamepad_tooltip.setFixedSize(required_width, required_height)
|
||||
self.gamepad_tooltip.move(self.mapFromGlobal(QPoint(x, y)))
|
||||
self.gamepad_tooltip.move(x, y)
|
||||
self.gamepad_tooltip.setVisible(True)
|
||||
self.gamepad_tooltip.raise_()
|
||||
self.gamepad_tooltip_timer.start(tooltip_timeout_ms)
|
||||
|
||||
if anchor_global_pos is not None:
|
||||
@@ -2043,11 +1814,6 @@ class ExeSettingsDialog(
|
||||
super().hideEvent(event)
|
||||
|
||||
def reject(self):
|
||||
if hasattr(self, 'save_timer'):
|
||||
self.apply_changes()
|
||||
if self.saving:
|
||||
self.close_after_save = True
|
||||
return
|
||||
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
|
||||
self.keyboard.hide()
|
||||
self.gamepad_tooltip.setVisible(False)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""ReShade settings support for executable settings dialog."""
|
||||
|
||||
import os
|
||||
from typing import Any, cast
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QKeyEvent, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
RESHADE_ENV_KEYS = [
|
||||
'PW_RESHADE_EXE',
|
||||
'PW_RESHADE_API',
|
||||
'PW_RESHADE_FONT',
|
||||
'PW_RESHADE_SCREENSHOT_PATH',
|
||||
'PW_RESHADE_SCREENSHOT_KEY',
|
||||
'PW_RESHADE_OVERLAY_KEY',
|
||||
]
|
||||
RESHADE_API_OPTIONS = {
|
||||
"Auto": "auto",
|
||||
"DXGI": "dxgi.dll",
|
||||
"DirectX 8/9": "d3d9.dll",
|
||||
"OpenGL": "opengl32.dll",
|
||||
"Vulkan": "vulkan",
|
||||
}
|
||||
RESHADE_SPECIAL_KEYS = {
|
||||
Qt.Key.Key_Cancel: 3,
|
||||
Qt.Key.Key_Backspace: 8,
|
||||
Qt.Key.Key_Tab: 9,
|
||||
Qt.Key.Key_Clear: 12,
|
||||
Qt.Key.Key_Return: 13,
|
||||
Qt.Key.Key_Enter: 13,
|
||||
Qt.Key.Key_Pause: 19,
|
||||
Qt.Key.Key_CapsLock: 20,
|
||||
Qt.Key.Key_Escape: 27,
|
||||
Qt.Key.Key_Space: 32,
|
||||
Qt.Key.Key_PageUp: 33,
|
||||
Qt.Key.Key_PageDown: 34,
|
||||
Qt.Key.Key_End: 35,
|
||||
Qt.Key.Key_Home: 36,
|
||||
Qt.Key.Key_Left: 37,
|
||||
Qt.Key.Key_Up: 38,
|
||||
Qt.Key.Key_Right: 39,
|
||||
Qt.Key.Key_Down: 40,
|
||||
Qt.Key.Key_Print: 44,
|
||||
Qt.Key.Key_Insert: 45,
|
||||
Qt.Key.Key_Delete: 46,
|
||||
Qt.Key.Key_Help: 47,
|
||||
Qt.Key.Key_Meta: 91,
|
||||
Qt.Key.Key_Menu: 93,
|
||||
Qt.Key.Key_Sleep: 95,
|
||||
Qt.Key.Key_NumLock: 144,
|
||||
Qt.Key.Key_ScrollLock: 145,
|
||||
Qt.Key.Key_Semicolon: 186,
|
||||
Qt.Key.Key_Equal: 187,
|
||||
Qt.Key.Key_Comma: 188,
|
||||
Qt.Key.Key_Minus: 189,
|
||||
Qt.Key.Key_Period: 190,
|
||||
Qt.Key.Key_Slash: 191,
|
||||
Qt.Key.Key_QuoteLeft: 192,
|
||||
Qt.Key.Key_BracketLeft: 219,
|
||||
Qt.Key.Key_Backslash: 220,
|
||||
Qt.Key.Key_BracketRight: 221,
|
||||
Qt.Key.Key_Apostrophe: 222,
|
||||
Qt.Key.Key_Back: 166,
|
||||
Qt.Key.Key_Forward: 167,
|
||||
Qt.Key.Key_Refresh: 168,
|
||||
Qt.Key.Key_Stop: 169,
|
||||
Qt.Key.Key_Search: 170,
|
||||
Qt.Key.Key_Favorites: 171,
|
||||
Qt.Key.Key_HomePage: 172,
|
||||
Qt.Key.Key_VolumeMute: 173,
|
||||
Qt.Key.Key_VolumeDown: 174,
|
||||
Qt.Key.Key_VolumeUp: 175,
|
||||
Qt.Key.Key_MediaNext: 176,
|
||||
Qt.Key.Key_MediaPrevious: 177,
|
||||
Qt.Key.Key_MediaStop: 178,
|
||||
Qt.Key.Key_MediaPlay: 179,
|
||||
Qt.Key.Key_LaunchMail: 180,
|
||||
Qt.Key.Key_LaunchMedia: 181,
|
||||
Qt.Key.Key_Launch0: 182,
|
||||
Qt.Key.Key_Launch1: 183,
|
||||
}
|
||||
RESHADE_KEYPAD_KEYS = {
|
||||
Qt.Key.Key_Asterisk: 106,
|
||||
Qt.Key.Key_Plus: 107,
|
||||
Qt.Key.Key_Comma: 108,
|
||||
Qt.Key.Key_Minus: 109,
|
||||
Qt.Key.Key_Period: 110,
|
||||
Qt.Key.Key_Slash: 111,
|
||||
}
|
||||
RESHADE_FIRST_FUNCTION_KEY = 112
|
||||
RESHADE_LAST_FUNCTION_KEY = 135
|
||||
RESHADE_FIRST_NUMPAD_KEY = 96
|
||||
|
||||
|
||||
class ReShadeSettingsMixin:
|
||||
"""Mixin with ReShade settings UI and serialization logic."""
|
||||
|
||||
theme: Any
|
||||
exe_path: str | None
|
||||
current_settings: dict[str, str]
|
||||
original_values: dict[str, str]
|
||||
reshade_tab: QWidget
|
||||
reshade_tab_layout: QVBoxLayout
|
||||
|
||||
def init_reshade_state(self) -> None:
|
||||
self.reshade_original_values = {}
|
||||
self.reshade_key_target = None
|
||||
|
||||
def setup_reshade_tab(self) -> None:
|
||||
"""Create ReShade tab widgets."""
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
scroll.setStyleSheet(self.theme.SCROLL_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
layout.setSpacing(self.theme.exeSettingsGroupBoxBlockSpacing)
|
||||
|
||||
actions = QGroupBox(_("Actions"))
|
||||
actions.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
|
||||
actions_layout = QGridLayout(actions)
|
||||
self.reshade_enable_button = self._create_reshade_button("")
|
||||
self.reshade_enable_button.clicked.connect(self.toggle_reshade_enable)
|
||||
actions_layout.addWidget(self.reshade_enable_button, 0, 0)
|
||||
layout.addWidget(actions)
|
||||
|
||||
self._add_reshade_settings_group(layout)
|
||||
layout.addStretch()
|
||||
|
||||
scroll.setWidget(container)
|
||||
self.reshade_tab_layout.addWidget(scroll)
|
||||
|
||||
def _add_reshade_settings_group(self, parent_layout: QVBoxLayout) -> None:
|
||||
self.reshade_settings_group = QGroupBox(_("Settings"))
|
||||
self.reshade_settings_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
|
||||
settings_layout = QGridLayout(self.reshade_settings_group)
|
||||
self.reshade_exe_edit = self._add_reshade_file_row(
|
||||
settings_layout, 0, "EXE", ".exe"
|
||||
)
|
||||
self.reshade_api_combo = CustomComboBox(theme=self.theme)
|
||||
self.reshade_api_combo.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
self.reshade_api_combo.view().window().setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
self.reshade_api_combo.setStyleSheet(
|
||||
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
|
||||
)
|
||||
self.reshade_api_combo.addItems(list(RESHADE_API_OPTIONS))
|
||||
settings_layout.addWidget(QLabel("API"), 1, 0)
|
||||
settings_layout.addWidget(self.reshade_api_combo, 1, 1)
|
||||
self.reshade_font_combo = CustomComboBox(theme=self.theme)
|
||||
self.reshade_font_combo.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
self.reshade_font_combo.view().window().setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
self.reshade_font_combo.setStyleSheet(
|
||||
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
|
||||
)
|
||||
self.reshade_font_combo.addItem(_("Default"), "")
|
||||
settings_layout.addWidget(QLabel(_("Font")), 2, 0)
|
||||
settings_layout.addWidget(self.reshade_font_combo, 2, 1)
|
||||
self.reshade_screenshot_edit = self._add_reshade_directory_row(
|
||||
settings_layout, 3, _("Screenshots")
|
||||
)
|
||||
self.reshade_screenshot_key_button = self._create_reshade_key_button(
|
||||
"screenshot"
|
||||
)
|
||||
settings_layout.addWidget(QLabel(_("Screenshot key")), 4, 0)
|
||||
settings_layout.addWidget(self.reshade_screenshot_key_button, 4, 1)
|
||||
self.reshade_overlay_key_button = self._create_reshade_key_button("overlay")
|
||||
settings_layout.addWidget(QLabel(_("Overlay key")), 5, 0)
|
||||
settings_layout.addWidget(self.reshade_overlay_key_button, 5, 1)
|
||||
parent_layout.addWidget(self.reshade_settings_group)
|
||||
self._load_reshade_fonts()
|
||||
|
||||
def _create_reshade_button(self, label: str) -> QPushButton:
|
||||
button = QPushButton(label)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
return button
|
||||
|
||||
def _add_reshade_file_row(
|
||||
self, layout: QGridLayout, row: int, label: str, file_filter: str
|
||||
) -> QLineEdit:
|
||||
line_edit = QLineEdit()
|
||||
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
button = AutoSizeButton(
|
||||
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
|
||||
)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False: self._select_reshade_file(line_edit, file_filter)
|
||||
)
|
||||
field_layout = QHBoxLayout()
|
||||
field_layout.addWidget(line_edit)
|
||||
field_layout.addWidget(button)
|
||||
layout.addWidget(QLabel(label), row, 0)
|
||||
layout.addLayout(field_layout, row, 1)
|
||||
return line_edit
|
||||
|
||||
def _select_reshade_file(self, line_edit: QLineEdit, file_filter: str) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
current_path = line_edit.text().strip()
|
||||
initial_path = os.path.dirname(current_path) if os.path.isfile(current_path) else None
|
||||
explorer = FileExplorer(
|
||||
self, theme=self.theme, file_filter=file_filter, initial_path=initial_path
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(line_edit.setText)
|
||||
explorer.exec()
|
||||
|
||||
def _add_reshade_directory_row(
|
||||
self, layout: QGridLayout, row: int, label: str
|
||||
) -> QLineEdit:
|
||||
line_edit = QLineEdit()
|
||||
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
button = AutoSizeButton(
|
||||
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
|
||||
)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False: self._select_reshade_directory(line_edit)
|
||||
)
|
||||
field_layout = QHBoxLayout()
|
||||
field_layout.addWidget(line_edit)
|
||||
field_layout.addWidget(button)
|
||||
layout.addWidget(QLabel(label), row, 0)
|
||||
layout.addLayout(field_layout, row, 1)
|
||||
return line_edit
|
||||
|
||||
def _select_reshade_directory(self, line_edit: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
initial_path = line_edit.text().strip() or os.path.dirname(self.exe_path or "")
|
||||
explorer = FileExplorer(
|
||||
self, theme=self.theme, initial_path=initial_path, directory_only=True
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(line_edit.setText)
|
||||
explorer.exec()
|
||||
|
||||
def _create_reshade_key_button(self, target: str) -> QPushButton:
|
||||
button = self._create_reshade_button("")
|
||||
button.clicked.connect(lambda _checked=False: self._capture_reshade_key(target))
|
||||
button.installEventFilter(cast(QWidget, self))
|
||||
return button
|
||||
|
||||
def _capture_reshade_key(self, target: str) -> None:
|
||||
self.reshade_key_target = target
|
||||
button = self._get_reshade_key_button(target)
|
||||
button.setText(_("Press a button to choose"))
|
||||
button.grabKeyboard()
|
||||
|
||||
def _get_reshade_key_button(self, target: str) -> QPushButton:
|
||||
if target == "screenshot":
|
||||
return self.reshade_screenshot_key_button
|
||||
return self.reshade_overlay_key_button
|
||||
|
||||
def _handle_reshade_key_button_event(self, obj: QWidget, event: QEvent) -> bool:
|
||||
target = self.reshade_key_target
|
||||
if target is None or obj is not self._get_reshade_key_button(target):
|
||||
return False
|
||||
if event.type() != QEvent.Type.KeyPress:
|
||||
return False
|
||||
key_event = cast(QKeyEvent, event)
|
||||
key_value = self._reshade_virtual_key(
|
||||
key_event.key(), key_event.modifiers()
|
||||
)
|
||||
if key_value is None:
|
||||
return True
|
||||
modifiers = key_event.modifiers()
|
||||
value = ",".join(str(item) for item in (
|
||||
key_value,
|
||||
int(bool(modifiers & Qt.KeyboardModifier.ControlModifier)),
|
||||
int(bool(modifiers & Qt.KeyboardModifier.ShiftModifier)),
|
||||
int(bool(modifiers & Qt.KeyboardModifier.AltModifier)),
|
||||
))
|
||||
self.current_settings[f'PW_RESHADE_{target.upper()}_KEY'] = value
|
||||
button = self._get_reshade_key_button(target)
|
||||
button.setText(QKeySequence(key_event.keyCombination()).toString())
|
||||
button.releaseKeyboard()
|
||||
self.reshade_key_target = None
|
||||
return True
|
||||
|
||||
def _reshade_virtual_key(
|
||||
self, key: int, modifiers: Qt.KeyboardModifier
|
||||
) -> int | None:
|
||||
is_keypad = bool(modifiers & Qt.KeyboardModifier.KeypadModifier)
|
||||
if is_keypad and Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
|
||||
return RESHADE_FIRST_NUMPAD_KEY + key - Qt.Key.Key_0.value
|
||||
if is_keypad:
|
||||
try:
|
||||
keypad_key = RESHADE_KEYPAD_KEYS.get(Qt.Key(key))
|
||||
except ValueError:
|
||||
keypad_key = None
|
||||
if keypad_key is not None:
|
||||
return keypad_key
|
||||
if Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
|
||||
return key
|
||||
if Qt.Key.Key_A.value <= key <= Qt.Key.Key_Z.value:
|
||||
return key
|
||||
if Qt.Key.Key_F1.value <= key <= Qt.Key.Key_F24.value:
|
||||
return RESHADE_FIRST_FUNCTION_KEY + key - Qt.Key.Key_F1.value
|
||||
try:
|
||||
return RESHADE_SPECIAL_KEYS.get(Qt.Key(key))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _format_reshade_key(self, value: str) -> str:
|
||||
try:
|
||||
key, control, shift, alt = (int(item) for item in value.split(","))
|
||||
except ValueError:
|
||||
return value
|
||||
qt_key = next(
|
||||
(item for item, code in RESHADE_SPECIAL_KEYS.items() if code == key), None
|
||||
)
|
||||
if qt_key is not None:
|
||||
name = QKeySequence(qt_key).toString()
|
||||
elif RESHADE_FIRST_FUNCTION_KEY <= key <= RESHADE_LAST_FUNCTION_KEY:
|
||||
name = f"F{key - RESHADE_FIRST_FUNCTION_KEY + 1}"
|
||||
else:
|
||||
name = chr(key)
|
||||
modifiers = [
|
||||
label for active, label in (
|
||||
(control, "Ctrl"), (shift, "Shift"), (alt, "Alt")
|
||||
) if active
|
||||
]
|
||||
return "+".join([*modifiers, name])
|
||||
|
||||
def _load_reshade_fonts(self) -> None:
|
||||
selected_family = self.current_settings.get('PW_RESHADE_FONT', '')
|
||||
font_options = cast(Any, self)._get_mangohud_font_options()
|
||||
families = {name for name, _path in font_options}
|
||||
self.reshade_font_combo.clear()
|
||||
self.reshade_font_combo.addItem(_("Default"), "")
|
||||
for family in sorted(families, key=str.casefold):
|
||||
self.reshade_font_combo.addItem(family, family)
|
||||
self._select_reshade_font(selected_family)
|
||||
|
||||
def _select_reshade_font(self, family: str) -> None:
|
||||
for index in range(self.reshade_font_combo.count()):
|
||||
if self.reshade_font_combo.itemData(index) == family:
|
||||
self.reshade_font_combo.setCurrentIndex(index)
|
||||
return
|
||||
if family:
|
||||
self.reshade_font_combo.addItem(family, family)
|
||||
self.reshade_font_combo.setCurrentIndex(self.reshade_font_combo.count() - 1)
|
||||
|
||||
def populate_reshade(self) -> None:
|
||||
"""Populate ReShade tab from current settings."""
|
||||
reshade_exe = self.current_settings.get('PW_RESHADE_EXE', '') or self.exe_path or ''
|
||||
self.reshade_exe_edit.setText(reshade_exe)
|
||||
api_value = self.current_settings.get('PW_RESHADE_API', 'auto')
|
||||
api_label = next(
|
||||
(label for label, value in RESHADE_API_OPTIONS.items() if value == api_value),
|
||||
"Auto",
|
||||
)
|
||||
self.reshade_api_combo.setCurrentText(api_label)
|
||||
font_family = self.current_settings.get('PW_RESHADE_FONT', '')
|
||||
self._select_reshade_font(font_family)
|
||||
screenshot_path = self.current_settings.get('PW_RESHADE_SCREENSHOT_PATH', '')
|
||||
screenshot_key = self.current_settings.get(
|
||||
'PW_RESHADE_SCREENSHOT_KEY', '44,0,0,0'
|
||||
)
|
||||
overlay_key = self.current_settings.get(
|
||||
'PW_RESHADE_OVERLAY_KEY', '36,0,0,0'
|
||||
)
|
||||
self.reshade_screenshot_edit.setText(screenshot_path)
|
||||
self.reshade_screenshot_key_button.setText(
|
||||
self._format_reshade_key(screenshot_key)
|
||||
)
|
||||
self.reshade_overlay_key_button.setText(self._format_reshade_key(overlay_key))
|
||||
self.current_settings['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
|
||||
self.current_settings['PW_RESHADE_OVERLAY_KEY'] = overlay_key
|
||||
self.reshade_original_values = {
|
||||
key: self.current_settings.get(key, '') for key in RESHADE_ENV_KEYS
|
||||
}
|
||||
self.reshade_original_values['PW_RESHADE_EXE'] = reshade_exe
|
||||
self.reshade_original_values['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
|
||||
self.reshade_original_values['PW_RESHADE_OVERLAY_KEY'] = overlay_key
|
||||
self._update_reshade_widgets()
|
||||
|
||||
def toggle_reshade_enable(self) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.current_settings['PW_USE_RESHADE'] = '0' if enabled else '1'
|
||||
self._update_reshade_widgets()
|
||||
|
||||
def _update_reshade_widgets(self) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.reshade_enable_button.setText(
|
||||
_("Disable {0}").format("ReShade")
|
||||
if enabled else _("Enable {0}").format("ReShade")
|
||||
)
|
||||
self.reshade_enable_button.setStyleSheet(
|
||||
self.theme.ACTION_BUTTON_ACTIVE_STYLE if enabled else self.theme.ACTION_BUTTON_STYLE
|
||||
)
|
||||
self.reshade_settings_group.setVisible(enabled)
|
||||
|
||||
def _collect_reshade_changes(self) -> list[str]:
|
||||
changes = []
|
||||
enabled = '1' if self.current_settings.get('PW_USE_RESHADE') == '1' else '0'
|
||||
original_enabled = '1' if self.original_values.get('PW_USE_RESHADE') == '1' else '0'
|
||||
if enabled != original_enabled:
|
||||
changes.append(f"PW_USE_RESHADE={enabled}")
|
||||
values = {
|
||||
'PW_RESHADE_EXE': self.reshade_exe_edit.text().strip(),
|
||||
'PW_RESHADE_API': RESHADE_API_OPTIONS[self.reshade_api_combo.currentText()],
|
||||
'PW_RESHADE_FONT': str(self.reshade_font_combo.currentData() or ''),
|
||||
'PW_RESHADE_SCREENSHOT_PATH': self.reshade_screenshot_edit.text().strip(),
|
||||
'PW_RESHADE_SCREENSHOT_KEY': self.current_settings['PW_RESHADE_SCREENSHOT_KEY'],
|
||||
'PW_RESHADE_OVERLAY_KEY': self.current_settings['PW_RESHADE_OVERLAY_KEY'],
|
||||
}
|
||||
for key, value in values.items():
|
||||
if value != self.reshade_original_values.get(key, ''):
|
||||
changes.append(f"{key}={value}")
|
||||
return changes
|
||||
|
||||
def _filter_reshade_settings(self, search_text: str) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.reshade_settings_group.setVisible(
|
||||
enabled and (
|
||||
not search_text
|
||||
or search_text in "exe api font screenshot overlay"
|
||||
or search_text in _("Settings").lower()
|
||||
)
|
||||
)
|
||||
+4
-10
@@ -266,11 +266,9 @@ class EGSAPI:
|
||||
data = self._load_json(self.config_dir / "installed.json", {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def is_game_installed(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> bool:
|
||||
def is_game_installed(self, app_id: str) -> bool:
|
||||
"""Return whether Legendary records a usable installation."""
|
||||
return self.get_launch_target(app_id, installed_data) is not None
|
||||
return self.get_launch_target(app_id) is not None
|
||||
|
||||
@staticmethod
|
||||
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
|
||||
@@ -287,13 +285,9 @@ class EGSAPI:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_launch_target(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> str | None:
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
"""Return the installed game's Windows executable."""
|
||||
installed = (
|
||||
installed_data if installed_data is not None else self.load_installed()
|
||||
).get(app_id, {})
|
||||
installed = self.load_installed().get(app_id, {})
|
||||
install_path = Path(str(installed.get("install_path", "")))
|
||||
executable = str(installed.get("executable", "")).replace("\\", "/")
|
||||
target = install_path.joinpath(*executable.split("/"))
|
||||
|
||||
+12
-43
@@ -16,7 +16,6 @@ from PySide6.QtGui import (
|
||||
QEnterEvent,
|
||||
QFocusEvent,
|
||||
QMouseEvent,
|
||||
QKeyEvent,
|
||||
QPaintEvent,
|
||||
)
|
||||
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
@@ -287,10 +286,6 @@ class AnimatedCard(QFrame):
|
||||
colors = (self.theme.color_bg, self.theme.color_surface_elevated)
|
||||
painter.fillRect(self.rect(), QColor(colors[row % len(colors)]))
|
||||
self.animations.paint_border(painter)
|
||||
if getattr(self, "batch_selected", False):
|
||||
painter.setPen(QPen(QColor(self.theme.color_accent),
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"]))
|
||||
painter.drawRect(self.rect())
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.raise_()
|
||||
@@ -380,7 +375,6 @@ class GameCard(AnimatedCard):
|
||||
|
||||
self.select_callback = select_callback
|
||||
self.context_menu_manager = context_menu_manager
|
||||
self.batch_selected = False
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.theme_manager = ThemeManager()
|
||||
@@ -402,7 +396,6 @@ class GameCard(AnimatedCard):
|
||||
self.steam_visible = str(game_source).lower() == "steam"
|
||||
self.gog_visible = str(game_source).lower() == "gog"
|
||||
self.egs_visible = str(game_source).lower() == "egs"
|
||||
self.native_visible = str(game_source).lower() == "native"
|
||||
self.portproton_visible = str(game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
@@ -516,13 +509,6 @@ class GameCard(AnimatedCard):
|
||||
)
|
||||
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
|
||||
|
||||
self.nativeLabel = SourceCorner(
|
||||
icon=self.theme_manager.get_icon("badge_linux", as_path=True),
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.nativeLabel.setVisible(self.native_visible and not self.vertical_layout)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
icon=portproton_icon,
|
||||
@@ -669,7 +655,6 @@ class GameCard(AnimatedCard):
|
||||
(self.steamLabel, "badge_steam"),
|
||||
(self.gogLabel, "badge_gog"),
|
||||
(self.egsLabel, "badge_egs"),
|
||||
(self.nativeLabel, "badge_linux"),
|
||||
(self.portprotonLabel, "badge_portproton"),
|
||||
):
|
||||
icon = self.theme_manager.get_icon(
|
||||
@@ -888,11 +873,10 @@ class GameCard(AnimatedCard):
|
||||
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
|
||||
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.egs_visible, self.egsLabel),
|
||||
(self.native_visible, self.nativeLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
(self.steam_visible and not self.vertical_layout, self.steamLabel),
|
||||
(self.gog_visible and not self.vertical_layout, self.gogLabel),
|
||||
(self.egs_visible and not self.vertical_layout, self.egsLabel),
|
||||
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
@@ -915,7 +899,6 @@ class GameCard(AnimatedCard):
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.nativeLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -1057,19 +1040,21 @@ class GameCard(AnimatedCard):
|
||||
self.steam_visible = str(self.game_source).lower() == "steam"
|
||||
self.gog_visible = str(self.game_source).lower() == "gog"
|
||||
self.egs_visible = str(self.game_source).lower() == "egs"
|
||||
self.native_visible = str(self.game_source).lower() == "native"
|
||||
self.portproton_visible = str(self.game_source).lower() == "portproton"
|
||||
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
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
show_source_ribbons = self.vertical_layout is not True
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.nativeLabel.setVisible(self.native_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
|
||||
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
|
||||
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and show_source_ribbons
|
||||
)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
|
||||
@@ -1271,12 +1256,6 @@ class GameCard(AnimatedCard):
|
||||
super().showEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
if self.context_menu_manager:
|
||||
if self.context_menu_manager.card_selection_mode is True:
|
||||
self.batch_selected = not self.batch_selected
|
||||
self.update()
|
||||
return
|
||||
self.context_menu_manager.clear_card_selection()
|
||||
if self._get_missing_executable_path() and self.context_menu_manager:
|
||||
self.context_menu_manager.handle_missing_executable(self)
|
||||
return
|
||||
@@ -1306,16 +1285,6 @@ class GameCard(AnimatedCard):
|
||||
self.click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
if event.key() == Qt.Key.Key_Space and self.context_menu_manager:
|
||||
if self.context_menu_manager.card_selection_mode:
|
||||
self.context_menu_manager.clear_card_selection()
|
||||
else:
|
||||
self.context_menu_manager.start_card_selection(self)
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up animations to prevent memory leaks when the card is destroyed."""
|
||||
if hasattr(self, 'coverLabel') and self.coverLabel is not None:
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
"""Unified icon extractor for NE, PE and type-2 AppImages."""
|
||||
"""Unified icon extractor for NE and PE files."""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import io
|
||||
import subprocess
|
||||
import tarfile
|
||||
import posixpath
|
||||
import glob
|
||||
import pefile
|
||||
from PySide6.QtCore import QByteArray, QBuffer, QIODevice
|
||||
from PySide6.QtGui import QImage, QImageWriter
|
||||
from PIL import Image
|
||||
from portprotonqt.config.base import CACHE_DIR
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -23,82 +15,6 @@ logger = get_logger(__name__)
|
||||
RT_ICON = 3
|
||||
RT_GROUP_ICON = 14
|
||||
THUMBNAIL_SIZE = 128
|
||||
APPIMAGE_EXTRACT_TIMEOUT = 10
|
||||
ELF_SECTION_LAYOUTS = {1: (32, "I", 46), 2: (40, "Q", 58)}
|
||||
|
||||
|
||||
def _read_dwarfs_icon(path: str, offset: int) -> bytes:
|
||||
"""Read an icon and resolve archive-local links without extracting to disk."""
|
||||
filename = ".DirIcon"
|
||||
seen: set[str] = set()
|
||||
while filename not in seen:
|
||||
seen.add(filename)
|
||||
result = subprocess.run(
|
||||
["dwarfsextract", "-i", os.path.abspath(path), "-O", str(offset),
|
||||
"-f", "pax", "-o", "-", "--pattern", ":" + glob.escape(filename),
|
||||
"--skip-devices", "--skip-specials", "--log-level", "error"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
|
||||
member = next((item for item in archive if item.name.removeprefix("./") == filename), None)
|
||||
if member is None:
|
||||
return b""
|
||||
if member.issym() or member.islnk():
|
||||
target = member.linkname
|
||||
base = posixpath.dirname(filename) if member.issym() else ""
|
||||
filename = posixpath.normpath(target.lstrip("/") if target.startswith("/")
|
||||
else posixpath.join(base, target))
|
||||
if filename == ".." or filename.startswith("../"):
|
||||
return b""
|
||||
continue
|
||||
if not member.isfile():
|
||||
return b""
|
||||
stream = archive.extractfile(member)
|
||||
return stream.read() if stream is not None else b""
|
||||
return b""
|
||||
|
||||
|
||||
def _get_appimage_icon(path: str) -> io.BytesIO | None:
|
||||
"""Read .DirIcon from a type-2 AppImage without executing its runtime."""
|
||||
with open(path, "rb") as source:
|
||||
header = source.read(64)
|
||||
if len(header) < 64 or header[:4] != b"\x7fELF" or header[8:11] != b"AI\x02":
|
||||
return None
|
||||
layout = ELF_SECTION_LAYOUTS.get(header[4])
|
||||
if layout is None or header[5] not in (1, 2):
|
||||
return None
|
||||
endian = "<" if header[5] == 1 else ">"
|
||||
section_offset, offset_format, size_offset = layout
|
||||
offset = struct.unpack_from(endian + offset_format, header, section_offset)[0]
|
||||
entry_size, entry_count = struct.unpack_from(endian + "HH", header, size_offset)
|
||||
offset += entry_size * entry_count
|
||||
source.seek(offset)
|
||||
magic = source.read(6)
|
||||
if not magic.startswith(b"hsqs") and magic != b"DWARFS":
|
||||
return None
|
||||
try:
|
||||
if magic == b"DWARFS":
|
||||
data = _read_dwarfs_icon(path, offset)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["unsquashfs", "-cat", "-o", str(offset), os.path.abspath(path), ".DirIcon"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
data = result.stdout
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read AppImage icon from %s: %s", path, error)
|
||||
return None
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.warning("Cannot extract AppImage icon from %s: %s", path, error)
|
||||
return None
|
||||
image = QImage.fromData(data)
|
||||
if image.isNull():
|
||||
return None
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
if not QImageWriter(buffer, QByteArray(b"PNG")).write(image):
|
||||
return None
|
||||
return io.BytesIO(buffer.data().data())
|
||||
|
||||
|
||||
def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
@@ -133,12 +49,7 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
custom_icon = get_custom_icon_path(exe_path)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
if exe_path.lower().endswith(".appimage"):
|
||||
icon_name += "-" + hashlib.sha256(os.fsencode(os.path.abspath(exe_path))).hexdigest()
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
"images",
|
||||
@@ -149,7 +60,7 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
|
||||
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
|
||||
"""
|
||||
Generates a thumbnail for a Windows executable or type-2 AppImage.
|
||||
Generates a thumbnail for an .exe file.
|
||||
|
||||
inputfile: the input file path
|
||||
outfile: output filename
|
||||
@@ -202,8 +113,6 @@ class IconExtractor:
|
||||
"""
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
if f.read(4) == b"\x7fELF":
|
||||
return _get_appimage_icon(self.file_path)
|
||||
if not self._parse_file(f):
|
||||
return None
|
||||
|
||||
|
||||
@@ -498,7 +498,7 @@ def load_pixmap_async(
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith(("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/")):
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
|
||||
@@ -86,8 +86,9 @@ class MouseEmulationThread(QThread):
|
||||
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
|
||||
}, name="Virtual DPad Mouse")
|
||||
|
||||
self._device = ui
|
||||
self.finished.emit(True)
|
||||
# Store device in thread result
|
||||
self._device = ui
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
|
||||
@@ -213,6 +214,10 @@ class InputManager(
|
||||
|
||||
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
|
||||
|
||||
if self.mouse_emulation_enabled:
|
||||
# Initialize mouse emulation asynchronously to avoid blocking startup
|
||||
QTimer.singleShot(0, self._async_enable_mouse_emulation)
|
||||
|
||||
# FileExplorer specific attributes
|
||||
self.file_explorer = None
|
||||
self.nav_timer = QTimer(self)
|
||||
@@ -260,8 +265,6 @@ class InputManager(
|
||||
|
||||
def _on_mouse_emulation_finished(self, success: bool):
|
||||
"""Handle mouse emulation thread completion."""
|
||||
if not self.mouse_emulation_enabled:
|
||||
return
|
||||
if success and self.mouse_emulation_thread:
|
||||
device = self.mouse_emulation_thread.get_device()
|
||||
if device:
|
||||
@@ -278,22 +281,6 @@ class InputManager(
|
||||
self.emulation_active = self.mouse_emulation_enabled
|
||||
if not self.emulation_active:
|
||||
self.emulation_triggered = False
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
return
|
||||
if self.emulation_triggered:
|
||||
if self.ui is None:
|
||||
self._async_enable_mouse_emulation()
|
||||
elif self.ui is not None:
|
||||
try:
|
||||
self.ui.close()
|
||||
except Exception as e:
|
||||
logger.error("EMUL: Error closing virtual mouse: %s", e)
|
||||
self.ui = None
|
||||
self.mouse_emulation_thread = None
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
self.scroll_accumulator = 0.0
|
||||
|
||||
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
|
||||
"""Common navigation logic for game cards in a container."""
|
||||
@@ -427,10 +414,8 @@ class InputManager(
|
||||
|
||||
# Stop the thread if still running
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread:
|
||||
if thread and thread.isRunning():
|
||||
thread.wait()
|
||||
if self.ui is None:
|
||||
self.ui = thread.get_device()
|
||||
self.mouse_emulation_thread = None
|
||||
|
||||
if self.ui:
|
||||
|
||||
@@ -2,14 +2,12 @@ import time
|
||||
|
||||
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QLineEdit,
|
||||
QListView,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSlider,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
@@ -135,15 +133,10 @@ class ButtonInputMixin(InputMixin):
|
||||
return True
|
||||
|
||||
def _handle_combo_button(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if isinstance(focused, QLineEdit) and focused.isReadOnly():
|
||||
focused = self._find_parent_combo(focused)
|
||||
if not isinstance(focused, QComboBox):
|
||||
return False
|
||||
if button_code in BUTTONS['confirm']:
|
||||
if focused.view().isVisible():
|
||||
self._activate_list_view_selection(focused.view())
|
||||
else:
|
||||
focused.showPopup()
|
||||
focused.showPopup()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and focused.view().isVisible():
|
||||
focused.hidePopup()
|
||||
@@ -167,11 +160,12 @@ class ButtonInputMixin(InputMixin):
|
||||
def _route_button_to_text_input(
|
||||
self, active: QWidget, focused: QWidget | None, button_code: int
|
||||
) -> bool:
|
||||
keyboard = getattr(active, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
if (
|
||||
button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit)
|
||||
and not focused.isReadOnly()
|
||||
):
|
||||
keyboard = (
|
||||
getattr(active, 'keyboard', None)
|
||||
if isinstance(active, AddGameDialog)
|
||||
else getattr(self._parent, 'keyboard', None)
|
||||
)
|
||||
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
|
||||
if keyboard:
|
||||
keyboard.show_for_widget(focused)
|
||||
return True
|
||||
@@ -182,7 +176,7 @@ class ButtonInputMixin(InputMixin):
|
||||
combo = self._find_parent_combo(focused)
|
||||
if combo is not None and combo.isEditable():
|
||||
line_edit = combo.lineEdit()
|
||||
if line_edit is not None and not line_edit.isReadOnly() and keyboard:
|
||||
if line_edit is not None and keyboard:
|
||||
line_edit.setFocus()
|
||||
keyboard.show_for_widget(line_edit)
|
||||
return True
|
||||
@@ -202,7 +196,7 @@ class ButtonInputMixin(InputMixin):
|
||||
parent = parent.parentWidget()
|
||||
return None
|
||||
|
||||
def _activate_list_view_selection(self, view: QAbstractItemView) -> None:
|
||||
def _activate_list_view_selection(self, view: QListView) -> None:
|
||||
index = view.currentIndex()
|
||||
if not index.isValid():
|
||||
return
|
||||
@@ -211,7 +205,6 @@ class ButtonInputMixin(InputMixin):
|
||||
combo.setCurrentIndex(index.row())
|
||||
combo.hidePopup()
|
||||
combo.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
combo.activated.emit(index.row())
|
||||
return
|
||||
view.activated.emit(index)
|
||||
view.clicked.emit(index)
|
||||
@@ -253,11 +246,6 @@ class ButtonInputMixin(InputMixin):
|
||||
return False
|
||||
|
||||
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
|
||||
if button_code in BUTTONS['add_game'] and isinstance(active, QDialog):
|
||||
action = active.findChild(QPushButton, "gamepadAddButton")
|
||||
if action is not None and action.isVisible() and action.isEnabled():
|
||||
action.click()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
|
||||
active.reject()
|
||||
return True
|
||||
|
||||
@@ -317,7 +317,10 @@ class DpadInputMixin(InputMixin):
|
||||
|
||||
def _handle_default_dpad(self, code: int, value: int, current_time: float) -> None:
|
||||
active_window = QApplication.activeWindow()
|
||||
keyboard = getattr(active_window, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
if isinstance(active_window, AddGameDialog):
|
||||
keyboard = getattr(active_window, 'keyboard', None)
|
||||
else:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
|
||||
if value == 0:
|
||||
self._reset_dpad_repeat()
|
||||
|
||||
@@ -138,13 +138,9 @@ class FileExplorerInputMixin(InputMixin):
|
||||
current_item = self.file_explorer.file_list.currentItem()
|
||||
if current_item:
|
||||
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
|
||||
self.file_explorer.show_context_menu(item_rect.center())
|
||||
self.file_explorer.show_folder_context_menu(item_rect.center())
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X button
|
||||
if self._handle_active_window_button(self.file_explorer, button_code):
|
||||
return
|
||||
if not self.file_explorer.directory_only:
|
||||
return
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
return
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import cast
|
||||
|
||||
from shiboken6 import isValid
|
||||
from PySide6.QtCore import QEvent, QObject, QPoint, Qt
|
||||
from PySide6.QtGui import QKeyEvent, QKeySequence
|
||||
from PySide6.QtGui import QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QApplication,
|
||||
@@ -93,6 +93,10 @@ class KeyboardInputMixin(InputMixin):
|
||||
keyboard.stop_backspace_repeat()
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
if event.type() == QEvent.Type.Show:
|
||||
if isinstance(obj, QMenu):
|
||||
SoundManager().play("open")
|
||||
@@ -130,7 +134,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if isinstance(event, QKeyEvent) and self._redirect_gamecard_input_to_search(event):
|
||||
if self._redirect_gamecard_input_to_search(event):
|
||||
return True
|
||||
key = self._qt_event_to_input_key(event)
|
||||
if key is not None:
|
||||
@@ -158,7 +162,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
|
||||
return False
|
||||
|
||||
return False
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
|
||||
native_key = self._native_scan_to_input_key(event)
|
||||
@@ -235,9 +239,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
1: 'autoInstallSearchLineEdit',
|
||||
}
|
||||
|
||||
def _redirect_gamecard_input_to_search(self, event: QKeyEvent) -> bool:
|
||||
if event.key() == Qt.Key.Key_Space:
|
||||
return False
|
||||
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
|
||||
focused = self._focused_widget()
|
||||
if not isinstance(focused, GameCard):
|
||||
return False
|
||||
|
||||
@@ -184,7 +184,10 @@ class SettingsInputMixin(InputMixin):
|
||||
return
|
||||
|
||||
# 4. Global Shortcuts
|
||||
if button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
if button_code in BUTTONS['add_game']: # X: Apply
|
||||
self.settings_dialog.apply_changes()
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
focused = QApplication.focusWidget()
|
||||
focused_combo = None
|
||||
if isinstance(focused, QTableWidget) and self.settings_dialog:
|
||||
@@ -339,14 +342,12 @@ class SettingsInputMixin(InputMixin):
|
||||
combo_tables = (
|
||||
self.settings_dialog.advanced_table,
|
||||
self.settings_dialog.settings_table,
|
||||
getattr(self.settings_dialog, "favorites_table", None),
|
||||
)
|
||||
if self.settings_dialog and table in combo_tables and table.currentRow() >= 0:
|
||||
cell_widget = table.cellWidget(table.currentRow(), 1)
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
view = cell_widget.view()
|
||||
idx = view.currentIndex().row()
|
||||
idx = cell_widget.currentIndex()
|
||||
step = -1 if value < 0 else 1
|
||||
model = cell_widget.model()
|
||||
end = -1 if step < 0 else cell_widget.count()
|
||||
@@ -354,8 +355,7 @@ class SettingsInputMixin(InputMixin):
|
||||
row for row in range(idx + step, end, step)
|
||||
if model.flags(model.index(row, 0)) & Qt.ItemFlag.ItemIsEnabled
|
||||
), idx)
|
||||
view.setCurrentIndex(model.index(new_idx, 0))
|
||||
view.scrollTo(model.index(new_idx, 0), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
cell_widget.setCurrentIndex(new_idx)
|
||||
return # Consume event
|
||||
|
||||
# 3. Standard Table Navigation
|
||||
@@ -476,14 +476,7 @@ class SettingsInputMixin(InputMixin):
|
||||
if table != self.settings_dialog.advanced_table:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
entry_widget = table.cellWidget(row, 0)
|
||||
if isinstance(entry_widget, QLineEdit):
|
||||
entry_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, QPushButton):
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if isinstance(cell_widget, QComboBox):
|
||||
line_edit = cell_widget.lineEdit()
|
||||
if cell_widget.isEditable() and line_edit is not None:
|
||||
@@ -499,33 +492,15 @@ class SettingsInputMixin(InputMixin):
|
||||
and cell_widget.property("ppqt_run_after_exe_widget")
|
||||
):
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
if line_edit and line_edit.isEnabled() and not line_edit.isHidden():
|
||||
if line_edit and line_edit.isEnabled():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
if button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
|
||||
"""Move focus between inline line edit and button in settings cell."""
|
||||
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
|
||||
return False
|
||||
focused = QApplication.focusWidget()
|
||||
for row in range(table.rowCount()):
|
||||
entry_widget = table.cellWidget(row, 0)
|
||||
delete_button = table.cellWidget(row, 1)
|
||||
if not isinstance(entry_widget, QLineEdit) or not isinstance(delete_button, QPushButton):
|
||||
continue
|
||||
if focused not in (entry_widget, delete_button):
|
||||
continue
|
||||
widget = delete_button if focused == entry_widget else entry_widget
|
||||
if not widget.isEnabled():
|
||||
return False
|
||||
table.setCurrentCell(row, 1)
|
||||
widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if table.currentColumn() != 1:
|
||||
return False
|
||||
|
||||
@@ -533,14 +508,15 @@ class SettingsInputMixin(InputMixin):
|
||||
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
|
||||
return False
|
||||
|
||||
widgets = [cell_widget.findChild(QLineEdit), *cell_widget.findChildren(QPushButton)]
|
||||
widgets = [widget for widget in widgets if widget and widget.isEnabled() and not widget.isHidden()]
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
focused = QApplication.focusWidget()
|
||||
if focused in widgets:
|
||||
index = widgets.index(focused) + (1 if value > 0 else -1)
|
||||
if 0 <= index < len(widgets):
|
||||
widgets[index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value > 0 and focused == line_edit and button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _focus_first_row_in_current_settings_table(self):
|
||||
|
||||
@@ -22,22 +22,14 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.5\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr ""
|
||||
|
||||
@@ -119,6 +111,12 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr ""
|
||||
@@ -144,15 +142,9 @@ msgstr ""
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -277,6 +269,14 @@ msgstr ""
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -294,6 +294,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -304,6 +308,10 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -463,16 +471,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -888,22 +886,6 @@ msgstr ""
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
@@ -919,9 +901,6 @@ msgstr ""
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr ""
|
||||
|
||||
@@ -934,30 +913,6 @@ msgstr ""
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "Select games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr ""
|
||||
|
||||
@@ -973,13 +928,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1003,6 +952,9 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1144,6 +1096,9 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1231,6 +1186,9 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr ""
|
||||
|
||||
@@ -2159,6 +2117,9 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2231,9 +2192,6 @@ msgid ""
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2297,9 +2255,6 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2520,10 +2475,16 @@ msgstr ""
|
||||
msgid "Failed to download theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"PO-Revision-Date: 2026-10-12 19:09+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -21,24 +21,14 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Spiel beenden"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr "Kein Lese/Schreibzugriff auf den Ordner. Wähle anderen Benutzerordner."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"PortProtonQt läuft bereits. Möchtest du es sowie alle Spiele beenden und "
|
||||
"anschließend PortProtonQt neu starten?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Nein"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Aktualisierung abgeschlossen"
|
||||
|
||||
@@ -124,6 +114,12 @@ msgstr ""
|
||||
"Soll der Ordner '{folder_name}' wirklich gelöscht werden? Dieser Vorgang ist "
|
||||
"unwiderruflich."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Nein"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Ordner konnte nicht gelöscht werden: {error}"
|
||||
@@ -151,15 +147,9 @@ msgstr "Die Datei existiert bereits: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Datei konnte nicht umbenannt werden: {error}"
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr "Mehrere auswählen"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Von PortProton löschen"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Spiel beenden"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Spiel starten"
|
||||
|
||||
@@ -289,6 +279,14 @@ msgstr "Fehler beim Generieren der .desktop-Datei"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Die alte .desktop-Datei für '{game_name}' wurde entfernt"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
|
||||
@@ -308,6 +306,10 @@ msgstr ""
|
||||
"'{game_name}' wurde zu Steam hinzugefügt. Bitte starte Steam neu, damit die "
|
||||
"Änderungen wirksam werden."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' wurde zu Steam hinzugefügt."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Fehler beim Hinzufügen von '{game_name}' zu Steam: {error}"
|
||||
@@ -320,6 +322,10 @@ msgstr ""
|
||||
"'{game_name}' wurde von Steam entfernt. Bitte starte Steam neu, damit die "
|
||||
"Änderungen wirksam werden."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' wurde von Steam entfernt."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Das Spiel '{game_name}' konnte nicht von Steam entfernt werden: {error}"
|
||||
@@ -477,20 +483,6 @@ msgstr "Eine weitere Epic-Operation läuft"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Wine-Prefix nicht gefunden"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"In den Pfaden wurden kyrillische Zeichen gefunden. Wine funktioniert unter "
|
||||
"Umständen nicht zuverlässig. Verschiebe das Spiel oder PortProton an einen "
|
||||
"Pfad, der keine kyrillischen Zeichen enthält:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "Nicht erneut erinnern"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
|
||||
@@ -966,22 +958,6 @@ msgstr "AppImage aktualisieren..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Start {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "Scannen: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "Überprüfung vorhandener Verknüpfungen: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "Hinzufügen: {name}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Verknüpfung konnte nicht erstellt werden: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Titel:"
|
||||
|
||||
@@ -997,10 +973,6 @@ msgstr "Eigenes Titelbild:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Lokaler Pfad oder die URL für das Titelbild"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Eigenes Titelbild:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Verknüpfung hinzufügen zu:"
|
||||
|
||||
@@ -1013,32 +985,6 @@ msgstr "Anwenden"
|
||||
msgid "Invalid image"
|
||||
msgstr "Ungültiges Bild"
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr "Ordner auswählen"
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr "Spiele werden verarbeitet…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Verarbeitet {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "Im ausgewählten Ordner wurden keine Spiele gefunden."
|
||||
|
||||
msgid "Select games"
|
||||
msgstr "Spiele auswählen"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Herunterladen des Titelbildes fehlgeschlagen"
|
||||
|
||||
@@ -1056,14 +1002,8 @@ msgstr ""
|
||||
"Das Programm wurde beendet. Kompatibilitätsdetails wurden automatisch "
|
||||
"erfasst."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
@@ -1086,6 +1026,9 @@ msgstr "Vorheriger Tab"
|
||||
msgid "Next Tab"
|
||||
msgstr "Nächster Tab"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Suchen"
|
||||
|
||||
@@ -1233,6 +1176,9 @@ msgstr "WINE/Proton konnte nicht entfernt werden in {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "{} WINE/Proton erfolgreich entfernt."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Alle ausgewählten WINE/Proton-Versionen wurden heruntergeladen!"
|
||||
|
||||
@@ -1320,6 +1266,9 @@ msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Datei konnte nicht geöffnet werden:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "PPDB-Zurücksetzung bestätigen"
|
||||
|
||||
@@ -2266,6 +2215,9 @@ msgstr "Fehlgeschlagen"
|
||||
msgid "Repaired"
|
||||
msgstr "Repariert"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Schnellstart"
|
||||
|
||||
@@ -2340,9 +2292,6 @@ msgstr ""
|
||||
"Sollen '{count}' Titelbidler ohne ausführbaren Dateien wirklich gelöscht "
|
||||
"werden? Dabei werden Benutzerdaten und .desktop-Dateien gelöscht."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Installation des Themes fehlgeschlagen"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton-Einstellungen"
|
||||
|
||||
@@ -2406,9 +2355,6 @@ msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "Warnung bei kyrillischen Pfaden deaktivieren"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
|
||||
|
||||
@@ -2633,9 +2579,15 @@ msgstr "Laden der Themes fehlgeschlagen"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Herunterladen des Themes fehlgeschlagen"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Herunterladen… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Installation des Themes fehlgeschlagen"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Theme auswählen:"
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Detener Juego"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
@@ -28,17 +31,6 @@ msgstr ""
|
||||
"La carpeta de datos de PortProton no tiene permisos de lectura ni escritura. "
|
||||
"Elija otra carpeta para los datos de PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Actualización completada"
|
||||
|
||||
@@ -124,6 +116,12 @@ msgstr ""
|
||||
"¿Estás seguro de que quieres eliminar la carpeta «{folder_name}»? Esta "
|
||||
"acción no se puede deshacer."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "No se ha podido eliminar la carpeta: {error}"
|
||||
@@ -151,16 +149,9 @@ msgstr "El archivo ya existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "No se pudo cambiar el nombre del archivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Seleccionar todo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Eliminar de PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Detener Juego"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar juego"
|
||||
|
||||
@@ -290,6 +281,14 @@ msgstr "No se han podido generar los datos del archivo .desktop"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "No se ha podido guardar el archivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Se ha eliminado el antiguo archivo .desktop de «{game_name}»"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "No se ha podido copiar la imagen de portada: {error}"
|
||||
@@ -309,6 +308,10 @@ msgstr ""
|
||||
"Se ha añadido «{game_name}» a Steam. Reinicia Steam para que los cambios "
|
||||
"surtan efecto."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "«{game_name}» se ha añadido a Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "No se ha podido añadir «{game_name}» a Steam: {error}"
|
||||
@@ -321,6 +324,10 @@ msgstr ""
|
||||
"«{game_name}» se ha eliminado de Steam. Por favor, reinicia Steam para que "
|
||||
"los cambios surtan efecto."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "«{game_name}» se ha eliminado de Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "No se ha podido eliminar el juego «{game_name}» de Steam: {error}"
|
||||
@@ -481,16 +488,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefijo no encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Juego no encontrado."
|
||||
|
||||
@@ -980,22 +977,6 @@ msgstr "Actualizando AppImage…"
|
||||
msgid "Launching {0}"
|
||||
msgstr "Comenzando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "No se ha podido crear la carpeta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nombre:"
|
||||
|
||||
@@ -1011,10 +992,6 @@ msgstr "Portada personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Ingrese la ruta local o la URL de la imagen de portada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Portada personalizada:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Agregar acceso directo a:"
|
||||
|
||||
@@ -1027,33 +1004,6 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagen no válida"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Borrar la carpeta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "No se ha podido descargar la portada"
|
||||
|
||||
@@ -1069,14 +1019,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1100,6 +1043,9 @@ msgstr "Pestaña anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Pestaña siguiente"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Guardar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
@@ -1245,6 +1191,9 @@ msgstr "No se pudo eliminar WINE/Proton en {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1335,6 +1284,9 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "No se ha podido abrir la carpeta: {error}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar que PPDB está vacío"
|
||||
|
||||
@@ -2274,6 +2226,10 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2351,9 +2307,6 @@ msgstr ""
|
||||
"¿Estás seguro de que quieres eliminar «{game_name}»? Esto eliminará el "
|
||||
"archivo .desktop y los datos personalizados."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Error al instalar el tema"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2417,9 +2370,6 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2642,9 +2592,15 @@ msgstr "Error al cargar los temas"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Error al descargar los temas"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Descargando… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Error al instalar el tema"
|
||||
|
||||
msgid "Select Theme:"
|
||||
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-10-24 18:13+0500\n"
|
||||
"POT-Creation-Date: 2026-10-11 01:04+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"
|
||||
@@ -18,22 +18,14 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr ""
|
||||
|
||||
@@ -115,6 +107,12 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr ""
|
||||
@@ -140,15 +138,9 @@ msgstr ""
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -273,6 +265,14 @@ msgstr ""
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -290,6 +290,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -300,6 +304,10 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -459,16 +467,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -884,22 +882,6 @@ msgstr ""
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
@@ -915,9 +897,6 @@ msgstr ""
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr ""
|
||||
|
||||
@@ -930,30 +909,6 @@ msgstr ""
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "Select games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr ""
|
||||
|
||||
@@ -969,13 +924,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -999,6 +948,9 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1140,6 +1092,9 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1227,6 +1182,9 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr ""
|
||||
|
||||
@@ -2155,6 +2113,9 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2227,9 +2188,6 @@ msgid ""
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2293,9 +2251,6 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2517,10 +2472,16 @@ msgstr ""
|
||||
msgid "Failed to download theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-10-17 23:27+0000\n"
|
||||
"PO-Revision-Date: 2026-09-24 06:40+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"
|
||||
@@ -18,9 +18,12 @@ 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.10.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Parar o Jogo"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
@@ -28,17 +31,6 @@ msgstr ""
|
||||
"A pasta de dados do PortProton não pode ser lida nem gravada. Escolha "
|
||||
"outra pasta para os dados do PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Atualização concluída"
|
||||
|
||||
@@ -122,6 +114,12 @@ msgstr ""
|
||||
"Tem certeza de que deseja excluir a pasta '{folder_name}'? Esta ação não "
|
||||
"pode ser desfeita."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Falha ao excluir a pasta: {error}"
|
||||
@@ -149,16 +147,9 @@ msgstr "A pasta já existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Selecionar Tudo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Parar o Jogo"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
|
||||
@@ -288,6 +279,14 @@ msgstr "Falha ao gerar os dados do arquivo .desktop"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Falha ao salvar o arquivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Arquivo .desktop antigo de '{game_name}' removido"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Falha ao copiar a imagem de capa: {error}"
|
||||
@@ -307,6 +306,10 @@ msgstr ""
|
||||
"'{game_name}' foi adicionado à Steam. Reinicie a Steam para aplicar as "
|
||||
"alterações."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' foi adicionado à Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Falha ao adicionar '{game_name}' à Steam: {error}"
|
||||
@@ -319,6 +322,10 @@ msgstr ""
|
||||
"'{game_name}' foi removido da Steam. Reinicie à Steam para que as "
|
||||
"alterações tenham efeito."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' foi removido da Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Falha ao remover o jogo '{game_name}' da Steam: {error}"
|
||||
@@ -475,16 +482,6 @@ msgstr "Outra operação da Epic está em andamento"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefixo do Wine não encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
@@ -962,22 +959,6 @@ msgstr "Atualizando AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar a pasta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
|
||||
@@ -993,10 +974,6 @@ msgstr "Capa Personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Informe o caminho local ou a URL da imagem de capa"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Capa Personalizada:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Adicionar atalho em:"
|
||||
|
||||
@@ -1009,33 +986,6 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Falha ao baixar a capa"
|
||||
|
||||
@@ -1053,14 +1003,8 @@ msgstr ""
|
||||
"O programa foi encerrado. As informações de compatibilidade foram coletadas "
|
||||
"automaticamente."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
@@ -1083,6 +1027,9 @@ msgstr "Aba Anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Próxima Aba"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Salvar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Pesquisar"
|
||||
|
||||
@@ -1230,6 +1177,9 @@ msgstr "Falha ao remover WINE/Proton em {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "{} WINE/Proton removido(s) com sucesso."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Todos os WINE/Proton selecionados foram baixados!"
|
||||
|
||||
@@ -1268,14 +1218,15 @@ msgstr "O download ou a extração foi cancelado."
|
||||
msgid "System WINE"
|
||||
msgstr "WINE do Sistema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Configurações globais dos jogos"
|
||||
msgstr "Salvar Configurações"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Configurações do Exe"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr "Editar o arquivo user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Pesquisar configurações…"
|
||||
@@ -1301,8 +1252,9 @@ msgstr "Editar PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Limpar PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Falha ao carregar as configurações globais dos jogos."
|
||||
msgstr "Falha ao carregar temas"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "O caminho do executável não está disponível."
|
||||
@@ -1313,8 +1265,12 @@ msgstr "O arquivo PPDB não existe em: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Falha ao abrir o arquivo:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar Limpeza do PPDB"
|
||||
@@ -1336,8 +1292,9 @@ msgstr "Padrão"
|
||||
msgid "disabled"
|
||||
msgstr "desativado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Baixar Wine/Proton..."
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Digite o nome do prefixo"
|
||||
@@ -2178,7 +2135,7 @@ msgid "GOG account connected"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
msgid "Log out"
|
||||
msgstr "Sair da sessão"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Abrir página de login"
|
||||
@@ -2260,6 +2217,9 @@ msgstr "Falhou"
|
||||
msgid "Repaired"
|
||||
msgstr "Reparado"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Início Rápido"
|
||||
|
||||
@@ -2299,14 +2259,16 @@ msgstr "Oculto"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Modo de Exibição do Selo:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
msgstr "Temas"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "Grade"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Lista"
|
||||
msgstr "Claro"
|
||||
|
||||
msgid "Vertical"
|
||||
msgstr "Vertical"
|
||||
@@ -2334,9 +2296,6 @@ msgstr ""
|
||||
"Tem certeza de que deseja excluir {count} cartões sem executável? Isso "
|
||||
"removerá os arquivos .desktop e os dados personalizados."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Falha ao instalar o tema"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Configurações do PortProton"
|
||||
|
||||
@@ -2367,14 +2326,15 @@ msgstr "Tipo de Controle:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Iniciar Aplicativo em Tela Cheia"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Ocultar o cursor em tela cheia"
|
||||
msgstr "Forçar janelas em tela cheia"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Tela Cheia Automática ao Conectar o Controle"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "Desativar animação de inicialização"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Minimizar para a bandeja ao fechar"
|
||||
@@ -2400,11 +2360,8 @@ msgstr "Ativar sons da interface"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
@@ -2413,7 +2370,7 @@ msgid "Add to Steam compatibility tools"
|
||||
msgstr "Adicionar às ferramentas de compatibilidade da Steam"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "Desativar a busca por instalações do Steam Proton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
@@ -2625,9 +2582,15 @@ msgstr "Falha ao carregar temas"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Falha ao baixar o tema"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Baixando… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Falha ao instalar o tema"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Selecionar Tema:"
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"PO-Revision-Date: 2026-10-09 11:06+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"
|
||||
@@ -19,9 +19,12 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Остановить игру"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
@@ -29,19 +32,6 @@ msgstr ""
|
||||
"Папка с данными PortProton не доступна для чтения и записи. Выберите "
|
||||
"другую папку для данных PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"PortProtonQt уже запущен. Остановить его и все игры, а затем перезапустить "
|
||||
"для нового запуска?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Нет"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Обновление завершено"
|
||||
|
||||
@@ -125,6 +115,12 @@ msgstr ""
|
||||
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Нет"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Не удалось удалить папку: {error}"
|
||||
@@ -151,15 +147,9 @@ msgstr "Файл уже существует: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Не удалось переименовать файл: {error}"
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr "Выбрать несколько"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Удалить из PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Остановить игру"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Запустить игру"
|
||||
|
||||
@@ -287,6 +277,14 @@ msgstr "Не удалось сгенерировать данные файла .
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Не удалось удалить старый файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Удалён старый файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Не удалось скопировать обложку: {error}"
|
||||
@@ -306,6 +304,10 @@ msgstr ""
|
||||
"'{game_name}' добавлен(а) в Steam. Пожалуйста, перезапустите Steam, чтобы"
|
||||
" изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' добавлен(а) в Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
@@ -318,6 +320,10 @@ msgstr ""
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
|
||||
" чтобы изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' был(а) удален(а) из Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Не удалось удалить игру '{game_name}' из Steam: {error}"
|
||||
@@ -474,19 +480,6 @@ msgstr "Другая операция Epic уже выполняется"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Префикс Wine не найден"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"В следующих путях обнаружены символы кириллицы. Wine может работать "
|
||||
"неправильно. Переместите игру или PortProton в путь без символов кириллицы:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "Больше не напоминать"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
|
||||
@@ -754,14 +747,14 @@ msgstr ""
|
||||
"run --user --scope --slice=app.slice"
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr "Ограничение ядер ЦП"
|
||||
msgstr "Ограничение ядер CPU"
|
||||
|
||||
msgid ""
|
||||
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||
"recommended to set the value equal to 8)"
|
||||
msgstr ""
|
||||
"Ограничение количества ядер процессора полезно для игр на Unity "
|
||||
"(рекомендуется установить значение равным 8)"
|
||||
"Ограничение количества ядер CPU полезно для игр на Unity (рекомендуется "
|
||||
"установить значение равным 8)"
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr "Версия OpenGL"
|
||||
@@ -965,22 +958,6 @@ msgstr "Обновление AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Запуск {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "Сканирование: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "Проверка существующих ярлыков: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "Добавление: {name}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Не удалось создать ярлык: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Название:"
|
||||
|
||||
@@ -996,10 +973,6 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательская обложка:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -1012,32 +985,6 @@ msgstr "Применить"
|
||||
msgid "Invalid image"
|
||||
msgstr "Недопустимое изображение"
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr "Выбрать папку"
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr "Обработка игр…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Обработано {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "В выбранной папке игры не найдены."
|
||||
|
||||
msgid "Select games"
|
||||
msgstr "Выбрать игры"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Не удалось скачать обложку"
|
||||
|
||||
@@ -1055,14 +1002,8 @@ msgstr ""
|
||||
"Программа остановилась. Информация о совместимости была собрана "
|
||||
"автоматически."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
@@ -1085,6 +1026,9 @@ msgstr "Предыдущая вкладка"
|
||||
msgid "Next Tab"
|
||||
msgstr "Следующая вкладка"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
@@ -1232,6 +1176,9 @@ msgstr "Не удалось удалить WINE/Proton в {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "Успешно удалено {} WINE/Proton."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Все выбранные WINE/Proton успешно загружены!"
|
||||
|
||||
@@ -1318,6 +1265,9 @@ msgstr "Не удалось открыть файл PPDB:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Не удалось открыть файл:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Подтвердите очистку PPDB"
|
||||
|
||||
@@ -1597,7 +1547,7 @@ msgid "Failed to save custom preset."
|
||||
msgstr "Ошибка сохранения пользовательских настроек."
|
||||
|
||||
msgid "Overlay position"
|
||||
msgstr "Положение MangoHud"
|
||||
msgstr "Позиция MangoHud"
|
||||
|
||||
msgid "Device battery"
|
||||
msgstr "Батарея устройства"
|
||||
@@ -1612,7 +1562,7 @@ msgid "FCAT screen edge"
|
||||
msgstr "Край области FCAT"
|
||||
|
||||
msgid "Table columns"
|
||||
msgstr "Столбцов таблицы"
|
||||
msgstr "Кол-во колонок"
|
||||
|
||||
msgid "Network (tx/rx kb/s)"
|
||||
msgstr "Сеть (tx/rx kb/s)"
|
||||
@@ -1981,8 +1931,9 @@ msgid ""
|
||||
"Select one or more FPS presets. The values are saved into FPS_LIMIT and "
|
||||
"MangoHud will also show the active FPS limit."
|
||||
msgstr ""
|
||||
"Выберите один или несколько предустановленных профилей FPS. Значения будут "
|
||||
"сохранены в FPS_LIMIT, а также MangoHud отобразит активное ограничение FPS."
|
||||
"Выберите один или несколько предустановленных профилей FPS. Значения "
|
||||
"будут сохранены в FPS_LIMIT и MangoHud также отобразит активное "
|
||||
"ограничение FPS."
|
||||
|
||||
msgid "Additional comma-separated MangoHud options not covered by the GUI."
|
||||
msgstr "Дополнительные параметры MangoHud (через запятую), отсутствующие в GUI."
|
||||
@@ -2256,6 +2207,9 @@ msgstr "Ошибка"
|
||||
msgid "Repaired"
|
||||
msgstr "Исправлено"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
@@ -2330,9 +2284,6 @@ msgstr ""
|
||||
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
|
||||
"приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProtonQt"
|
||||
|
||||
@@ -2396,9 +2347,6 @@ msgstr "Включить звуки интерфейса"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Показывать отчёт о совместимости после сбоя игры"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "Отключить предупреждение о кириллице в пути"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Отключить проверку зависимостей ALT Linux i586"
|
||||
|
||||
@@ -2619,9 +2567,15 @@ msgstr "Не удалось загрузить темы"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Не удалось скачать тему"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Скачивание… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Выбрать тему:"
|
||||
|
||||
|
||||
@@ -21,22 +21,14 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择其他文件夹。"
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "更新完成"
|
||||
|
||||
@@ -118,6 +110,12 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr "确定要删除文件夹\"{folder_name}\"吗?此操作无法撤销。"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "删除文件夹失败:{error}"
|
||||
@@ -143,16 +141,9 @@ msgstr "文件已存在:{file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "全选"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
@@ -282,6 +273,14 @@ msgstr "生成 .desktop 文件数据失败"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "保存 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "删除旧的 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "已移除\"{game_name}\"的旧版 .desktop 文件"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "复制封面图像失败:{error}"
|
||||
@@ -299,6 +298,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr "\"{game_name}\"已添加到 Steam。请重启 Steam 以使更改生效。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "\"{game_name}\"已添加到 Steam。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "无法将\"{game_name}\"添加到 Steam:{error}"
|
||||
@@ -309,6 +312,10 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr "\"{game_name}\"已从 Steam 中移除。请重启 Steam 以使更改生效。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "\"{game_name}\"已从 Steam 中移除。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "无法从 Steam 中移除游戏\"{game_name}\":{error}"
|
||||
@@ -469,16 +476,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "未找到前缀"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
@@ -928,22 +925,6 @@ msgstr "正在更新 AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "正在启动 {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "创建文件夹失败:{error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
|
||||
@@ -959,10 +940,6 @@ msgstr "自定义封面:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "输入封面图像的本地路径或 URL"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "自定义封面:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "添加快捷方式到:"
|
||||
|
||||
@@ -975,34 +952,6 @@ msgstr "应用"
|
||||
msgid "Invalid image"
|
||||
msgstr "图像无效"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "下载封面失败"
|
||||
|
||||
@@ -1018,15 +967,9 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
@@ -1049,6 +992,9 @@ msgstr "上一标签页"
|
||||
msgid "Next Tab"
|
||||
msgstr "下一标签页"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "保存"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "搜索"
|
||||
|
||||
@@ -1196,6 +1142,9 @@ msgstr "移除位于 {path} 的 WINE/Proton 失败:{error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "已成功移除 {} 个 WINE/Proton。"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "所有选定的 WINE/Proton 已下载完成!"
|
||||
|
||||
@@ -1285,6 +1234,9 @@ msgstr "打开 PPDB 文件失败:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
|
||||
@@ -2235,6 +2187,10 @@ msgstr "详细"
|
||||
msgid "Repaired"
|
||||
msgstr "已配对"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "快速启动"
|
||||
|
||||
@@ -2317,9 +2273,6 @@ msgstr ""
|
||||
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
|
||||
"据。"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton 设置"
|
||||
|
||||
@@ -2385,9 +2338,6 @@ msgstr "启用界面音效"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2610,9 +2560,15 @@ msgstr "加载主题失败"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "下载主题失败"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "正在下载… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "选择主题:"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import configparser
|
||||
from pathlib import Path
|
||||
import locale
|
||||
import os
|
||||
from babel import Locale, UnknownLocaleError
|
||||
from babel import Locale
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -85,18 +85,8 @@ def get_system_locale():
|
||||
|
||||
if ui_config.get_force_english():
|
||||
return 'en'
|
||||
for language in os.getenv("LANGUAGE", "").split(":"):
|
||||
if not language:
|
||||
continue
|
||||
try:
|
||||
return str(Locale.parse(language))
|
||||
except (UnknownLocaleError, ValueError):
|
||||
continue
|
||||
try:
|
||||
loc = locale.getlocale()[0]
|
||||
return str(Locale.parse(loc)) if loc else 'en'
|
||||
except (UnknownLocaleError, ValueError):
|
||||
return 'en'
|
||||
loc = locale.getlocale()[0]
|
||||
return loc if loc else 'en'
|
||||
|
||||
def get_steam_language():
|
||||
try:
|
||||
|
||||
+41
-248
@@ -7,7 +7,6 @@ import shlex
|
||||
import subprocess
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
@@ -18,7 +17,6 @@ import psutil
|
||||
import orjson
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
@@ -29,7 +27,6 @@ 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.egs_api import EGSAPI
|
||||
from portprotonqt.native_games import NativeLibraryWorker, NATIVE_URI_PREFIX, get_native_launch, read_native_game
|
||||
from portprotonqt.debug_utils import get_prefix_name
|
||||
from portprotonqt.input_manager import InputManager, MainWindowProtocol
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
@@ -93,7 +90,6 @@ from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
STORE_LAUNCH_GRACE_SECONDS = 10
|
||||
GAME_PROCESS_CHECK_INTERVAL_MS = 500
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.appimage_updater import AppImageUpdateWorker
|
||||
@@ -119,7 +115,7 @@ class MainWindow(
|
||||
QMainWindow,
|
||||
):
|
||||
games_loaded = Signal(list)
|
||||
compatibility_report_ready = Signal(str, str)
|
||||
compatibility_report_ready = Signal(str)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
@@ -1038,7 +1034,7 @@ class MainWindow(
|
||||
self.games_loaded.emit(games)
|
||||
|
||||
# Load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
|
||||
def check_completion():
|
||||
@@ -1068,24 +1064,14 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
def native_callback(games: list[tuple]) -> None:
|
||||
results['native'] = games
|
||||
completed['native'] = True
|
||||
check_completion()
|
||||
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
self._load_native_games_async(native_callback)
|
||||
else:
|
||||
# Load selected sources in parallel.
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
|
||||
selected_sources = set(display_filter.split(","))
|
||||
completed = {
|
||||
source: bool(selected_sources & results.keys()) and source not in selected_sources
|
||||
for source in results
|
||||
}
|
||||
# For 'all' filter - load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
|
||||
def on_all_games():
|
||||
seen = set()
|
||||
@@ -1127,106 +1113,15 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
def native_callback(games: list[tuple]) -> None:
|
||||
results['native'] = games
|
||||
completed['native'] = True
|
||||
check_completion()
|
||||
|
||||
callbacks = {
|
||||
"portproton": portproton_callback, "steam": steam_callback,
|
||||
"gog": gog_callback, "egs": egs_callback, "native": native_callback,
|
||||
}
|
||||
for source, callback in callbacks.items():
|
||||
if not completed[source]:
|
||||
getattr(self, f"_load_{source}_games_async")(callback)
|
||||
# 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)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
|
||||
def _load_native_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
self._native_games_callback = callback
|
||||
self.native_library_worker = NativeLibraryWorker()
|
||||
self.native_library_worker.finished.connect(self._on_native_games_loaded)
|
||||
self.native_library_worker.start()
|
||||
|
||||
def open_native_settings(self, exec_line: str) -> None:
|
||||
desktop_path = exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
try:
|
||||
dialog = ExeSettingsDialog(self, self.theme, desktop_path, game_source="native")
|
||||
except OSError as error:
|
||||
logger.warning("Cannot open native settings for %s: %s", desktop_path, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
dialog.exec()
|
||||
dialog.deleteLater()
|
||||
|
||||
def _launch_native_game(self, exec_line: str, button: QPushButton | None = None) -> None:
|
||||
desktop_path = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
if self.game_processes:
|
||||
if getattr(self, "native_game_running", False) and self.game_start_exe == str(desktop_path):
|
||||
self.stop_running_game(button)
|
||||
else:
|
||||
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||
return
|
||||
try:
|
||||
if not desktop_path.is_absolute() or desktop_path.suffix != ".desktop":
|
||||
raise ValueError("Invalid native desktop path")
|
||||
command, directory = get_native_launch(str(desktop_path))
|
||||
process = subprocess.Popen(command, cwd=directory, start_new_session=True, text=True)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to launch native game %s: %s", desktop_path, error)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(error))
|
||||
return
|
||||
self.native_game_running = True
|
||||
self.game_stopped_by_user = False
|
||||
self.game_processes = [process]
|
||||
self.current_running_button = button
|
||||
self.target_exe = desktop_path.name
|
||||
self.game_start_exe = str(desktop_path)
|
||||
self.game_start_exact_path = True
|
||||
self.game_start_time = datetime.now()
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
self.game_launch_started = True
|
||||
self.wine_download_seen = False
|
||||
save_last_launch(desktop_path.stem, self.game_start_time)
|
||||
self._update_last_launch_after_start(str(desktop_path), self.game_start_time)
|
||||
self.launch_output_queue = Queue()
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self._set_running_button_stop()
|
||||
SoundManager().play("game_launch")
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||
self.checkProcessTimer.start(GAME_PROCESS_CHECK_INTERVAL_MS)
|
||||
|
||||
@Slot()
|
||||
def _on_native_games_loaded(self) -> None:
|
||||
worker = self.native_library_worker
|
||||
if worker is None:
|
||||
return
|
||||
self.native_library_worker = None
|
||||
games = worker.games
|
||||
worker.deleteLater()
|
||||
if not games:
|
||||
self._native_games_callback([])
|
||||
return
|
||||
completed = []
|
||||
|
||||
def on_info(info: dict, game: tuple) -> None:
|
||||
updated = list(game)
|
||||
updated[1] = info.get("description", "") or game[1]
|
||||
updated[2] = game[2] or info.get("cover", "")
|
||||
updated[3] = info.get("appid", "")
|
||||
updated[4] = info.get("controller_support", "")
|
||||
completed.append(tuple(updated))
|
||||
if len(completed) == len(games):
|
||||
self._native_games_callback(completed)
|
||||
|
||||
for game in games:
|
||||
if ui_config.get_economy_mode():
|
||||
on_info(get_cached_steam_game_info(game[0], game[5]), game)
|
||||
else:
|
||||
get_steam_game_info_async(game[0], game[5], lambda info, game=game: on_info(info, game))
|
||||
|
||||
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
api = getattr(self, "gog_api", None) or GOGAPI()
|
||||
installed = api.load_installed()
|
||||
@@ -1234,11 +1129,6 @@ class MainWindow(
|
||||
library = [game for game in api.load_library() if game.get("app_id")]
|
||||
if self._upgrade_legacy_gog_library(api, library, callback):
|
||||
return
|
||||
if only_installed:
|
||||
library = [
|
||||
game for game in library
|
||||
if api.is_game_installed(str(game["app_id"]), installed)
|
||||
]
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
@@ -1302,23 +1192,6 @@ class MainWindow(
|
||||
library = [game for game in self.egs_api.load_library() if game.get("app_id")]
|
||||
only_installed = game_config.get_only_installed()
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
installed_data = None
|
||||
installed_by_id = {}
|
||||
if only_installed:
|
||||
installed_data = self.egs_api.load_installed()
|
||||
installed_by_id = {
|
||||
str(game["app_id"]): self.egs_api.is_game_installed(
|
||||
str(game["app_id"]), installed_data
|
||||
)
|
||||
for game in library
|
||||
}
|
||||
library = [
|
||||
game for game in library
|
||||
if installed_by_id[str(game["app_id"])]
|
||||
]
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
@@ -1332,19 +1205,9 @@ class MainWindow(
|
||||
name, custom_cover = MainWindow._get_custom_store_data(
|
||||
"egs", app_id, str(game.get("title", app_id))
|
||||
)
|
||||
installed = (
|
||||
installed_by_id[app_id] if only_installed
|
||||
else self.egs_api.is_game_installed(app_id)
|
||||
)
|
||||
installed = self.egs_api.is_game_installed(app_id)
|
||||
if not only_installed or installed:
|
||||
target = None
|
||||
if installed:
|
||||
if only_installed:
|
||||
target = self.egs_api.get_launch_target(
|
||||
app_id, installed_data
|
||||
)
|
||||
else:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
target = self.egs_api.get_launch_target(app_id) if installed else None
|
||||
description = str(game.get("description", ""))
|
||||
if not description or description == str(game.get("title", "")):
|
||||
description = str(steam_info.get("description", ""))
|
||||
@@ -1519,9 +1382,6 @@ class MainWindow(
|
||||
executor.submit(self._process_desktop_file_async, file_path, on_desktop_processed)
|
||||
|
||||
def _generate_missing_portproton_icon(self, game_exe: str, icon_path: str, desktop_name: str) -> str:
|
||||
custom_icon = get_custom_icon_path(game_exe)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
if not self.portproton_location:
|
||||
return ""
|
||||
if not game_exe.lower().endswith(".exe") or not os.path.isfile(game_exe):
|
||||
@@ -1560,7 +1420,7 @@ class MainWindow(
|
||||
callback: Callable[[tuple | None], None],
|
||||
):
|
||||
entry = parse_desktop_entry(file_path)
|
||||
if not entry or read_native_game(Path(file_path)) is not None:
|
||||
if not entry:
|
||||
callback(None)
|
||||
return
|
||||
desktop_name = entry.get("Name", _("Unknown Application"))
|
||||
@@ -1902,13 +1762,11 @@ class MainWindow(
|
||||
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 exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
detail_exe = exec_line
|
||||
elif exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
detail_exe = getattr(self, f"{source}_api").get_launch_target(app_id)
|
||||
if pending_log_exe and detail_exe:
|
||||
if detail_exe == pending_log_exe or os.path.abspath(detail_exe) == pending_log_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:
|
||||
@@ -2225,7 +2083,7 @@ class MainWindow(
|
||||
|
||||
|
||||
def checkTargetExe(self):
|
||||
"""Update launch state and reset controls after the game exits."""
|
||||
"""Update launch state from the marker and PortProton process."""
|
||||
dependency_active = self._drain_launch_output_progress()
|
||||
child_running = self._has_running_game_process()
|
||||
|
||||
@@ -2350,7 +2208,6 @@ class MainWindow(
|
||||
self.game_processes = [] if launcher_only else [
|
||||
proc for proc in self.game_processes if proc.poll() is None
|
||||
]
|
||||
self.native_game_running = False
|
||||
if not getattr(self, "_animated_covers_suspended", False):
|
||||
self.input_manager.resume_gamepad_polling()
|
||||
self._refresh_portproton_shortcuts()
|
||||
@@ -2445,8 +2302,6 @@ class MainWindow(
|
||||
|
||||
def _get_game_executable(self, exec_line: str) -> str:
|
||||
"""Resolve a library launch entry to its tracked executable."""
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
return exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
if exec_line.startswith("gog://launch/"):
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
return str(self.gog_api.get_launch_target(app_id) or "")
|
||||
@@ -2456,19 +2311,6 @@ class MainWindow(
|
||||
return extract_exec_target_path(exec_line) or ""
|
||||
|
||||
def _has_running_game_process(self) -> bool:
|
||||
if getattr(self, "native_game_running", False):
|
||||
if any(process.poll() is None for process in self.game_processes):
|
||||
return True
|
||||
groups = {process.pid for process in self.game_processes}
|
||||
for process in psutil.process_iter(attrs=["pid", "status"]):
|
||||
try:
|
||||
if process.info["status"] != psutil.STATUS_ZOMBIE and os.getpgid(process.pid) in groups:
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except PermissionError:
|
||||
continue
|
||||
return False
|
||||
if self._observe_game_processes():
|
||||
return True
|
||||
process_alive = any(proc.poll() is None for proc in self.game_processes)
|
||||
@@ -2590,8 +2432,6 @@ class MainWindow(
|
||||
self._start_launch_output_reader(game_process)
|
||||
|
||||
def _analyze_short_launch(self) -> None:
|
||||
if getattr(self, "native_game_running", False):
|
||||
return
|
||||
if not ui_config.get_crash_reports_enabled():
|
||||
logger.info("Compatibility report skipped: crash reports disabled")
|
||||
return
|
||||
@@ -2653,22 +2493,20 @@ class MainWindow(
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
logger.error("Compatibility analysis failed for %s: %s", launch.executable, error)
|
||||
return
|
||||
self.compatibility_report_ready.emit(report, launch.executable)
|
||||
self.compatibility_report_ready.emit(report)
|
||||
|
||||
def _show_compatibility_report(self, report: str, executable: str) -> None:
|
||||
def _show_compatibility_report(self, report: str) -> None:
|
||||
logger.info("Compatibility report dialog opened")
|
||||
dialog = CompatibilityReportDialog(self, self.theme, report)
|
||||
dialog.check_ppdb(executable)
|
||||
dialog.exec()
|
||||
|
||||
def _terminate_game_processes(self) -> None:
|
||||
for proc in self.game_processes:
|
||||
if proc.poll() is not None and not getattr(self, "native_game_running", False):
|
||||
if proc.poll() is not None:
|
||||
continue
|
||||
try:
|
||||
os.killpg(proc.pid if getattr(self, "native_game_running", False) else os.getpgid(proc.pid), signal.SIGTERM)
|
||||
if not getattr(self, "native_game_running", False):
|
||||
proc.kill()
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError as e:
|
||||
@@ -2687,14 +2525,11 @@ class MainWindow(
|
||||
return True
|
||||
|
||||
def stop_running_game(self, button=None) -> bool:
|
||||
"""Stop the current game's owned processes and launcher."""
|
||||
"""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.egs_launch_cancelled = True
|
||||
if getattr(self, "native_game_running", False):
|
||||
self._terminate_game_processes()
|
||||
return True
|
||||
if getattr(self, "launcher_process_only", False):
|
||||
if self._run_portproton_stop_command():
|
||||
return True
|
||||
@@ -2726,8 +2561,8 @@ class MainWindow(
|
||||
msg_box.setText(_("Prefix '{0}' was not found. Install recommended libraries?").format(prefix_name))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
env_vars["DISABLE_CP_DEFPFX"] = "1"
|
||||
@@ -2844,8 +2679,8 @@ class MainWindow(
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Ok)
|
||||
msg_box.button(QMessageBox.StandardButton.Ok).setText(_("OK"))
|
||||
msg_box.button(QMessageBox.StandardButton.Cancel).setText(_("Cancel"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Ok, _("OK"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Ok:
|
||||
return self._install_alt_i586_dependencies()
|
||||
@@ -2929,23 +2764,22 @@ class MainWindow(
|
||||
|
||||
def _delete_egs_game(self, app_id: str) -> None:
|
||||
game = self.egs_api.load_installed().get(app_id, {})
|
||||
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
|
||||
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.get("title") or app_id
|
||||
)
|
||||
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.get("title") or app_id
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
)
|
||||
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
|
||||
self._start_egs_operation(
|
||||
app_id, ["uninstall", app_id, "-y"], _("Delete")
|
||||
)
|
||||
@@ -3036,37 +2870,6 @@ class MainWindow(
|
||||
prefix_path = Path(self.portproton_location) / "data/prefixes" / prefix_name
|
||||
return self.egs_api.is_eos_overlay_enabled(prefix_path)
|
||||
|
||||
def _warn_cyrillic_launch_paths(self, game_path: str) -> bool:
|
||||
if ui_config.get_disable_cyrillic_path_check():
|
||||
return False
|
||||
paths = [game_path, *(self.start_sh or [])[:1]]
|
||||
affected_paths = []
|
||||
for path in paths:
|
||||
for candidate in (os.path.abspath(path), os.path.realpath(path)):
|
||||
if candidate in affected_paths:
|
||||
continue
|
||||
if any("CYRILLIC" in unicodedata.name(char, "") for char in candidate):
|
||||
affected_paths.append(candidate)
|
||||
if not affected_paths:
|
||||
return False
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Warning)
|
||||
message_box.setWindowTitle(_("Warning"))
|
||||
message_box.setText(
|
||||
_("Cyrillic characters were found in the following paths. "
|
||||
"Wine may work unreliably. Move the game or PortProton "
|
||||
"to a path without Cyrillic characters:\n\n")
|
||||
+ "\n".join(affected_paths)
|
||||
)
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Close)
|
||||
disable_button = message_box.addButton(
|
||||
_("Don't remind again"), QMessageBox.ButtonRole.ActionRole
|
||||
)
|
||||
message_box.exec()
|
||||
if message_box.clickedButton() is disable_button:
|
||||
ui_config.set_disable_cyrillic_path_check(True)
|
||||
return True
|
||||
|
||||
def _launch_egs_game(self, app_id: str, button=None) -> None:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
if not target:
|
||||
@@ -3094,7 +2897,6 @@ class MainWindow(
|
||||
"launch", app_id, "--json", "--wrapper", self.start_sh[0],
|
||||
"--no-wine", "--skip-version-check",
|
||||
])
|
||||
self._warn_cyrillic_launch_paths(target)
|
||||
process = subprocess.Popen(
|
||||
command, env=self.egs_api.get_environment(), shell=False,
|
||||
preexec_fn=os.setsid, stdout=subprocess.PIPE,
|
||||
@@ -3156,9 +2958,6 @@ class MainWindow(
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
self._finish_silent_launch()
|
||||
return
|
||||
self._warn_cyrillic_launch_paths(
|
||||
self.gog_api.get_launch_target(app_id) or install_path
|
||||
)
|
||||
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)
|
||||
@@ -3202,10 +3001,6 @@ class MainWindow(
|
||||
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
|
||||
|
||||
def toggleGame(self, exec_line, button=None, game_name=None):
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
self._launch_native_game(exec_line, button)
|
||||
return
|
||||
|
||||
# Handle Steam games
|
||||
if exec_line.startswith("steam://"):
|
||||
self._launch_steam_game(exec_line)
|
||||
@@ -3268,8 +3063,6 @@ class MainWindow(
|
||||
else:
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
if file_to_check.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS):
|
||||
self._warn_cyrillic_launch_paths(file_to_check)
|
||||
|
||||
if update_button:
|
||||
try:
|
||||
|
||||
@@ -22,7 +22,6 @@ install_data(
|
||||
'localization.py',
|
||||
'logger.py',
|
||||
'main_window.py',
|
||||
'native_games.py',
|
||||
'port_data_path_selector.py',
|
||||
'portproton_api.py',
|
||||
'preloader.py',
|
||||
@@ -119,6 +118,7 @@ install_data(
|
||||
'dialogs/settings_dialog.py',
|
||||
'dialogs/settings_mangohud.py',
|
||||
'dialogs/settings_gamescope.py',
|
||||
'dialogs/settings_reshade.py',
|
||||
'dialogs/settings_vkbasalt.py',
|
||||
'dialogs/proton_manager.py',
|
||||
'dialogs/prefix_backup.py',
|
||||
@@ -187,7 +187,6 @@ install_data(
|
||||
['scripts_utils/__init__.py',
|
||||
'scripts_utils/dbus_tools.py',
|
||||
'scripts_utils/easyterm.py',
|
||||
'scripts_utils/exe_metadata.py',
|
||||
'scripts_utils/graphics_detector.py',
|
||||
'scripts_utils/idle_inhibit.py',
|
||||
'scripts_utils/json_tools.py',
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
"""Discover installed native games from desktop application entries."""
|
||||
|
||||
import configparser
|
||||
import platform
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
from portprotonqt.config import LAUNCH_FILE_EXTENSIONS, WINDOWS_LAUNCH_EXTENSIONS, get_portproton_location, get_portproton_scripts_path, get_custom_data_dir_name
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, extract_exec_target_path, resolve_custom_data_dir, parse_desktop_entry
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.icon_extractor import get_exe_icon_cache_path
|
||||
from portprotonqt.time_utils import (
|
||||
get_statistics_path, parse_playtime_file, get_last_launch_path, _parse_last_launch_line,
|
||||
format_last_launch, format_playtime,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
NATIVE_URI_PREFIX = "native://"
|
||||
NATIVE_SETTINGS_SECTION = "X-PortProtonQt"
|
||||
NATIVE_TOGGLE_KEYS = frozenset({"PW_USE_GAMEMODE", "PW_USE_RUNTIME", "PW_REDUCE_PULSE_LATENCY",
|
||||
"PW_USE_GALLIUM_ZINK", "PW_USE_OBS_VKCAPTURE", "PW_USE_INHIBIT_SLEEP"})
|
||||
NATIVE_ADVANCED_KEYS = frozenset({"LAUNCH_PARAMETERS", "PW_MESA_GL_VERSION_OVERRIDE", "PW_LOCALE_SELECT",
|
||||
"PW_MESA_VK_WSI_PRESENT_MODE", "PW_WINE_CPU_TOPOLOGY"})
|
||||
|
||||
|
||||
def is_native_executable(path: str) -> bool:
|
||||
"""Recognize native launch files while preserving Windows and disc formats."""
|
||||
return (
|
||||
Path(path).suffix.lower() not in LAUNCH_FILE_EXTENSIONS
|
||||
and os.path.isfile(path)
|
||||
and os.access(path, os.X_OK)
|
||||
)
|
||||
|
||||
|
||||
def quote_native_arguments(arguments: list[str]) -> str:
|
||||
"""Quote arguments using desktop entry escaping, without a shell."""
|
||||
escaped = []
|
||||
for argument in arguments:
|
||||
value = argument.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
|
||||
escaped.append('"' + value.replace("\\", "\\\\").replace("%", "%%") + '"')
|
||||
return " ".join(escaped)
|
||||
|
||||
|
||||
def build_native_shortcut_command(command: str, mode: str = "--silent") -> str:
|
||||
"""Use the installed application launcher for native desktop shortcuts."""
|
||||
flatpak_id = os.getenv("FLATPAK_ID")
|
||||
appimage = os.getenv("APPIMAGE", "").strip()
|
||||
launcher = ["portprotonqt"]
|
||||
if flatpak_id:
|
||||
launcher = ["flatpak", "run", flatpak_id]
|
||||
elif appimage and os.path.isfile(appimage):
|
||||
launcher = [appimage]
|
||||
if mode:
|
||||
launcher.append(mode)
|
||||
return quote_native_arguments(launcher) + " " + command
|
||||
|
||||
|
||||
def get_native_source_command(command: str) -> str:
|
||||
"""Unwrap a PortProtonQt desktop command before applying native settings."""
|
||||
parts = shlex.split(command.replace("\\\\", "\\"))
|
||||
if "--silent" in parts and (parts[0] in {"portprotonqt", "flatpak", os.getenv("APPIMAGE", "").strip()} or parts[0].lower().endswith(".appimage")):
|
||||
target = extract_exec_target_path(parts)
|
||||
if target:
|
||||
target = target.replace("\\$", "$").replace("\\`", "`").replace("%%", "%")
|
||||
return quote_native_arguments([target])
|
||||
return command
|
||||
|
||||
|
||||
def get_native_tool_paths() -> dict[str, str]:
|
||||
"""Locate the existing PortProton runtime and shader bundle without loading Wine."""
|
||||
scripts = get_portproton_scripts_path()
|
||||
location = get_portproton_location()
|
||||
if not scripts or not location:
|
||||
return {}
|
||||
values = {}
|
||||
try:
|
||||
for line in (Path(scripts) / "var").read_text(encoding="utf-8").splitlines():
|
||||
key, separator, value = line.removeprefix("export ").partition("=")
|
||||
if separator and key in {"PW_LIBS_VER", "PW_PLUGINS_VER"}:
|
||||
values[key] = value.strip().strip('"\'')
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native runtime versions: %s", error)
|
||||
return {}
|
||||
root = Path(location) / "data/tmp"
|
||||
return {"runtime": str(root / ("libs" + values.get("PW_LIBS_VER", ""))),
|
||||
"plugins": str(root / ("plugins" + values.get("PW_PLUGINS_VER", ""))),
|
||||
"PW_PLUGINS_VER": values.get("PW_PLUGINS_VER", "")}
|
||||
|
||||
|
||||
def read_native_variables(path: Path) -> dict[str, str]:
|
||||
"""Read literal var/PPDB assignments without executing shell commands."""
|
||||
values = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip().startswith(("export ", "check_variables ")):
|
||||
continue
|
||||
parts = shlex.split(line, comments=True)
|
||||
if not parts:
|
||||
continue
|
||||
if parts[0] == "check_variables" and len(parts) == 3:
|
||||
values[parts[1]] = parts[2]
|
||||
elif parts[0] == "export" and len(parts) == 2 and "=" in parts[1]:
|
||||
key, value = parts[1].split("=", 1)
|
||||
values[key] = value.replace("\\$", "$").replace("\\`", "`")
|
||||
return values
|
||||
|
||||
|
||||
def get_native_ppdb_path(desktop_path: str, settings: dict[str, str]) -> Path:
|
||||
"""Keep native settings and logs in a named per-game user data directory."""
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
name = settings.get("Name", Path(desktop_path).stem).replace("/", "_").strip(".") or "native"
|
||||
return data_home / "PortProtonQt/native_games" / name / (name + ".ppdb")
|
||||
|
||||
|
||||
def get_native_defaults() -> dict[str, str]:
|
||||
"""Read supported Linux defaults from the existing PortProton var file."""
|
||||
values = dict.fromkeys(NATIVE_TOGGLE_KEYS | NATIVE_ADVANCED_KEYS, "0")
|
||||
values.update(dict.fromkeys(NATIVE_ADVANCED_KEYS, "disabled"))
|
||||
values.update({"LAUNCH_PARAMETERS": "", "PW_MANGOHUD": "0", "PW_MANGOHUD_USER_CONF": "0",
|
||||
"PW_VKBASALT": "0", "PW_VKBASALT_USER_CONF": "0", "PW_GAMESCOPE": "0",
|
||||
"MANGOHUD_CONFIG": "", "FPS_LIMIT": ""})
|
||||
scripts = get_portproton_scripts_path()
|
||||
if scripts and (Path(scripts) / "var").is_file():
|
||||
defaults = read_native_variables(Path(scripts) / "var")
|
||||
values.update({key: value for key, value in defaults.items()
|
||||
if (key in values and key != "PW_USE_RUNTIME") or key.startswith(("PW_VKBASALT_", "PW_GAMESCOPE_"))})
|
||||
values["MANGOHUD_CONFIG"] = ",".join(item for item in defaults.get("DEFAULT_MANGOHUD_CONFIG", "").split(",")
|
||||
if item not in {"wine", "winesync"})
|
||||
return values
|
||||
|
||||
|
||||
def load_native_settings(desktop_path: str) -> dict[str, str]:
|
||||
"""Load var defaults, existing desktop settings, and the game PPDB."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
|
||||
settings = get_native_defaults()
|
||||
if parser.has_section(NATIVE_SETTINGS_SECTION):
|
||||
settings.update(parser[NATIVE_SETTINGS_SECTION])
|
||||
settings["Path"] = parser["Desktop Entry"].get("Path", "").replace("\\\\", "\\")
|
||||
database = Path(desktop_path + ".ppdb")
|
||||
if database.is_file():
|
||||
content = database.read_text(encoding="utf-8")
|
||||
if content.lstrip().startswith("["):
|
||||
parser.read_string(content)
|
||||
settings.update(parser[NATIVE_SETTINGS_SECTION])
|
||||
else:
|
||||
settings.update(read_native_variables(database))
|
||||
settings.setdefault("Name", parser["Desktop Entry"].get("Name", Path(desktop_path).stem))
|
||||
settings.setdefault("Exec", parser["Desktop Entry"]["Exec"])
|
||||
settings["Exec"] = get_native_source_command(settings["Exec"])
|
||||
parts = shlex.split(settings["Exec"].replace("\\\\", "\\"))
|
||||
executable = next((part.replace("%%", "%") for part in reversed(parts)
|
||||
if os.path.isabs(part.replace("%%", "%")) and Path(part.replace("%%", "%")).is_file()), None)
|
||||
if executable is None and parts:
|
||||
executable = shutil.which(parts[0])
|
||||
legacy_paths = [Path((executable or desktop_path) + ".ppdb")]
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
legacy_paths.append(data_home / "PortProtonQt/native_games" / (get_custom_data_dir_name(desktop_path) + ".ppdb"))
|
||||
for legacy in legacy_paths:
|
||||
if legacy.is_file():
|
||||
settings.update(read_native_variables(legacy))
|
||||
database = get_native_ppdb_path(desktop_path, settings)
|
||||
if database.is_file():
|
||||
settings.update(read_native_variables(database))
|
||||
settings.setdefault("PW_PLUGINS_VER", get_native_tool_paths().get("PW_PLUGINS_VER", ""))
|
||||
return settings
|
||||
|
||||
|
||||
def build_native_runtime(settings: dict[str, str]) -> tuple[list[str], list[str]]:
|
||||
"""Use PortProton's runtime and portable Linux tools without loading Wine."""
|
||||
paths = get_native_tool_paths()
|
||||
runtime = Path(paths.get("runtime", ""))
|
||||
wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime"
|
||||
if not wrapper.is_file() or not (runtime / "runtime/files").is_dir():
|
||||
raise ValueError("PortProton container runtime is not installed")
|
||||
environment = [f"PRESSURE_VESSEL_RUNTIME_BASE={runtime}", "PRESSURE_VESSEL_RUNTIME=runtime",
|
||||
f"PRESSURE_VESSEL_VARIABLE_DIR={runtime / 'var'}", "PRESSURE_VESSEL_COPY_RUNTIME=1",
|
||||
"PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1"]
|
||||
if platform.freedesktop_os_release().get("ID") == "altlinux":
|
||||
drivers = [str(path.resolve()) for path in (Path("/etc/libnvidiacurrent"), Path("/etc/libnvidia32current"))
|
||||
if path.is_dir()]
|
||||
if drivers:
|
||||
environment.append("PRESSURE_VESSEL_APP_LD_LIBRARY_PATH=" + ":".join(filter(None, [os.getenv("LD_LIBRARY_PATH", ""), *drivers])))
|
||||
environment.append("LIBGL_DRIVERS_PATH=/usr/lib/X11/modules/dri:/usr/lib64/X11/modules/dri")
|
||||
command = [str(wrapper), "--share-home", "--remove-game-overlay", "--share-pid", "--", "env"]
|
||||
portable = Path(paths.get("plugins", "")) / "portable"
|
||||
command += [f"PATH={portable / 'bin'}:{os.environ.get('PATH', '')}"]
|
||||
if settings.get("PW_MANGOHUD") == "1" or settings.get("PW_VKBASALT") == "1":
|
||||
layers = portable / "share/vulkan/implicit_layer.d"
|
||||
libraries = ":".join(str(portable / "lib" / arch) for arch in ("lib64", "lib32"))
|
||||
command += [f"LD_LIBRARY_PATH={libraries}:/overrides/lib/x86_64-linux-gnu:/overrides/lib/i386-linux-gnu",
|
||||
f"VK_ADD_LAYER_PATH={layers}", f"VK_ADD_IMPLICIT_LAYER_PATH={layers}"]
|
||||
environment.remove("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1")
|
||||
environment.append("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=0")
|
||||
if settings.get("PW_MANGOHUD") == "1":
|
||||
if not (portable / "lib/lib64/libMangoHud_shim.so").is_file():
|
||||
raise ValueError("PortProton portable MangoHud is not installed")
|
||||
preload = ":".join(filter(None, [os.getenv("LD_PRELOAD", ""), "libMangoHud_shim.so"]))
|
||||
command += ["MANGOHUD=1", f"LD_PRELOAD={preload}"]
|
||||
return command, environment
|
||||
|
||||
|
||||
def build_native_launch_command(command: str, settings: dict[str, str]) -> str:
|
||||
"""Compose Linux launch wrappers and environment for a desktop entry."""
|
||||
prefix = []
|
||||
environment = []
|
||||
for key, variable in (("PW_REDUCE_PULSE_LATENCY", "PULSE_LATENCY_MSEC=60"),
|
||||
("PW_USE_GALLIUM_ZINK", "MESA_LOADER_DRIVER_OVERRIDE=zink"),
|
||||
("PW_USE_OBS_VKCAPTURE", "OBS_VKCAPTURE=1")):
|
||||
if settings.get(key) == "1":
|
||||
environment.append(variable)
|
||||
for key, variable in (("PW_LOCALE_SELECT", "LC_ALL"), ("PW_MESA_GL_VERSION_OVERRIDE", "MESA_GL_VERSION_OVERRIDE"),
|
||||
("PW_MESA_VK_WSI_PRESENT_MODE", "MESA_VK_WSI_PRESENT_MODE")):
|
||||
if settings.get(key) not in (None, "", "disabled"):
|
||||
environment.append(f"{variable}={settings[key]}")
|
||||
if settings.get("PW_USE_INHIBIT_SLEEP") == "1":
|
||||
prefix += ["systemd-inhibit", "--what=idle:sleep", "--why=Native game", "--"]
|
||||
if settings.get("PW_GAMESCOPE") == "1":
|
||||
prefix += ["gamescope", *shlex.split(settings.get("PW_GAMESCOPE_ARGS_NEW", "")), "--"]
|
||||
if settings.get("PW_USE_RUNTIME") == "1":
|
||||
runtime_command, runtime_env = build_native_runtime(settings)
|
||||
prefix += runtime_command
|
||||
environment += runtime_env
|
||||
cores = settings.get("PW_WINE_CPU_TOPOLOGY", "disabled")
|
||||
if cores not in ("", "disabled"):
|
||||
available = sorted(os.sched_getaffinity(0))
|
||||
count = int(cores)
|
||||
if not 1 <= count <= len(available):
|
||||
raise ValueError("Invalid CPU cores limit")
|
||||
prefix += ["taskset", "-c", ",".join(str(cpu) for cpu in available[:count])]
|
||||
if settings.get("PW_USE_GAMEMODE") == "1":
|
||||
prefix.append("gamemoderun")
|
||||
if settings.get("PW_MANGOHUD") == "1":
|
||||
if settings.get("PW_USE_RUNTIME") != "1":
|
||||
prefix += ["mangohud", "--dlsym"]
|
||||
if settings.get("PW_MANGOHUD_USER_CONF") != "1":
|
||||
config = settings.get("MANGOHUD_CONFIG", "")
|
||||
if settings.get("FPS_LIMIT"):
|
||||
config += f",fps_limit={settings['FPS_LIMIT']}"
|
||||
environment.append(f"MANGOHUD_CONFIG={config.strip(',')}")
|
||||
if settings.get("PW_VKBASALT") == "1":
|
||||
environment.append("ENABLE_VKBASALT=1")
|
||||
if settings.get("VKBASALT_CONFIG_FILE"):
|
||||
environment.append(f"VKBASALT_CONFIG_FILE={settings['VKBASALT_CONFIG_FILE']}")
|
||||
if environment:
|
||||
prefix = ["env", *environment, *prefix]
|
||||
suffix = quote_native_arguments(shlex.split(settings.get("LAUNCH_PARAMETERS", "")))
|
||||
return " ".join(part for part in (quote_native_arguments(prefix), command, suffix) if part)
|
||||
|
||||
|
||||
def write_native_vkbasalt_config(desktop_path: str, settings: dict[str, str]) -> None:
|
||||
"""Keep native vkBasalt configuration isolated from global settings."""
|
||||
if settings.get("PW_VKBASALT") != "1" or settings.get("PW_VKBASALT_USER_CONF") == "1":
|
||||
settings.pop("VKBASALT_CONFIG_FILE", None)
|
||||
return
|
||||
config = get_native_ppdb_path(desktop_path, settings).with_suffix(".vkbasalt.conf")
|
||||
plugins = Path(get_native_tool_paths().get("plugins", "")) / "reshade"
|
||||
lines = [f"effects = {settings.get('PW_VKBASALT_EFFECTS', 'cas')}",
|
||||
f"casSharpness = {settings.get('PW_VKBASALT_FFX_CAS', '0.50')}",
|
||||
f"toggleKey = {settings.get('PW_VKBASALT_TOOGLE_KEY', 'Home')}",
|
||||
f"reshadeTexturePath = {plugins / 'textures'}", f"reshadeIncludePath = {plugins}"]
|
||||
for shader in settings.get("PW_VKBASALT_EFFECTS", "").split(":"):
|
||||
if shader and shader != "cas":
|
||||
if Path(shader).name != shader:
|
||||
raise ValueError("Invalid vkBasalt shader name")
|
||||
shader_path = plugins / "shaders" / (shader + ".fx")
|
||||
if not shader_path.is_file():
|
||||
raise ValueError(f"vkBasalt shader is not installed: {shader}")
|
||||
lines.append(f"{shader} = {shader_path}")
|
||||
config.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
settings["VKBASALT_CONFIG_FILE"] = str(config)
|
||||
|
||||
|
||||
def save_native_settings(desktop_path: str, changes: dict[str, str]) -> None:
|
||||
"""Save native settings only to PPDB, leaving installed launchers unchanged."""
|
||||
settings = load_native_settings(desktop_path)
|
||||
settings.update(changes)
|
||||
settings["Exec"] = get_native_source_command(settings["Exec"])
|
||||
command, directory = settings["Exec"], settings.get("Path", "")
|
||||
if not shlex.split(command.replace("\\\\", "\\")) or any(char in "".join(settings.values()) for char in "\n\r\x00"):
|
||||
raise ValueError("Invalid native launch command or directory")
|
||||
if directory and (not os.path.isabs(directory) or not os.path.isdir(directory)):
|
||||
raise ValueError("Working directory must be an existing absolute path")
|
||||
database = get_native_ppdb_path(desktop_path, settings)
|
||||
database.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_native_vkbasalt_config(desktop_path, settings)
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=database.parent, delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
for key, value in sorted(settings.items()):
|
||||
if key in {"Name", "Icon", "Exec", "Path"}:
|
||||
continue
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
|
||||
output.write(f'export {key}="{escaped}"\n')
|
||||
os.replace(temporary, database)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def create_native_desktop_file(executable: str, name: str, icon: str = "") -> tuple[str, str] | None:
|
||||
"""Build a native launcher through PortProtonQt with the game's directory."""
|
||||
path = Path(executable).expanduser().absolute()
|
||||
if not path.is_file() or not os.access(path, os.X_OK) or path.suffix.lower() in WINDOWS_LAUNCH_EXTENSIONS:
|
||||
logger.warning("Not an executable native game: %s", path)
|
||||
return None
|
||||
if not name.strip() or any(char in str(path) + icon for char in "\n\r\x00"):
|
||||
logger.warning("Invalid native game name or path")
|
||||
return None
|
||||
name = " ".join(name.splitlines()).strip()
|
||||
# Desktop Exec uses double quoting and doubles literal percent signs.
|
||||
command = build_native_shortcut_command(quote_native_arguments([str(path)]))
|
||||
directory = str(path.parent).replace("\\", "\\\\")
|
||||
icon = get_custom_icon_path(str(path)) or icon
|
||||
if not icon and path.suffix.lower() == ".appimage":
|
||||
icon = get_exe_icon_cache_path(str(path))
|
||||
icon = icon.replace("\\", "\\\\") if os.path.isfile(icon) else "applications-games"
|
||||
comment = _('Launch "{name}" with PortProton').format(name=name)
|
||||
content = (f"[Desktop Entry]\nType=Application\nName={name}\nComment={comment}\nExec={command}\n"
|
||||
f"Path={directory}\nIcon={icon}\nTerminal=false\nCategories=Game;\nStartupNotify=true\nActions=RunSilent;RunLog;\n")
|
||||
for action, label, mode in (("RunSilent", _("Run in silent mode"), "--silent"),
|
||||
("RunLog", _("Run in logging mode"), "--log")):
|
||||
action_command = build_native_shortcut_command(quote_native_arguments([str(path)]), mode)
|
||||
content += f"\n[Desktop Action {action}]\nName={label}\nExec={action_command}\nIcon={icon}\n"
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
logger.warning("PortProton data directory is unavailable")
|
||||
return None
|
||||
filename = name.replace("/", "_") + ".desktop"
|
||||
destination = Path(location) / filename
|
||||
try:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot create native library directory: %s", error)
|
||||
return None
|
||||
return content, str(destination)
|
||||
|
||||
|
||||
def read_native_game(path: Path) -> tuple | None:
|
||||
"""Read a game entry without modifying the installed desktop file."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
parser.read_string(path.read_text(encoding="utf-8"))
|
||||
if not parser.has_section("Desktop Entry"):
|
||||
return None
|
||||
entry = parser["Desktop Entry"]
|
||||
if entry.get("Type") != "Application" or "Game" not in entry.get("Categories", "").split(";"):
|
||||
return None
|
||||
if any(entry.get(key, "false").lower() == "true" for key in ("Hidden", "NoDisplay")):
|
||||
return None
|
||||
if any(mime.startswith("x-scheme-handler/") for mime in entry.get("MimeType", "").split(";")):
|
||||
return None
|
||||
name, command = entry.get("Name", ""), entry.get("Exec", "")
|
||||
parts = shlex.split(command.replace("\\\\", "\\"))
|
||||
if not name or not parts:
|
||||
return None
|
||||
if any(part.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS)
|
||||
or part.lower().startswith(("steam://", "gog://", "egs://")) for part in parts):
|
||||
return None
|
||||
if entry.get("TryExec") and not shutil.which(entry["TryExec"]):
|
||||
return None
|
||||
original = parser[NATIVE_SETTINGS_SECTION].get("Exec", command) if parser.has_section(NATIVE_SETTINGS_SECTION) else command
|
||||
executable = extract_exec_target_path(shlex.split(original.replace("\\\\", "\\"))) or ""
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
root = str(data_home / "PortProtonQt/custom_data")
|
||||
custom = Path(resolve_custom_data_dir(root, str(path)))
|
||||
if not custom.is_dir() and executable:
|
||||
custom = Path(resolve_custom_data_dir(root, executable))
|
||||
description = entry.get("Comment", "")
|
||||
metadata = custom / "metadata.txt"
|
||||
if metadata.is_file():
|
||||
translations = read_metadata_translations(str(metadata), get_metadata_language())
|
||||
name = translations.get("name") or name
|
||||
description = translations.get("description") or description
|
||||
cover = next((str(path) for path in custom.glob("cover.*") if path.is_file()), "")
|
||||
if not cover:
|
||||
from portprotonqt.steam_api.api import get_cached_sgdb_cover
|
||||
|
||||
cover = get_cached_sgdb_cover(name)
|
||||
if not cover and executable.lower().endswith(".appimage"):
|
||||
cached_icon = get_exe_icon_cache_path(executable)
|
||||
if os.path.isfile(cached_icon):
|
||||
cover = cached_icon
|
||||
return (name, description, cover,
|
||||
"", "", NATIVE_URI_PREFIX + str(path), _("Never"), "", "", "", 0, 0,
|
||||
"native", "", "", "")
|
||||
except configparser.Error as error:
|
||||
logger.warning("Invalid native desktop entry %s: %s", path, error)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native desktop entry %s: %s", path, error)
|
||||
except UnicodeError as error:
|
||||
logger.warning("Invalid native desktop encoding %s: %s", path, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid native desktop command %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def get_native_launch(desktop_path: str) -> tuple[list[str], str]:
|
||||
"""Resolve desktop field codes into a direct Linux process command."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
|
||||
entry = parser["Desktop Entry"]
|
||||
settings = load_native_settings(desktop_path)
|
||||
entry["Exec"] = settings["Exec"]
|
||||
if get_native_ppdb_path(desktop_path, settings).is_file():
|
||||
entry["Exec"] = build_native_launch_command(settings["Exec"], settings)
|
||||
entry["Path"] = settings.get("Path", "").replace("\\", "\\\\")
|
||||
escapes = {"\\": "\\", "s": " ", "n": "\n", "t": "\t", "r": "\r"}
|
||||
command = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Exec", ""))
|
||||
arguments = []
|
||||
for part in shlex.split(command):
|
||||
part = part.replace("\\$", "$").replace("\\`", "`")
|
||||
if part == "%i":
|
||||
if entry.get("Icon"):
|
||||
arguments.extend(["--icon", entry["Icon"]])
|
||||
continue
|
||||
value = re.sub(r"%%|%[fFuUdDnNvmick]", lambda match:
|
||||
{"%%": "%", "%c": entry.get("Name", ""), "%k": desktop_path}.get(match[0], ""), part)
|
||||
if value:
|
||||
arguments.append(value)
|
||||
if not arguments:
|
||||
raise ValueError("Empty native launch command")
|
||||
directory = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Path", ""))
|
||||
if directory and not os.path.isdir(directory):
|
||||
raise ValueError(f"Working directory does not exist: {directory}")
|
||||
executable = next((part for part in reversed(arguments)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), arguments[0])
|
||||
fallback = Path(executable).parent if os.path.isabs(executable) else Path.home()
|
||||
return arguments, directory or str(fallback)
|
||||
|
||||
|
||||
def load_native_game_times(games: list[tuple]) -> list[tuple]:
|
||||
"""Read shared statistics once for the native library batch."""
|
||||
statistics, launches = {}, {}
|
||||
try:
|
||||
path = get_statistics_path()
|
||||
if os.path.isfile(path):
|
||||
statistics = parse_playtime_file(path)
|
||||
path = get_last_launch_path()
|
||||
if os.path.isfile(path):
|
||||
launches = dict(parsed for line in Path(path).read_text(encoding="utf-8").splitlines()
|
||||
if (parsed := _parse_last_launch_line(line)))
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native game statistics: %s", error)
|
||||
updated_games = []
|
||||
for game in games:
|
||||
path = Path(game[5].removeprefix(NATIVE_URI_PREFIX))
|
||||
updated = list(game)
|
||||
updated[11] = statistics.get(str(path).replace(" ", "#@_@#"), 0)
|
||||
updated[7] = format_playtime(updated[11])
|
||||
if path.stem in launches:
|
||||
try:
|
||||
launched = datetime.fromisoformat(launches[path.stem])
|
||||
updated[6], updated[10] = format_last_launch(launched), launched.timestamp()
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid native last launch time for %s: %s", path, error)
|
||||
updated_games.append(tuple(updated))
|
||||
return updated_games
|
||||
|
||||
|
||||
class NativeLibraryWorker(QThread):
|
||||
"""Scan application directories outside the UI thread."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.games: list[tuple] = []
|
||||
|
||||
def run(self) -> None:
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(os.pathsep)
|
||||
roots = [data_home, *(Path(directory) for directory in data_dirs if directory),
|
||||
data_home / "flatpak/exports/share", Path("/var/lib/flatpak/exports/share")]
|
||||
directories = [root / "applications" for root in roots]
|
||||
directories.insert(0, data_home / "PortProtonQt/native_games")
|
||||
location = get_portproton_location()
|
||||
if location:
|
||||
directories.insert(0, Path(location))
|
||||
seen: set[str] = set()
|
||||
commands: set[tuple[str, str, str]] = set()
|
||||
for applications in directories:
|
||||
try:
|
||||
for path in sorted(applications.glob("*.desktop") if location and applications == Path(location)
|
||||
else applications.rglob("*.desktop")):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
desktop_id = str(path.relative_to(applications)).replace(os.sep, "-")
|
||||
if desktop_id in seen:
|
||||
continue
|
||||
seen.add(desktop_id)
|
||||
game = read_native_game(path)
|
||||
if not game:
|
||||
continue
|
||||
entry = parse_desktop_entry(str(path)) or {}
|
||||
command = get_native_source_command(entry.get("Exec", ""))
|
||||
identity = (command, entry.get("Path", ""), entry.get("Name", ""))
|
||||
if identity in commands:
|
||||
continue
|
||||
commands.add(identity)
|
||||
self.games.append(game)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot scan native games in %s: %s", applications, error)
|
||||
self.games = load_native_game_times(self.games)
|
||||
@@ -175,7 +175,7 @@ def _show_portdata_warning(parent: QWidget, theme: Any, text: str, allow_choose:
|
||||
message_box.setText(text)
|
||||
if allow_choose:
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
message_box.button(QMessageBox.StandardButton.Open).setText(_("Choose Folder"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Open, _("Choose Folder"))
|
||||
main_style = getattr(theme, "MAIN_WINDOW_STYLE", "")
|
||||
message_box_style = getattr(theme, "MESSAGE_BOX_STYLE", "")
|
||||
message_box.setStyleSheet(main_style + message_box_style)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Awaitable
|
||||
@@ -169,20 +168,17 @@ async def request_deepin_wm_switch() -> bool:
|
||||
|
||||
|
||||
async def request_idle_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
|
||||
"""Request idle inhibit through screensaver, portal or logind D-Bus API."""
|
||||
bus = None
|
||||
"""Request idle inhibit through screensaver or portal D-Bus API."""
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
try:
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
try:
|
||||
kind, payload = await _request_screensaver_inhibit(bus, application, reason)
|
||||
except Exception as error:
|
||||
logger.warning("Screensaver inhibit failed: %s", error)
|
||||
kind, payload = await _request_portal_inhibit(bus, reason)
|
||||
except Exception as error:
|
||||
logger.warning("Session idle inhibit failed: %s", error)
|
||||
if bus is not None:
|
||||
bus.disconnect()
|
||||
return await _request_logind_inhibit(application, reason)
|
||||
except Exception:
|
||||
bus.disconnect()
|
||||
raise
|
||||
|
||||
return bus, kind, payload
|
||||
|
||||
@@ -215,33 +211,8 @@ async def _request_portal_inhibit(bus: MessageBus, reason: str) -> tuple[str, An
|
||||
return "portal", body[0]
|
||||
|
||||
|
||||
async def _request_logind_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SYSTEM, negotiate_unix_fd=True).connect())
|
||||
try:
|
||||
reply = await dbus_call(bus.call(Message(
|
||||
destination="org.freedesktop.login1",
|
||||
path="/org/freedesktop/login1",
|
||||
interface="org.freedesktop.login1.Manager",
|
||||
member="Inhibit",
|
||||
signature="ssss",
|
||||
body=["sleep:idle", application, reason, "block"],
|
||||
)))
|
||||
if reply.message_type.name == "ERROR":
|
||||
raise DBusError(reply.error_name, reply.body[0])
|
||||
return bus, "logind", reply.unix_fds[reply.body[0]]
|
||||
except Exception:
|
||||
bus.disconnect()
|
||||
raise
|
||||
|
||||
|
||||
async def release_idle_inhibit(bus: MessageBus, kind: str, payload: Any) -> None:
|
||||
"""Release idle inhibit requested through request_idle_inhibit."""
|
||||
if kind == "logind":
|
||||
try:
|
||||
os.close(payload)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
return
|
||||
with contextlib.suppress(DBusError, asyncio.TimeoutError):
|
||||
if kind == "screensaver":
|
||||
iface, cookie = payload
|
||||
|
||||
@@ -1026,7 +1026,11 @@ class TerminalWidget(QPlainTextEdit):
|
||||
max_fd = os.sysconf("SC_OPEN_MAX")
|
||||
except ValueError:
|
||||
max_fd = 256
|
||||
os.closerange(3, max_fd)
|
||||
for fd in range(3, max_fd):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if self.cwd:
|
||||
os.chdir(self.cwd)
|
||||
@@ -1869,11 +1873,11 @@ def build_command(args: argparse.Namespace) -> list[str]:
|
||||
"""Build the command list to execute in the PTY."""
|
||||
if args.e:
|
||||
if len(args.e) == 1:
|
||||
command = args.e[0]
|
||||
# functions_helper is bash-only, user shell may be zsh or fish
|
||||
if _needs_portproton_functions(command):
|
||||
return [CONF_FALLBACK_SHELL, "-lc", _with_portproton_functions(command)]
|
||||
return [default_shell(), "-lc", command]
|
||||
return [
|
||||
default_shell(),
|
||||
"-lc",
|
||||
_with_portproton_functions(args.e[0]),
|
||||
]
|
||||
return args.e
|
||||
return [default_shell()]
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Read PE version metadata for PortProton shell scripts."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pefile
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def read_metadata(path: str) -> dict[str, str] | None:
|
||||
"""Read version strings without starting an external metadata tool."""
|
||||
try:
|
||||
with pefile.PE(path, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
return {
|
||||
key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", []) for key, value in table.entries.items()
|
||||
}
|
||||
except pefile.PEFormatError as error:
|
||||
logger.debug("Unsupported PE file %s: %s", path, error)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
sys.stderr.write("Usage: python -m portprotonqt.scripts_utils.exe_metadata FILE\n")
|
||||
return 2
|
||||
metadata = read_metadata(sys.argv[1])
|
||||
if metadata is None:
|
||||
return 1
|
||||
sys.stdout.write(f"File Name: {Path(sys.argv[1]).name}\n")
|
||||
for key, value in metadata.items():
|
||||
label = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key)
|
||||
sys.stdout.write(f"{label}: {' '.join(value.splitlines())}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -149,6 +149,7 @@ def get_toggle_settings():
|
||||
'PW_USE_SPECIALK': _("Enable SpecialK (injection library for fixing graphics, latency and more)"),
|
||||
# Upscalers and Frame Generation
|
||||
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
|
||||
'PW_USE_RESHADE': _("Enable ReShade post-processing"),
|
||||
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
|
||||
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
|
||||
# Ray Tracing
|
||||
|
||||
+16
-102
@@ -1,6 +1,5 @@
|
||||
"""Steam API functions for PortProtonQt."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
@@ -16,7 +15,6 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.localization import get_steam_language
|
||||
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.steam_api.utils import decode_text, get_local_steam_cover
|
||||
from portprotonqt.steam_api.cache import (
|
||||
@@ -48,12 +46,10 @@ SGDB_CACHE_DURATION = 24 * 60 * 60
|
||||
STEAM_CLIENT_ICON_SIZES = (16, 32, 64, 128, 256)
|
||||
|
||||
|
||||
def fetch_sgdb_cover_async(
|
||||
game_name: str, callback: Callable[[str], None], image_type: str = "grids"
|
||||
) -> None:
|
||||
def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> None:
|
||||
"""Asynchronously fetch cover image URL from steamgrid.usebottles.com."""
|
||||
encoded = urllib.parse.quote(game_name, safe="")
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}/{image_type}"
|
||||
encoded = urllib.parse.quote(game_name)
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -70,14 +66,6 @@ def fetch_sgdb_cover_async(
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
text = text[1:-1]
|
||||
if text:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
text = text.replace(
|
||||
"https://cdn2.steamgriddb.com/",
|
||||
"https://deckyloader.ru/sgdb-cdn/",
|
||||
1,
|
||||
)
|
||||
logger.debug("Got SGDB cover URL for %s: %s", game_name, text)
|
||||
callback(text)
|
||||
except Exception as e:
|
||||
@@ -86,7 +74,7 @@ def fetch_sgdb_cover_async(
|
||||
|
||||
cache_manager = CacheManager()
|
||||
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}_{image_type}.json"
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}.json"
|
||||
if cache_file.exists() and time.time() - cache_file.stat().st_mtime < SGDB_CACHE_DURATION:
|
||||
process_response(str(cache_file))
|
||||
return
|
||||
@@ -194,11 +182,6 @@ def fetch_client_icon_async(appid: int | str, callback: Callable[[str], None]) -
|
||||
callback("")
|
||||
return
|
||||
|
||||
custom_icon = get_custom_icon_path(f"steam://rungameid/{appid_str}")
|
||||
if custom_icon:
|
||||
callback(custom_icon)
|
||||
return
|
||||
|
||||
cache_dir = CacheManager().cache_dir
|
||||
info_path = cache_dir / f"steamcmd_app_{appid_str}_{time.time_ns()}.json"
|
||||
url = f"https://api.steamcmd.net/v1/info/{appid_str}"
|
||||
@@ -241,12 +224,7 @@ def get_protondb_tier_async(appid: int, callback: Callable[[str], None]) -> None
|
||||
callback(cached.get("tier", ""))
|
||||
return
|
||||
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
url = f"https://deckyloader.ru/protondb/{appid}.json"
|
||||
else:
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -575,23 +553,6 @@ def get_cached_full_steam_game_info(appid: int, fallback_name: str = "") -> dict
|
||||
)
|
||||
|
||||
|
||||
def get_cached_sgdb_cover(game_name: str) -> str:
|
||||
"""Read a cached SGDB grid URL without creating a download worker."""
|
||||
cache = CacheManager()
|
||||
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
|
||||
key = f"sgdb_{safe_name}_grids"
|
||||
if not cache.is_fresh(key, SGDB_CACHE_DURATION):
|
||||
return ""
|
||||
cover = cache.load_json(key)
|
||||
if not isinstance(cover, str):
|
||||
return ""
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
cover = cover.replace("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/", 1)
|
||||
return cover
|
||||
|
||||
|
||||
def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
"""Return Steam metadata from local cache only."""
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
@@ -599,8 +560,7 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
game_exe = extract_exec_target_path(exec_line) or exec_line
|
||||
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
|
||||
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
|
||||
names = [desktop_name] if exec_line.startswith("native://") else [desktop_name, exe_name, folder_name]
|
||||
candidates = remove_duplicates(filter_candidates(names))
|
||||
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
|
||||
apps_path = CacheManager().cache_dir / "steam_apps.json"
|
||||
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
|
||||
if not steam_apps:
|
||||
@@ -663,22 +623,8 @@ def get_steam_game_info_async(
|
||||
from portprotonqt.steam_api.cache import get_exiftool_data
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
|
||||
cache_manager = CacheManager()
|
||||
match_key = "steam_match_" + hashlib.sha256(
|
||||
orjson.dumps([desktop_name, exec_line])
|
||||
).hexdigest()
|
||||
cached_match = (
|
||||
cache_manager.load_json(match_key)
|
||||
if cache_manager.is_fresh(match_key, CACHE_DURATION) else None
|
||||
)
|
||||
if not isinstance(cached_match, dict) or type(cached_match.get("appid")) is not int:
|
||||
cached_match = None
|
||||
elif cached_match["appid"] <= 0:
|
||||
cached_match = None
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_store_uri = exec_line.startswith(("gog://", "egs://"))
|
||||
is_native = exec_line.startswith("native://")
|
||||
|
||||
if game_exe.lower().endswith(('.bat', '.cmd')):
|
||||
if os.path.exists(game_exe):
|
||||
@@ -700,7 +646,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if cached_match is not None or is_store_uri or is_native:
|
||||
if is_store_uri:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -724,9 +670,9 @@ def get_steam_game_info_async(
|
||||
candidates.append(file_description)
|
||||
if desktop_name:
|
||||
candidates.append(desktop_name)
|
||||
if exe_name and not (is_store_uri or is_native):
|
||||
if exe_name and not is_store_uri:
|
||||
candidates.append(exe_name)
|
||||
if folder_name and not (is_store_uri or is_native):
|
||||
if folder_name and not is_store_uri:
|
||||
candidates.append(folder_name)
|
||||
candidates = filter_candidates(candidates)
|
||||
candidates = remove_duplicates(candidates)
|
||||
@@ -739,13 +685,10 @@ def get_steam_game_info_async(
|
||||
data_and_index: tuple[list | None, dict | None]
|
||||
) -> None:
|
||||
steam_apps, steam_apps_index = data_and_index
|
||||
matching_app = cached_match
|
||||
matching_app = None
|
||||
|
||||
if matching_app is None and (not steam_apps or not steam_apps_index):
|
||||
if not steam_apps or not steam_apps_index:
|
||||
game_name = desktop_name or exe_name
|
||||
if is_native:
|
||||
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
|
||||
return
|
||||
|
||||
def on_sgdb_cover_failure(cover: str) -> None:
|
||||
result = _build_game_info_result(
|
||||
@@ -773,20 +716,15 @@ def get_steam_game_info_async(
|
||||
return
|
||||
|
||||
for candidate in candidates_ordered:
|
||||
if matching_app:
|
||||
break
|
||||
if not candidate:
|
||||
continue
|
||||
matching_app = search_app(candidate, steam_apps_index or {})
|
||||
matching_app = search_app(candidate, steam_apps_index)
|
||||
if matching_app:
|
||||
break
|
||||
|
||||
game_name = desktop_name or exe_name.capitalize()
|
||||
|
||||
if not matching_app:
|
||||
if is_native:
|
||||
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
|
||||
return
|
||||
def on_sgdb_cover_non_steam(cover: str) -> None:
|
||||
def on_anticheat_info_non_steam(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
@@ -811,39 +749,18 @@ def get_steam_game_info_async(
|
||||
return
|
||||
|
||||
appid = matching_app["appid"]
|
||||
if cached_match is None:
|
||||
cache_manager.save_json(match_key, matching_app)
|
||||
|
||||
def on_app_info(app_info: dict | None) -> None:
|
||||
if is_native:
|
||||
info = app_info or {}
|
||||
result = {"appid": appid, "name": info.get("name", game_name),
|
||||
"description": info.get("short_description", ""),
|
||||
"controller_support": info.get("controller_support", "")}
|
||||
|
||||
def on_native_cover(cover: str) -> None:
|
||||
result["cover"] = cover
|
||||
callback(result)
|
||||
|
||||
local_cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if local_cover:
|
||||
on_native_cover(local_cover)
|
||||
else:
|
||||
fetch_sgdb_cover_async(result["name"], on_native_cover)
|
||||
return
|
||||
if not app_info:
|
||||
cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if not cover and not has_custom_data:
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
def on_anticheat_info_no_info(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
appid=appid,
|
||||
appid="",
|
||||
name=decode_text(game_name),
|
||||
description="",
|
||||
cover=cover,
|
||||
cover="",
|
||||
controller_support="",
|
||||
protondb_tier="",
|
||||
steam_game="true",
|
||||
steam_game="false",
|
||||
anticheat_status=anticheat_info.get("status", ""),
|
||||
anticheat_slug=anticheat_info.get("slug", ""),
|
||||
)
|
||||
@@ -880,7 +797,4 @@ def get_steam_game_info_async(
|
||||
|
||||
fetch_app_info_async(appid, on_app_info)
|
||||
|
||||
if cached_match is not None:
|
||||
on_steam_apps_and_index((None, None))
|
||||
else:
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
@@ -179,7 +179,7 @@ def build_index(steam_apps: list) -> dict:
|
||||
for app in steam_apps:
|
||||
normalized = app.get("normalized_name", "")
|
||||
if normalized:
|
||||
steam_apps_index.setdefault(normalized, app)
|
||||
steam_apps_index[normalized] = app
|
||||
return steam_apps_index
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
"""Steam shortcuts management for PortProtonQt."""
|
||||
|
||||
import base64
|
||||
import glob
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import zlib
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
import vdf
|
||||
import websocket
|
||||
|
||||
from PySide6.QtGui import QIcon, QImageReader
|
||||
from PySide6.QtCore import QStandardPaths
|
||||
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, parse_desktop_entry
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -40,7 +33,6 @@ from portprotonqt.steam_api.utils import (
|
||||
)
|
||||
from portprotonqt.steam_api.api import (
|
||||
get_steam_game_info_async,
|
||||
fetch_sgdb_cover_async,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -175,7 +167,7 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
def _parse_exec_line(exec_line: str) -> tuple[str | None, str]:
|
||||
"""Parse exec_line to extract executable path."""
|
||||
try:
|
||||
exe_path = exec_line if exec_line.startswith("native://") else extract_exec_target_path(exec_line)
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
logger.error("Failed to parse exec_line: %s", exec_line)
|
||||
return None, "Failed to parse executable command: no target path"
|
||||
@@ -228,33 +220,6 @@ export LD_PRELOAD=
|
||||
return script_path, ""
|
||||
|
||||
|
||||
def _resolve_desktop_icon(source: str) -> QIcon:
|
||||
"""Resolve a desktop icon through Qt, then standard XDG icon directories."""
|
||||
if not source:
|
||||
return QIcon()
|
||||
if os.path.isfile(source):
|
||||
return QIcon(source)
|
||||
icon = QIcon.fromTheme(source)
|
||||
if not icon.isNull() or os.path.basename(source) != source:
|
||||
return icon
|
||||
formats = {bytes(value.data()).decode("ascii") for value in QImageReader.supportedImageFormats()}
|
||||
filename = glob.escape(source)
|
||||
if Path(source).suffix.lstrip(".").lower() not in formats:
|
||||
filename += ".*"
|
||||
roots = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.GenericDataLocation)
|
||||
patterns = (f"icons/hicolor/*/*/{filename}", f"icons/*/*/*/{filename}",
|
||||
f"icons/{filename}", f"pixmaps/{filename}")
|
||||
for root in roots:
|
||||
for pattern in patterns:
|
||||
for path in sorted(Path(root).glob(pattern)):
|
||||
if not path.is_file() or path.suffix.lstrip(".").lower() not in formats:
|
||||
continue
|
||||
icon = QIcon(str(path))
|
||||
if not icon.isNull():
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def _generate_icon(exe_path: str, portproton_dir: str) -> str:
|
||||
"""Generate thumbnail icon for the game."""
|
||||
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', os.path.basename(exe_path))
|
||||
@@ -267,13 +232,7 @@ def _generate_icon(exe_path: str, portproton_dir: str) -> str:
|
||||
if os.path.exists(generated_icon_path):
|
||||
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
|
||||
else:
|
||||
if exe_path.endswith(".desktop"):
|
||||
entry = parse_desktop_entry(exe_path) or {}
|
||||
source = entry.get("Icon", "")
|
||||
icon = _resolve_desktop_icon(source)
|
||||
success = icon.pixmap(128, 128).save(generated_icon_path)
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if not success or not os.path.exists(generated_icon_path):
|
||||
logger.warning(f"Failed to generate thumbnail for {exe_path}")
|
||||
return ""
|
||||
@@ -369,12 +328,11 @@ def _add_shortcut_via_vdf(
|
||||
|
||||
def _download_covers(
|
||||
appid: int,
|
||||
steam_appid: tuple[int | None, str],
|
||||
steam_appid: int,
|
||||
grid_dir: str,
|
||||
was_api_used: bool
|
||||
) -> None:
|
||||
"""Download cover images for the game."""
|
||||
source_appid, game_name = steam_appid
|
||||
cover_types = [
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
("_hero.jpg", "library_hero.jpg"),
|
||||
@@ -391,7 +349,7 @@ def _download_covers(
|
||||
with open(result_path, 'rb') as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
|
||||
ext = Path(result_path).suffix.lstrip('.')
|
||||
ext = Path(steam_name).suffix.lstrip('.')
|
||||
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
|
||||
@@ -400,54 +358,18 @@ def _download_covers(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
|
||||
|
||||
def download_sgdb(index: int, cover_file: str, steam_name: str) -> None:
|
||||
image_type = ("grids", "heroes", "logos", "hgrids")[index]
|
||||
|
||||
def on_url(url: str) -> None:
|
||||
if not url.startswith((
|
||||
"https://cdn2.steamgriddb.com/",
|
||||
"https://deckyloader.ru/sgdb-cdn/",
|
||||
)):
|
||||
on_cover_download(None, steam_name, index)
|
||||
return
|
||||
extension = Path(urlsplit(url).path).suffix.lower()
|
||||
if extension not in (".png", ".jpg", ".jpeg"):
|
||||
logger.warning("Unsupported SGDB artwork format: %s", extension)
|
||||
on_cover_download(None, steam_name, index)
|
||||
return
|
||||
destination = str(Path(cover_file).with_suffix(extension))
|
||||
downloader.download_async(
|
||||
url, destination, timeout=5,
|
||||
callback=partial(on_cover_download, steam_name=steam_name, index=index),
|
||||
)
|
||||
|
||||
fetch_sgdb_cover_async(game_name, on_url, image_type)
|
||||
|
||||
def on_steam_download(
|
||||
index: int, steam_name: str, cover_file: str, result: str | None
|
||||
) -> None:
|
||||
if result:
|
||||
on_cover_download(result, steam_name, index)
|
||||
else:
|
||||
download_sgdb(index, cover_file, steam_name)
|
||||
|
||||
for i, (suffix, steam_name) in enumerate(cover_types):
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
if not source_appid:
|
||||
download_sgdb(i, cover_file, steam_name)
|
||||
continue
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{source_appid}/{steam_name}"
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=partial(on_steam_download, i, steam_name, cover_file)
|
||||
callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
|
||||
)
|
||||
|
||||
|
||||
def add_to_steam(
|
||||
game_name: str, exec_line: str, cover_path: str, icon_source: str | None = None
|
||||
) -> tuple[bool, str]:
|
||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
|
||||
and download Steam Grid covers with correct sizes and names.
|
||||
@@ -460,9 +382,7 @@ def add_to_steam(
|
||||
if not exe_path:
|
||||
return (False, error)
|
||||
|
||||
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
|
||||
local_path = exe_path.removeprefix("native://")
|
||||
if not is_store_uri and not os.path.exists(local_path):
|
||||
if not os.path.exists(exe_path):
|
||||
logger.error(f"Executable not found: {exe_path}")
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
@@ -476,9 +396,7 @@ def add_to_steam(
|
||||
if error:
|
||||
return (False, error)
|
||||
|
||||
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(local_path)
|
||||
icon_source = custom_icon or icon_source or (None if is_store_uri else local_path)
|
||||
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
|
||||
icon_path = _generate_icon(exe_path, portproton_dir)
|
||||
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
@@ -553,10 +471,10 @@ def add_to_steam(
|
||||
nonlocal steam_appid
|
||||
steam_appid = game_info.get("appid")
|
||||
if not steam_appid or not isinstance(steam_appid, int):
|
||||
_download_covers(appid, (None, game_name), str(grid_dir), was_api_used)
|
||||
logger.info("No valid Steam appid found, skipping cover download")
|
||||
return
|
||||
logger.info(f"Found Steam AppID {steam_appid} for cover download")
|
||||
_download_covers(appid, (steam_appid, game_name), str(grid_dir), was_api_used)
|
||||
_download_covers(appid, steam_appid, str(grid_dir), was_api_used)
|
||||
|
||||
get_steam_game_info_async(game_name, exec_line, on_game_info)
|
||||
|
||||
@@ -569,9 +487,10 @@ def add_to_steam(
|
||||
def _delete_cover_files(grid_dir: str, appid: int) -> None:
|
||||
"""Delete cover files for a game."""
|
||||
cover_files = [
|
||||
os.path.join(grid_dir, f"{appid}{suffix}{extension}")
|
||||
for suffix in ("", "p", "_hero", "_logo")
|
||||
for extension in (".jpg", ".png", ".jpeg")
|
||||
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
|
||||
@@ -397,11 +397,6 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
"""Rebuild auto-install cards after a live library layout change."""
|
||||
if not hasattr(self, "autoInstallContainer"):
|
||||
return
|
||||
if self.stackedWidget.currentIndex() != self.auto_install_tab_index:
|
||||
self._autoinstall_layout_pending = True
|
||||
self.autoInstallLoaded = False
|
||||
return
|
||||
self._autoinstall_layout_pending = False
|
||||
mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
@@ -492,9 +487,6 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
|
||||
def _start_autoinstall_load(self, force_refresh: bool = False) -> None:
|
||||
if getattr(self, "_autoinstall_layout_pending", False):
|
||||
self.refresh_autoinstall_layout()
|
||||
return
|
||||
if self.autoInstallLoaded or self.autoInstallLoading:
|
||||
return
|
||||
if not hasattr(self, "_on_autoinstall_games_loaded"):
|
||||
|
||||
@@ -226,7 +226,6 @@ class MainWindowControlHintsMixin:
|
||||
|
||||
keyboard_hints = [
|
||||
("key_enter", _("Select"), "confirm"),
|
||||
("key_space", _("Select multiple"), None),
|
||||
("key_backspace", _("Back"), "back"),
|
||||
("key_e", _("Add a shortcut"), "add_game"),
|
||||
("key_context", _("Menu"), "context_menu"),
|
||||
|
||||
@@ -59,7 +59,6 @@ def _format_download_size(size_mib: float) -> str:
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
|
||||
_MainWindowTypingBase = QMainWindow
|
||||
else:
|
||||
@@ -205,35 +204,6 @@ class GOGRepairWorker(QThread):
|
||||
self.error = str(error)
|
||||
|
||||
|
||||
class GOGDeleteWorker(QThread):
|
||||
"""Delete a GOG installation outside the UI thread."""
|
||||
|
||||
def __init__(self, api: GOGAPI, game: dict, menu_manager: "ContextMenuManager") -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
self.game = game
|
||||
self.menu_manager = menu_manager
|
||||
self.error = ""
|
||||
|
||||
def run(self) -> None:
|
||||
app_id = str(self.game["app_id"])
|
||||
try:
|
||||
install_path = self.api.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
raise FileNotFoundError(f"GOG installation not found: {app_id}")
|
||||
shutil.rmtree(install_path)
|
||||
manifest_path = self.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.api.remove_installed_game(app_id)
|
||||
self.menu_manager.remove_gog_shortcuts(str(self.game["title"]))
|
||||
except Exception as error:
|
||||
logger.exception("Failed to delete GOG game %s", app_id)
|
||||
self.error = str(error)
|
||||
|
||||
|
||||
class StoreDLCWorker(QThread):
|
||||
"""Read owned, installable store DLC outside the UI thread."""
|
||||
|
||||
@@ -354,7 +324,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
self.gog_process = None
|
||||
self.egs_process = None
|
||||
self.gog_repair_worker = None
|
||||
self.gog_delete_worker = None
|
||||
self.egs_install_importing = False
|
||||
self.store_download_queue = []
|
||||
self.gog_download_output = ""
|
||||
@@ -849,17 +818,9 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
api = self.egs_api if source == "egs" else self.gog_api
|
||||
worker = StoreDLCWorker(api, str(game["app_id"]))
|
||||
results: list[list[dict]] = []
|
||||
errors: list[str] = []
|
||||
worker.loaded.connect(results.append)
|
||||
worker.failed.connect(errors.append)
|
||||
worker.loaded.connect(lambda dlcs: self._on_store_dlcs_loaded(source, game, dlcs))
|
||||
worker.failed.connect(lambda error: QMessageBox.warning(self, _("Error"), error))
|
||||
worker.finished.connect(self._on_store_dlc_worker_finished)
|
||||
worker.finished.connect(
|
||||
lambda: self._on_store_dlcs_loaded(source, game, results[0]) if results else None
|
||||
)
|
||||
worker.finished.connect(
|
||||
lambda: QMessageBox.warning(self, _("Error"), errors[0]) if errors else None
|
||||
)
|
||||
self.store_dlc_worker = worker
|
||||
worker.start()
|
||||
|
||||
@@ -1090,41 +1051,38 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
if install_path is None:
|
||||
self.gogAccountStatus.setText(_("GOG installation not found"))
|
||||
return
|
||||
if getattr(self, "gog_delete_worker", None) is not None:
|
||||
self.gogAccountStatus.setText(_("Another GOG operation is already running"))
|
||||
return
|
||||
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
|
||||
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 = 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.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
worker = GOGDeleteWorker(self.gog_api, game, self.context_menu_manager)
|
||||
worker.finished.connect(self._on_gog_delete_finished)
|
||||
self.gog_delete_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_gog_delete_finished(self) -> None:
|
||||
worker = self.gog_delete_worker
|
||||
if worker is None:
|
||||
)
|
||||
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
|
||||
self.gog_delete_worker = None
|
||||
if worker.error:
|
||||
self.gogAccountStatus.setText(worker.error)
|
||||
else:
|
||||
self.loadGames(force_load=True)
|
||||
worker.deleteLater()
|
||||
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
|
||||
@@ -1146,7 +1104,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._gog_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_gog_download_output)
|
||||
process.finished.connect(self._on_gog_download_finished)
|
||||
self.gog_process = process
|
||||
@@ -1180,7 +1137,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._egs_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_egs_download_output)
|
||||
process.finished.connect(self._on_egs_download_finished)
|
||||
self.egs_process = process
|
||||
@@ -1214,7 +1170,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._egs_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_egs_download_output)
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._on_egs_operation_finished(
|
||||
@@ -1230,12 +1185,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
self.switchTab(6)
|
||||
process.start()
|
||||
|
||||
def _handle_store_process_error(self, process: QProcess, error: QProcess.ProcessError) -> None:
|
||||
if error != QProcess.ProcessError.FailedToStart:
|
||||
return
|
||||
logger.error("Failed to start store operation: %s", process.errorString())
|
||||
process.finished.emit(os.EX_UNAVAILABLE, QProcess.ExitStatus.CrashExit)
|
||||
|
||||
def _cancel_gog_download(self) -> None:
|
||||
process = self.gog_process or self.egs_process
|
||||
if process is None:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -15,7 +13,6 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
@@ -42,11 +39,9 @@ from portprotonqt.dialogs.proton_manager import (
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, save_native_settings, is_native_executable, read_native_game
|
||||
from portprotonqt.scripts_utils.shortcut_tools import find_ext_ppdb
|
||||
from portprotonqt.scripts_utils.prefix_backup import BACKUP_EXTENSION
|
||||
from portprotonqt.steam_api import get_cached_steam_game_info, is_game_in_steam
|
||||
from portprotonqt.tabs.theme_store_workers import THEME_ARCHIVE_EXTENSIONS, ThemeArchiveInstallWorker
|
||||
from portprotonqt.time_utils import format_playtime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -100,27 +95,10 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _on_library_filter_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.filter_keys):
|
||||
return
|
||||
selected = set(game_config.get_display_filter().split(","))
|
||||
key = self.filter_keys[index]
|
||||
if key in ("all", "favorites"):
|
||||
selected = {key}
|
||||
else:
|
||||
selected -= {"all", "favorites"}
|
||||
selected.symmetric_difference_update({key})
|
||||
display_filter = ",".join(key for key in self.filter_keys if key in selected) or "all"
|
||||
selected = set(display_filter.split(","))
|
||||
combo = self.gamesDisplayCombo
|
||||
combo.setCurrentIndex(self.filter_keys.index(display_filter.split(",")[0]))
|
||||
for row, source in enumerate(self.filter_keys):
|
||||
combo.setItemData(row, Qt.CheckState.Checked if source in selected
|
||||
else Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
|
||||
combo.setCurrentText(" + ".join(
|
||||
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
|
||||
if source in selected
|
||||
))
|
||||
display_filter = self.filter_keys[index]
|
||||
game_config.set_display_filter(display_filter)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton", "native")
|
||||
display_filter not in ("steam", "portproton")
|
||||
)
|
||||
self._position_library_controls_widget()
|
||||
self._on_only_installed_changed(game_config.get_only_installed())
|
||||
@@ -172,17 +150,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
card_orientation = "vertical"
|
||||
elif layout_mode in {"horizontal", "horizontal_top"}:
|
||||
card_orientation = "horizontal"
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
previous_mode = ui_config.get_library_layout_mode(theme_mode)
|
||||
previous_orientation = ui_config.get_horizontal_card_orientation("theme")
|
||||
ui_config.set_library_layout_mode(layout_mode)
|
||||
ui_config.set_horizontal_card_orientation(card_orientation)
|
||||
if (
|
||||
previous_mode == ui_config.get_library_layout_mode(theme_mode)
|
||||
and (previous_mode not in {"horizontal", "horizontal_top"}
|
||||
or previous_orientation == card_orientation)
|
||||
):
|
||||
return
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
self.game_library_manager.rebuild_library_layout(
|
||||
ui_config.get_library_layout_mode(theme_mode)
|
||||
)
|
||||
@@ -365,39 +335,22 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
display_filter = game_config.get_display_filter()
|
||||
only_installed = game_config.get_only_installed()
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "native", "favorites"]
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
|
||||
self.filter_labels = [
|
||||
_("All"),
|
||||
"Steam",
|
||||
"GOG",
|
||||
"Epic Games",
|
||||
"PortProton",
|
||||
_("Native"),
|
||||
_("Favorites"),
|
||||
]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
self._set_combo_current_key(
|
||||
self.gamesDisplayCombo, self.filter_keys, display_filter.split(",")[0]
|
||||
self.gamesDisplayCombo,
|
||||
self.filter_keys,
|
||||
display_filter,
|
||||
)
|
||||
self.gamesDisplayCombo.setEditable(True)
|
||||
line_edit = self.gamesDisplayCombo.lineEdit()
|
||||
if line_edit is not None:
|
||||
line_edit.setReadOnly(True)
|
||||
line_edit.installEventFilter(self.gamesDisplayCombo)
|
||||
self.gamesDisplayCombo.setStyleSheet(
|
||||
self.gamesDisplayCombo.styleSheet() + self.theme.SOURCE_FILTER_STYLE
|
||||
)
|
||||
selected = set(display_filter.split(","))
|
||||
for row, source in enumerate(self.filter_keys):
|
||||
self.gamesDisplayCombo.setItemData(
|
||||
row, Qt.CheckState.Checked if source in selected else Qt.CheckState.Unchecked,
|
||||
Qt.ItemDataRole.CheckStateRole,
|
||||
)
|
||||
self.gamesDisplayCombo.setCurrentText(" + ".join(
|
||||
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
|
||||
if source in selected
|
||||
))
|
||||
self.gamesDisplayCombo.activated.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
|
||||
self.onlyInstalledCheckBox = QCheckBox(_("Show only installed games"))
|
||||
@@ -410,7 +363,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
controls_layout.addWidget(self.gamesDisplayCombo, 1, 0)
|
||||
controls_layout.addWidget(self.onlyInstalledCheckBox, 4, 0)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton", "native")
|
||||
display_filter not in ("steam", "portproton")
|
||||
)
|
||||
|
||||
self.badge_view_keys = ["detailed", "compact", "hidden"]
|
||||
@@ -543,13 +496,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
source_refresh()
|
||||
|
||||
if not callable(source_loader):
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "gog" in display_filter.split(",")
|
||||
) and self.gog_api.auth_path.is_file():
|
||||
if self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "egs" in display_filter.split(",")
|
||||
) and self.egs_api.user_path.is_file():
|
||||
if self.egs_api.user_path.is_file():
|
||||
self._refresh_egs_library()
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
@@ -587,8 +536,8 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
for game in missing_games:
|
||||
@@ -655,21 +604,18 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile().lower()
|
||||
if path.endswith(
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + THEME_ARCHIVE_EXTENSIONS
|
||||
) or (os.path.isfile(url.toLocalFile()) and is_native_executable(url.toLocalFile())):
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + WINE_ARCHIVE_EXTENSIONS
|
||||
):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event):
|
||||
if any(worker.isRunning() for worker in getattr(self, "_themeArchiveWorkers", [])):
|
||||
event.ignore()
|
||||
return
|
||||
wine_archives = []
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile()
|
||||
path_lower = path.lower()
|
||||
if path_lower.endswith(THEME_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
if path_lower.endswith(WINE_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
wine_archives.append(path)
|
||||
continue
|
||||
if path_lower.endswith(BACKUP_EXTENSION):
|
||||
@@ -680,60 +626,18 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.open_local_autoinstall_card(path)
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS) or (os.path.isfile(path) and is_native_executable(path)):
|
||||
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS):
|
||||
self.openAddGameDialog(path)
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if wine_archives:
|
||||
self._install_dropped_theme_archives(wine_archives)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _install_dropped_theme_archives(self, paths: list[str]) -> None:
|
||||
progress = QProgressDialog(_("Installing…"), "", 0, 0, self)
|
||||
progress.setWindowTitle(_("Theme"))
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setCancelButton(None)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
worker = ThemeArchiveInstallWorker(paths)
|
||||
workers = getattr(self, "_themeArchiveWorkers", [])
|
||||
self._themeArchiveWorkers = workers
|
||||
workers.append(worker)
|
||||
worker.installed.connect(self._on_dropped_theme_installed)
|
||||
worker.failed.connect(progress.close)
|
||||
worker.failed.connect(self._on_dropped_theme_failed)
|
||||
worker.wine_archives.connect(progress.close)
|
||||
worker.wine_archives.connect(self._on_dropped_wine_archives)
|
||||
worker.finished.connect(progress.close)
|
||||
worker.finished.connect(progress.deleteLater)
|
||||
worker.finished.connect(lambda: workers.remove(worker))
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _on_dropped_theme_installed(self, theme_names: list[str]) -> None:
|
||||
for theme_name in theme_names:
|
||||
self.theme_manager.invalidate_theme(theme_name)
|
||||
variant = ui_config.get_theme_variant()
|
||||
theme_name = ui_config.resolve_theme(theme_names[0], variant)
|
||||
if theme_name not in theme_names:
|
||||
theme_name = theme_names[0]
|
||||
self._refresh_theme_combo(theme_name)
|
||||
self._apply_theme_and_restart(theme_name, variant)
|
||||
|
||||
def _on_dropped_theme_failed(self, message: str) -> None:
|
||||
logger.warning("Failed to install dropped theme: %s", message)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to install theme"))
|
||||
|
||||
def _on_dropped_wine_archives(self, paths: list[str]) -> None:
|
||||
wine_paths = [path for path in paths if path.lower().endswith(WINE_ARCHIVE_EXTENSIONS)]
|
||||
if len(wine_paths) != len(paths):
|
||||
self._on_dropped_theme_failed("No themes found in archive")
|
||||
if wine_paths:
|
||||
show_proton_manager(
|
||||
self, self.portproton_location,
|
||||
input_manager=self.input_manager, local_archives=wine_paths,
|
||||
self,
|
||||
self.portproton_location,
|
||||
input_manager=self.input_manager,
|
||||
local_archives=wine_archives,
|
||||
)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def openAddGameDialog(self, exe_path=None):
|
||||
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
|
||||
@@ -759,9 +663,6 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
name = dialog.nameEdit.text().strip()
|
||||
exe_path = dialog.exeEdit.text().strip()
|
||||
user_cover = dialog.coverEdit.text().strip()
|
||||
native = is_native_executable(exe_path)
|
||||
if native:
|
||||
user_cover = dialog.last_cover_path or user_cover
|
||||
|
||||
if not name or not exe_path:
|
||||
return
|
||||
@@ -796,30 +697,14 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
entry = parse_desktop_entry(desktop_path)
|
||||
if not entry:
|
||||
return
|
||||
if not native:
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
elif cover_path:
|
||||
desktop_entry = re.sub(r"^Icon=.*$", lambda _match: f"Icon={cover_path}", desktop_entry, flags=re.MULTILINE)
|
||||
self._write_desktop_file(desktop_entry, desktop_path)
|
||||
if native:
|
||||
try:
|
||||
save_native_settings(desktop_path, {})
|
||||
except Exception as error:
|
||||
logger.warning("Cannot create native PPDB: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
for source in ("all", "native", "favorites"):
|
||||
getattr(self, "_loaded_library_cache", {}).pop(source, None)
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
description = entry.get("Comment", "")
|
||||
exec_line = NATIVE_URI_PREFIX + desktop_path if native else entry.get("Exec", exe_path)
|
||||
exec_line = entry.get("Exec", exe_path)
|
||||
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
|
||||
self._sync_game_shortcuts_from_dialog(dialog, name, exec_line, cover_for_shortcut)
|
||||
|
||||
# User cover fallback
|
||||
user_cover_path = cover_path # Already set if user provided
|
||||
native_game = read_native_game(Path(desktop_path)) if native else None
|
||||
if native_game:
|
||||
user_cover_path = native_game[2] or user_cover_path
|
||||
|
||||
# Statistics (playtime, last launch - defaults for new)
|
||||
playtime_seconds = 0
|
||||
@@ -868,15 +753,13 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
steam_info.get("anticheat_status", ""),
|
||||
last_played_timestamp,
|
||||
playtime_seconds,
|
||||
"native" if native else "portproton",
|
||||
"portproton",
|
||||
steam_info.get("anticheat_slug", ""),
|
||||
steam_info.get("ppdb_id", ""),
|
||||
steam_info.get("ppdb_rating", ""),
|
||||
)
|
||||
|
||||
# Incremental add
|
||||
if native and game_config.get_display_filter() not in ("all", "native"):
|
||||
return
|
||||
self.game_library_manager.add_game_incremental(game_data)
|
||||
|
||||
# Trigger visible images load
|
||||
@@ -898,7 +781,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
cached_steam_info.get("anticheat_status", ""),
|
||||
last_played_timestamp,
|
||||
playtime_seconds,
|
||||
"native" if native else "portproton",
|
||||
"portproton",
|
||||
cached_steam_info.get("anticheat_slug", ""),
|
||||
cached_steam_info.get("ppdb_id", ""),
|
||||
cached_steam_info.get("ppdb_rating", ""),
|
||||
@@ -910,27 +793,6 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def _sync_game_shortcuts_from_dialog(self, dialog, game_name, exec_line, cover_path):
|
||||
"""Apply shortcut options selected in add/edit game dialog."""
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
for directory, checkbox in (
|
||||
(os.path.join(data_home, "applications"), dialog.add_to_menu_checkbox),
|
||||
(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), dialog.add_to_desktop_checkbox),
|
||||
):
|
||||
if directory and checkbox.isChecked():
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
shutil.copyfile(source, Path(directory) / source.name)
|
||||
os.chmod(Path(directory) / source.name, 0o755)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to copy native shortcut to %s: %s", directory, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(game_name):
|
||||
self.context_menu_manager.add_to_steam(game_name, exec_line, cover_path)
|
||||
elif is_game_in_steam(game_name):
|
||||
self.context_menu_manager.remove_from_steam(game_name, exec_line, "native")
|
||||
return
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
menu_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
||||
if dialog.add_to_menu_checkbox.isChecked():
|
||||
|
||||
@@ -508,33 +508,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
crash_reports_enabled_layout.addStretch()
|
||||
uiForm.addRow(crash_reports_enabled_layout)
|
||||
|
||||
self.disableCyrillicPathCheckCheckBox = QCheckBox()
|
||||
self.disableCyrillicPathCheckCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.disableCyrillicPathCheckCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
disable_cyrillic_path_check_title = QLabel(
|
||||
_("Disable Cyrillic path warning")
|
||||
)
|
||||
disable_cyrillic_path_check_title.setSizePolicy(
|
||||
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
disable_cyrillic_path_check_title.setStyleSheet(
|
||||
self.theme.SETTINGS_TITLE_CHECKBOX_STYLE
|
||||
)
|
||||
disable_cyrillic_path_check_title.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.disableCyrillicPathCheckCheckBox.setChecked(
|
||||
ui_config.get_disable_cyrillic_path_check()
|
||||
)
|
||||
disable_cyrillic_path_check_layout = QHBoxLayout()
|
||||
disable_cyrillic_path_check_layout.setContentsMargins(0, 0, 0, 0)
|
||||
disable_cyrillic_path_check_layout.addWidget(
|
||||
self.disableCyrillicPathCheckCheckBox
|
||||
)
|
||||
disable_cyrillic_path_check_layout.addWidget(
|
||||
disable_cyrillic_path_check_title
|
||||
)
|
||||
disable_cyrillic_path_check_layout.addStretch()
|
||||
uiForm.addRow(disable_cyrillic_path_check_layout)
|
||||
|
||||
self.disableAltDependencyCheckCheckBox: QCheckBox | None = None
|
||||
if self._is_alt_x86_64():
|
||||
self.disableAltDependencyCheckCheckBox = QCheckBox()
|
||||
@@ -920,8 +893,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to reset all settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
if reset_settings():
|
||||
@@ -940,8 +913,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Migrate old PortProton shortcuts to PortProtonQt format?"))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -957,8 +930,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to clear the cache? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
cache_config.clear_cache()
|
||||
@@ -1222,9 +1195,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
ui_config.set_crash_reports_enabled(
|
||||
self.crashReportsEnabledCheckBox.isChecked()
|
||||
)
|
||||
ui_config.set_disable_cyrillic_path_check(
|
||||
self.disableCyrillicPathCheckCheckBox.isChecked()
|
||||
)
|
||||
if self.disableAltDependencyCheckCheckBox is not None:
|
||||
ui_config.set_disable_alt_i586_dependency_check(
|
||||
self.disableAltDependencyCheckCheckBox.isChecked()
|
||||
|
||||
@@ -1530,8 +1530,8 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(request.get("message", ""))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
button = msg_box.exec()
|
||||
worker.submit_pairing_response("yes" if button == QMessageBox.StandardButton.Yes else "no")
|
||||
return
|
||||
|
||||
@@ -21,7 +21,6 @@ THEME_STORE_IMAGE_WORKERS = 8
|
||||
THEME_STORE_CARD_BATCH_SIZE = 20
|
||||
THEME_STORE_VOTES_ICON = "★"
|
||||
THEME_STORE_DOWNLOADS_ICON = "⇩"
|
||||
THEME_ARCHIVE_EXTENSIONS = (".zip", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".tar.bz2")
|
||||
|
||||
|
||||
def _theme_store_download_url(theme_id: int) -> str:
|
||||
@@ -127,40 +126,6 @@ def _install_theme_archive(archive_path: str) -> list[str]:
|
||||
return installed_names
|
||||
|
||||
|
||||
class ThemeArchiveInstallWorker(QThread):
|
||||
installed = Signal(list)
|
||||
failed = Signal(str)
|
||||
wine_archives = Signal(list)
|
||||
|
||||
def __init__(self, paths: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.paths = paths
|
||||
|
||||
def run(self) -> None:
|
||||
wine_paths = []
|
||||
for path in self.paths:
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
if zipfile.is_zipfile(path):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = archive.namelist()
|
||||
else:
|
||||
with tarfile.open(path) as archive:
|
||||
names = archive.getnames()
|
||||
if not any(name.rstrip("/").split("/")[-1] == "styles.py" for name in names):
|
||||
wine_paths.append(path)
|
||||
continue
|
||||
installed = _install_theme_archive(path)
|
||||
if not installed:
|
||||
raise ValueError("No themes found in archive")
|
||||
self.installed.emit(installed)
|
||||
except (OSError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error:
|
||||
self.failed.emit(str(error))
|
||||
if wine_paths and not self.isInterruptionRequested():
|
||||
self.wine_archives.emit(wine_paths)
|
||||
|
||||
|
||||
class ThemeStoreListWorker(QThread):
|
||||
loaded = Signal(list)
|
||||
failed = Signal(str)
|
||||
|
||||
@@ -287,8 +287,8 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
removal_results = [
|
||||
|
||||
@@ -336,8 +336,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to clear prefix '{}'?").format(selected_prefix))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -443,8 +443,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to delete prefix '{}'?").format(selected_prefix))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
reply = msg_box.exec()
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
|
||||
@@ -5,17 +5,6 @@ logger = get_logger(__name__)
|
||||
|
||||
class MainWindowWorkersMixin:
|
||||
def _stopBackgroundWorkers(self) -> None:
|
||||
context_menu_manager = getattr(self, "context_menu_manager", None)
|
||||
if context_menu_manager is not None:
|
||||
context_menu_manager.stop_card_actions()
|
||||
self.store_download_queue = []
|
||||
for process_name in ("gog_process", "egs_process"):
|
||||
process = getattr(self, process_name, None)
|
||||
if process is not None:
|
||||
process.terminate()
|
||||
if not process.waitForFinished():
|
||||
process.kill()
|
||||
process.waitForFinished()
|
||||
worker_timeouts = {
|
||||
"networkWorker": 3000,
|
||||
"bluetoothWorker": 12000,
|
||||
@@ -32,9 +21,7 @@ class MainWindowWorkersMixin:
|
||||
"egs_auth_worker": None,
|
||||
"gog_library_worker": None,
|
||||
"gog_metadata_worker": None,
|
||||
"native_library_worker": None,
|
||||
"gog_repair_worker": None,
|
||||
"gog_delete_worker": None,
|
||||
"store_dlc_worker": None,
|
||||
"gog_auth_worker": None,
|
||||
"gog_account_worker": None,
|
||||
@@ -51,8 +38,6 @@ class MainWindowWorkersMixin:
|
||||
"_listWorkerPool",
|
||||
"_imageWorkerPool",
|
||||
"_detailImageWorkerPool",
|
||||
"_themeArchiveWorkers",
|
||||
"_gameFolderWorkers",
|
||||
):
|
||||
self._stopWorkerThreads(workers_name)
|
||||
downloader = getattr(self, "downloader", None)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 386 B |
@@ -1 +0,0 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 383 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0008 1a6.9996 6.9996 0 0 0-7.0008 6.9992 6.9996 6.9996 0 0 0 7.0008 7.0008 6.9996 6.9996 0 0 0 6.9992-7.0008 6.9996 6.9996 0 0 0-6.9992-6.9992zm0.03052 1.8345c0.49138 0 0.68069 0.026991 1.0522 0.151 0.77261 0.25794 1.4608 0.88663 1.857 1.698 0.36432 0.74592 0.47136 1.542 0.34538 2.5446-0.02796 0.2226-0.0498 0.49442-0.0498 0.60402v0.1992l0.24578 0.24739c0.8226 0.82687 1.5016 1.8186 1.8892 2.7614 0.0076 0.01852 0.01187 0.0377 0.01928 0.05622-0.49301 0.83904-1.1794 1.5485-2.0016 2.0691-0.04447-0.3775-0.13313-0.76334-0.26667-1.147-0.33295-0.95658-0.86947-1.775-1.5422-2.3534-0.22323-0.19199-0.53694-0.41582-0.75181-0.53655-0.0648-0.036441-0.11405-0.069295-0.11084-0.072289 0.0035-0.00285 0.10384-0.043441 0.22329-0.08996 0.47005-0.18305 0.84651-0.41233 1.1438-0.69558 0.4802-0.45754 0.7046-0.96565 0.70361-1.5984-0.0012-0.60346-0.212-1.0975-0.64418-1.5133-0.37522-0.36091-0.8574-0.54398-1.3639-0.53815-0.30388 0.003495-0.61702 0.07465-0.92048 0.21687-0.40675 0.1906-0.86555 0.57266-1.2353 1.0265-0.19966 0.24497-0.64341 0.92641-0.61687 1.0008 0.17687 0.49573 0.05014 0.89774-0.68755 1.1213-0.14863 0.00956-0.2752 0.022977-0.28112 0.028916-0.01608 0.016029 0.06854 0.1696 0.16707 0.30843 0.14523 0.20473 0.39623 0.4208 0.6763 0.57992 0.0689 0.039149 0.12571 0.076335 0.1253 0.081928 0 0.00577-0.07689 0.054243-0.16867 0.10763-0.66042 0.38426-1.248 1.1985-1.6 2.2201-0.14407 0.4181-0.23536 0.77816-0.28755 1.1454-0.55629-0.51797-1.0112-1.1354-1.3414-1.8201 0.18238-0.35341 0.3666-0.624 0.69237-1.0217 0.32937-0.40208 0.89278-0.92816 1.3494-1.2594l0.17671-0.12851v-0.12209c1e-6 -0.11617-0.0027-0.11963-0.04659-0.10602-0.13353 0.041394-0.41447 0.20677-0.53012 0.31165l-0.05783 0.053012 0.01125-0.24418c0.01809-0.40899 0.1444-0.77469 0.40482-1.1647l0.13815-0.20562-0.0498-0.1751c-0.1341-0.46267-0.11978-1.0338 0.04016-1.5133 0.15572-0.46694 0.38428-0.82639 0.76144-1.2032 0.5179-0.51738 1.0636-0.80828 1.8201-0.96867 0.24284-0.051483 0.30063-0.056286 0.71165-0.056225zm0.45783 3.1325c0.39072-0.00731 0.65853 0.36199 0.5494 0.75663-0.03707 0.13416-0.22104 0.31614-0.36466 0.35823-0.13428 0.039318-0.26887 0.039149-0.38072 0v-0.00161c-0.19556-0.069357-0.35076-0.27993-0.36787-0.5012-0.01367-0.17688 0.04474-0.32481 0.17831-0.4498 0.12845-0.12009 0.21938-0.15913 0.38554-0.16225zm-0.14297 0.33574c-0.06851 0.00128-0.10608 0.017958-0.15904 0.06747-0.05507 0.051532-0.07953 0.11181-0.0739 0.18474 0.0071 0.091227 0.07199 0.17863 0.15261 0.20723 0.04612 0.016141 0.10046 0.01621 0.15582 0 0.05921-0.017353 0.13572-0.090873 0.151-0.14618 0.045-0.1627-0.06542-0.31627-0.22651-0.31325zm-3.0185 0.55904c-0.01687-1.298e-4 -0.03412 0.00176-0.05301 0.00321-0.12511 0.00961-0.15006 0.020885-0.27149 0.12048-0.15317 0.12564-0.3168 0.33226-0.45301 0.57349-0.10345 0.18332-0.24198 0.53868-0.2008 0.51566 0.0143-0.00799 0.11568-0.087633 0.22329-0.17671 0.26056-0.21554 0.35801-0.28499 0.67148-0.47068 0.36751-0.21785 0.41804-0.28276 0.32932-0.42892-0.05724-0.094184-0.12772-0.13564-0.24578-0.13655z" fill="#fff" stroke-width="0"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -727,25 +727,6 @@ COMBOBOX_STYLE = f"""
|
||||
}}
|
||||
"""
|
||||
|
||||
SOURCE_FILTER_STYLE = f"""
|
||||
QComboBox QAbstractItemView {{
|
||||
background: {color_surface};
|
||||
}}
|
||||
QListView::indicator {{
|
||||
width: {sourceFilterIndicatorSize}px;
|
||||
height: {sourceFilterIndicatorSize}px;
|
||||
border: {border_thin} {color_border_input};
|
||||
border-radius: {sourceFilterIndicatorRadius}px;
|
||||
background: {color_bg};
|
||||
image: none;
|
||||
}}
|
||||
QListView::indicator:checked {{
|
||||
background: {color_accent};
|
||||
border: {border_thin} {color_text};
|
||||
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||
}}
|
||||
"""
|
||||
|
||||
SCROLL_STYLE = f"""
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
|
||||
@@ -10,8 +10,6 @@ bootAnimationTimeoutMs = 30000
|
||||
cursorHideTimeoutMs = 5000
|
||||
startupLibraryLoadDelayMs = 500
|
||||
autoSizeButtonPadding = (10, 20)
|
||||
sourceFilterIndicatorSize = 18
|
||||
sourceFilterIndicatorRadius = 2
|
||||
favoriteLabelSize = 48, 48
|
||||
favoriteLabelIconSize = 32
|
||||
detailCompactCoverFrameSize = 128
|
||||
@@ -368,7 +366,3 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
WINETRICKS_CHECK_COLUMN_WIDTH = 80
|
||||
WINETRICKS_ROW_HEIGHT = 36
|
||||
|
||||
GAME_FOLDER_IMPORT_DIALOG_SIZE = (800, 600)
|
||||
|
||||
RUN_AFTER_VALUE_WIDTH = 230
|
||||
@@ -177,9 +177,6 @@ def test_app_restarts_installed_appimage_after_integration(
|
||||
lambda: SimpleNamespace(
|
||||
debug_level="NOTSET",
|
||||
reinstall_steam_compat_tool=True,
|
||||
list_games=False,
|
||||
game_info=None,
|
||||
edit_game=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -141,14 +141,6 @@ def test_crash_reports_config_defaults_to_enabled(tmp_path: Path):
|
||||
assert config.get_crash_reports_enabled() is False
|
||||
|
||||
|
||||
def test_cyrillic_path_check_config_defaults_to_enabled(tmp_path: Path):
|
||||
config = UIConfig(config_file=tmp_path / "test.conf")
|
||||
|
||||
assert config.get_disable_cyrillic_path_check() is False
|
||||
config.set_disable_cyrillic_path_check(True)
|
||||
assert config.get_disable_cyrillic_path_check() is True
|
||||
|
||||
|
||||
def test_alt_dependency_check_config_defaults_to_enabled(tmp_path: Path):
|
||||
config = UIConfig(config_file=tmp_path / "test.conf")
|
||||
|
||||
@@ -196,45 +188,18 @@ def test_horizontal_card_orientation_uses_theme_until_overridden(tmp_path: Path)
|
||||
@pytest.mark.parametrize("system_locale,force_english,expected", [
|
||||
("ru_RU", False, "ru_RU"),
|
||||
(None, False, "en"),
|
||||
("C", False, "en"),
|
||||
("c", False, "en"),
|
||||
("C.UTF-8", False, "en"),
|
||||
("POSIX", False, "en"),
|
||||
("unknown", False, "en"),
|
||||
("ru_RU", True, "en"),
|
||||
])
|
||||
def test_system_locale(
|
||||
monkeypatch: pytest.MonkeyPatch, system_locale: str | None,
|
||||
force_english: bool, expected: str,
|
||||
) -> None:
|
||||
monkeypatch.delenv("LANGUAGE", raising=False)
|
||||
monkeypatch.setattr(localization.locale, "getlocale", lambda: (system_locale, "UTF-8"))
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_force_english", lambda: force_english)
|
||||
|
||||
assert localization.get_system_locale() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language,expected", [
|
||||
("de", "german"),
|
||||
("ru", "russian"),
|
||||
("ru_RU.UTF-8", "russian"),
|
||||
("de:ru", "german"),
|
||||
("unknown:de", "german"),
|
||||
("", "russian"),
|
||||
("unknown", "russian"),
|
||||
])
|
||||
def test_steam_language_from_environment(
|
||||
monkeypatch: pytest.MonkeyPatch, language: str, expected: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGUAGE", language)
|
||||
monkeypatch.setenv("FULL_LN", "")
|
||||
monkeypatch.setattr(localization.locale, "getlocale", lambda: ("ru_RU", "UTF-8"))
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_force_english", lambda: False)
|
||||
|
||||
assert localization.get_steam_language() == expected
|
||||
assert localization.os.environ["FULL_LN"] == expected
|
||||
|
||||
|
||||
def test_translation_can_switch_to_english_without_restart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
|
||||
@@ -11,6 +11,10 @@ class TestCacheManagerInit:
|
||||
CacheManager(cache_dir=cache_dir)
|
||||
assert cache_dir.exists()
|
||||
|
||||
def test_default_cache_dir(self):
|
||||
mgr = CacheManager()
|
||||
assert mgr.cache_dir.exists()
|
||||
|
||||
|
||||
class TestGetCacheFilePath:
|
||||
def test_sanitizes_name(self, tmp_path: Path):
|
||||
@@ -52,8 +56,7 @@ class TestSaveLoadJson:
|
||||
|
||||
def test_pretty_print(self, tmp_path: Path):
|
||||
mgr = CacheManager(cache_dir=tmp_path)
|
||||
assert mgr.save_json("test", {"a": 1}, pretty=True)
|
||||
assert mgr._get_cache_file_path("test").read_text() == '{\n "a": 1\n}'
|
||||
mgr.save_json("test", {"a": 1}, pretty=True)
|
||||
loaded = mgr.load_json("test")
|
||||
assert loaded == {"a": 1}
|
||||
|
||||
|
||||
+60
-591
@@ -16,6 +16,8 @@ from portprotonqt.cli import (
|
||||
parse_portprotonqt_theme_url,
|
||||
parse_resolution,
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
PP_EXTENSIONS,
|
||||
PREFIX_BACKUP_EXTENSION,
|
||||
parse_args,
|
||||
)
|
||||
|
||||
@@ -35,9 +37,53 @@ class TestParseArgs:
|
||||
assert args.file_or_url == "file:///tmp/game.exe"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stopped", [False, True])
|
||||
def test_silent_tray_toggles_pause(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stopped: bool) -> None:
|
||||
import signal
|
||||
import psutil
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from portprotonqt import app, time_utils
|
||||
|
||||
qt_app = QApplication.instance() or QApplication([])
|
||||
assert isinstance(qt_app, QApplication)
|
||||
tray = MagicMock()
|
||||
timer = MagicMock()
|
||||
launcher = MagicMock(pid=123)
|
||||
launcher.poll.return_value = None
|
||||
child = MagicMock(pid=124)
|
||||
status = psutil.STATUS_STOPPED if stopped else psutil.STATUS_RUNNING
|
||||
child.status.return_value = status
|
||||
parent = MagicMock()
|
||||
parent.children.return_value = [child]
|
||||
monkeypatch.setattr(app, "QSystemTrayIcon", MagicMock(return_value=tray))
|
||||
monkeypatch.setattr(app, "QTimer", MagicMock(return_value=timer))
|
||||
monkeypatch.setattr(app, "get_portproton_tray_icon", MagicMock())
|
||||
monkeypatch.setattr(app.subprocess, "Popen", MagicMock(return_value=launcher))
|
||||
monkeypatch.setattr(app.psutil, "Process", MagicMock(return_value=parent))
|
||||
monkeypatch.setattr(time_utils, "save_last_launch", MagicMock())
|
||||
monkeypatch.setattr(time_utils, "save_playtime", MagicMock())
|
||||
|
||||
app.run_silent_tray(qt_app, ["start.sh"], str(tmp_path / "game.exe"))
|
||||
menu = tray.setContextMenu.call_args.args[0]
|
||||
menu.aboutToShow.emit()
|
||||
pause, stop = menu.actions()
|
||||
assert pause.isEnabled()
|
||||
assert pause.text() == app._("Resume Game" if stopped else "Pause Game")
|
||||
assert stop.text() == app._("Stop Game")
|
||||
pause.trigger()
|
||||
child.send_signal.assert_called_once_with(signal.SIGCONT if stopped else signal.SIGSTOP)
|
||||
parent.children.assert_called_with(recursive=True)
|
||||
|
||||
parent.children.return_value = []
|
||||
menu.aboutToShow.emit()
|
||||
assert not pause.isEnabled()
|
||||
pause.trigger()
|
||||
assert child.send_signal.call_count == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
|
||||
from PySide6 import QtCore, QtNetwork
|
||||
from PySide6 import QtNetwork
|
||||
from portprotonqt import app, main_window, port_data_path_selector
|
||||
|
||||
monkeypatch.delenv("STEAM_COMPAT", raising=False)
|
||||
@@ -49,8 +95,6 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
|
||||
monkeypatch.setenv("LD_PRELOAD", "libMangoHud_shim.so")
|
||||
monkeypatch.setattr(app, "get_steam_language", lambda: "english")
|
||||
monkeypatch.setattr(app, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr("portprotonqt.native_games.get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(app, "get_portproton_start_command", lambda: ["start.sh"])
|
||||
monkeypatch.setattr(app, "consume_download_counter_skip", lambda: False)
|
||||
monkeypatch.setattr(app, "update_app_version", lambda _version: False)
|
||||
@@ -60,21 +104,19 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
|
||||
qt_app = MagicMock()
|
||||
qt_app.exec.return_value = 0
|
||||
monkeypatch.setattr(app, "QApplication", MagicMock(return_value=qt_app))
|
||||
lock = MagicMock()
|
||||
lock.tryLock.return_value = True
|
||||
monkeypatch.setattr(QtCore, "QLockFile", MagicMock(return_value=lock))
|
||||
monkeypatch.setattr(QtCore.QStandardPaths, "writableLocation", lambda _location: str(tmp_path))
|
||||
socket = MagicMock()
|
||||
socket.waitForConnected.return_value = False
|
||||
server = MagicMock()
|
||||
monkeypatch.setattr(QtNetwork, "QLocalSocket", MagicMock(return_value=socket))
|
||||
monkeypatch.setattr(QtNetwork, "QLocalServer", server)
|
||||
tray = MagicMock()
|
||||
window = MagicMock(side_effect=RuntimeError("main window reached"))
|
||||
monkeypatch.setattr(app, "run_silent_tray", tray)
|
||||
monkeypatch.setattr(main_window, "MainWindow", window)
|
||||
exe = tmp_path / "my game.exe"
|
||||
exe.touch()
|
||||
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server, lock=lock,
|
||||
window=window, exe=exe)
|
||||
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server,
|
||||
tray=tray, window=window, exe=exe)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_uri", [False, True])
|
||||
@@ -88,6 +130,7 @@ def test_silent_exe_keeps_main_window_for_emulation(
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
assert startup.window.call_args.kwargs["launch_exe"] == str(startup.exe)
|
||||
assert os.environ["MANGOHUD"] == "1"
|
||||
@@ -104,114 +147,11 @@ def test_silent_exe_uses_existing_instance(
|
||||
startup.app.main()
|
||||
|
||||
startup.socket.write.assert_called_once_with(f"silent:{startup.exe}".encode())
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_not_called()
|
||||
startup.qt_app.exec.assert_not_called()
|
||||
|
||||
|
||||
def test_existing_instance_with_unavailable_ipc_does_not_start_another(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
startup = silent_startup
|
||||
startup.lock.tryLock.return_value = False
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--silent", str(startup.exe)])
|
||||
|
||||
startup.app.main()
|
||||
|
||||
startup.server.removeServer.assert_not_called()
|
||||
startup.server.assert_not_called()
|
||||
startup.window.assert_not_called()
|
||||
startup.qt_app.exec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", [False, True])
|
||||
def test_silent_launch_skips_startup_workers(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, silent: bool,
|
||||
) -> None:
|
||||
from PySide6.QtCore import QThread
|
||||
from portprotonqt import appimage_updater
|
||||
|
||||
startup = silent_startup
|
||||
window = MagicMock()
|
||||
startup.window.side_effect = None
|
||||
startup.window.return_value = window
|
||||
monkeypatch.setattr(startup.app, "QTimer", MagicMock())
|
||||
monkeypatch.setattr(QThread, "start", MagicMock())
|
||||
update_worker = MagicMock()
|
||||
monkeypatch.setattr(appimage_updater, "AppImageUpdateWorker", update_worker)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "set_fullscreen", MagicMock())
|
||||
flags = ["--silent"] if silent else []
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(startup.exe)])
|
||||
|
||||
with pytest.raises(SystemExit) as result:
|
||||
startup.app.main()
|
||||
|
||||
assert result.value.code == 0
|
||||
assert window.silent_launch_mode is silent
|
||||
assert ("initialCommandWorker" in vars(window)) is (not silent)
|
||||
assert update_worker.called is (not silent)
|
||||
assert ("appimageUpdateWorker" in vars(window)) is (not silent)
|
||||
startup.window.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["exe", "gog://launch/123", "egs://launch/example"])
|
||||
@pytest.mark.parametrize("decision", [
|
||||
("No", True, False), ("Yes", True, False), ("No", False, False),
|
||||
("Yes", False, False), ("Yes", True, True),
|
||||
])
|
||||
def test_silent_ipc_confirms_restarting_application(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch,
|
||||
target: str, decision: tuple[str, bool, bool],
|
||||
) -> None:
|
||||
from PySide6.QtCore import QThread
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
startup = silent_startup
|
||||
reply, running, stop_failed = decision
|
||||
window = MagicMock(game_processes=[])
|
||||
startup.window.side_effect = None
|
||||
startup.window.return_value = window
|
||||
timers = MagicMock()
|
||||
monkeypatch.setattr(startup.app, "QTimer", timers)
|
||||
monkeypatch.setattr(QThread, "start", MagicMock())
|
||||
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--silent", str(startup.exe)])
|
||||
question = MagicMock()
|
||||
question.exec.return_value = getattr(QMessageBox.StandardButton, reply)
|
||||
message_box = MagicMock(return_value=question, StandardButton=QMessageBox.StandardButton,
|
||||
Icon=QMessageBox.Icon)
|
||||
monkeypatch.setattr(startup.app, "QMessageBox", message_box)
|
||||
restart, stop = MagicMock(), MagicMock()
|
||||
monkeypatch.setattr(startup.app.os, "execv", restart)
|
||||
monkeypatch.setattr(startup.app.psutil, "Process", MagicMock(return_value=MagicMock(children=lambda **kw: [])))
|
||||
monkeypatch.setattr(startup.app.psutil, "wait_procs", MagicMock(return_value=([], [])))
|
||||
stop.side_effect = OSError("stop failed") if stop_failed else None
|
||||
monkeypatch.setattr(startup.app.subprocess, "run", stop)
|
||||
target = str(startup.exe) if target == "exe" else target
|
||||
def event_loop() -> int:
|
||||
timers.singleShot.call_args_list[0].args[1]()
|
||||
message_box.assert_not_called()
|
||||
window.game_processes = [MagicMock()] if running else []
|
||||
conn = MagicMock()
|
||||
conn.readAll.return_value.data.return_value = f"silent:{target}".encode()
|
||||
startup.server.return_value.nextPendingConnection.return_value = conn
|
||||
startup.server.return_value.newConnection.connect.call_args.args[0]()
|
||||
timers.singleShot.call_args.args[1]()
|
||||
return 0
|
||||
startup.qt_app.exec.side_effect = event_loop
|
||||
with pytest.raises(SystemExit):
|
||||
startup.app.main()
|
||||
question.setDefaultButton.assert_called_once_with(QMessageBox.StandardButton.No)
|
||||
window.toggleGame.assert_called_once_with("'" + str(startup.exe) + "'")
|
||||
assert startup.qt_app.quit.called is (reply == "Yes")
|
||||
assert (stop.called, window._stopBackgroundWorkers.called) == (reply == "Yes",) * 2
|
||||
if reply == "Yes" and not stop_failed:
|
||||
restart.assert_called_once_with(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", target])
|
||||
else:
|
||||
assert not restart.called and not startup.lock.unlock.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags,target", [
|
||||
([], "exe"),
|
||||
(["--silent", "--log"], "exe"),
|
||||
@@ -231,62 +171,10 @@ def test_other_launch_modes_keep_main_window(
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [[], ["--silent"], ["--log"], ["--silent", "--log"]])
|
||||
@pytest.mark.parametrize("existing", [False, True])
|
||||
def test_native_shortcut_launch_modes(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, flags: list[str], existing: bool,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
from PySide6.QtCore import QThread
|
||||
from portprotonqt.native_games import create_native_desktop_file
|
||||
|
||||
startup = silent_startup
|
||||
executable = startup.exe.with_name("native game")
|
||||
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||
executable.chmod(0o755)
|
||||
result = create_native_desktop_file(str(executable), "Native")
|
||||
assert result is not None
|
||||
content, destination = result
|
||||
Path(destination).write_text(content)
|
||||
uri = "native://" + destination
|
||||
startup.socket.waitForConnected.return_value = existing
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(executable)])
|
||||
mode = "log" if "--log" in flags else "silent" if "--silent" in flags else "open"
|
||||
if existing:
|
||||
startup.app.main()
|
||||
startup.socket.write.assert_called_once_with(f"{mode}:{quote(uri, safe='')}".encode())
|
||||
startup.window.assert_not_called()
|
||||
return
|
||||
window, timers = MagicMock(), MagicMock()
|
||||
window.game_processes = []
|
||||
startup.window.side_effect, startup.window.return_value = None, window
|
||||
monkeypatch.setattr(startup.app, "QTimer", timers)
|
||||
monkeypatch.setattr(QThread, "start", MagicMock())
|
||||
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
|
||||
def event_loop() -> int:
|
||||
callback = next(call.args[1] for call in timers.singleShot.call_args_list
|
||||
if getattr(call.args[1], "__name__", "") == "handle_native_launch")
|
||||
callback()
|
||||
return 0
|
||||
startup.qt_app.exec.side_effect = event_loop
|
||||
with pytest.raises(SystemExit):
|
||||
startup.app.main()
|
||||
assert window.silent_launch_mode is (mode == "silent")
|
||||
if mode == "silent":
|
||||
window.toggleGame.assert_called_once_with(uri)
|
||||
else:
|
||||
window._load_native_games_async.assert_called_once()
|
||||
window._load_native_games_async.call_args.args[0]([
|
||||
("Native", "", "", "", "", uri, "", "", "", "", 0, 0, "native", "", "", "")])
|
||||
assert window.openGameDetailPage.call_args.args[0]["exec_line"] == uri
|
||||
if mode == "log":
|
||||
assert window._pending_log_exe == uri
|
||||
|
||||
|
||||
class TestNormalizeLaunchPath:
|
||||
def test_plain_path(self):
|
||||
assert normalize_launch_path("/tmp/game.exe") == "/tmp/game.exe"
|
||||
@@ -372,6 +260,9 @@ class TestIsPrefixBackupFile:
|
||||
def test_nonexistent_file(self):
|
||||
assert is_prefix_backup_file("/nonexistent/backup.ppack") is False
|
||||
|
||||
def test_ppack_extension_constant(self):
|
||||
assert PREFIX_BACKUP_EXTENSION == ".ppack"
|
||||
|
||||
|
||||
class TestIsAutoinstallFile:
|
||||
def test_ppai_file(self, tmp_path: Path):
|
||||
@@ -384,6 +275,9 @@ class TestIsAutoinstallFile:
|
||||
txt.touch()
|
||||
assert is_autoinstall_file(str(txt)) is False
|
||||
|
||||
def test_ppai_extension_constant(self):
|
||||
assert PP_EXTENSIONS == (".ppack", ".ppai")
|
||||
|
||||
|
||||
class TestParsePortprotonUrl:
|
||||
def test_https_url(self):
|
||||
@@ -463,428 +357,3 @@ class TestParseResolution:
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_resolution("") is None
|
||||
|
||||
|
||||
def test_list_games_json_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_portproton_games
|
||||
from portprotonqt.config import portproton
|
||||
|
||||
executable = tmp_path / "Game with spaces.exe"
|
||||
shortcut = tmp_path / "game.desktop"
|
||||
shortcut.write_text(
|
||||
'[Desktop Entry]\nName=Test Game\n'
|
||||
f'Exec=portprotonqt --silent "{executable}"\nIcon=test-icon\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(portproton, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(portproton, "get_portproton_scripts_path", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {
|
||||
"name": "Test Game", "description": "Description", "cover": "cover.png",
|
||||
"appid": 123, "custom_icon": "",
|
||||
})
|
||||
games = _list_portproton_games(str(tmp_path), {}, {})
|
||||
assert len(games) == 1
|
||||
assert games[0]["description"] == "Description"
|
||||
assert games[0]["cover"] == "cover.png"
|
||||
assert games[0]["appid"] == 123
|
||||
assert not {"metadata", "desktop_entry", "custom_icon"} & games[0].keys()
|
||||
assert {key: games[0][key] for key in (
|
||||
"id", "name", "desktop_file", "exec", "executable", "directory", "icon", "ppdb"
|
||||
)} == {
|
||||
"id": "portproton:game.desktop", "name": "Test Game",
|
||||
"desktop_file": str(shortcut),
|
||||
"exec": f'portprotonqt --silent "{executable}"',
|
||||
"executable": str(executable), "directory": str(tmp_path),
|
||||
"icon": "test-icon", "ppdb": f"{executable}.ppdb",
|
||||
}
|
||||
|
||||
|
||||
def test_list_games_unconfigured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_portproton_games
|
||||
from portprotonqt.config import portproton
|
||||
|
||||
monkeypatch.setattr(portproton, "get_portproton_location", lambda: None)
|
||||
assert _list_portproton_games(None, {}, {}) == []
|
||||
|
||||
|
||||
def test_parse_list_games(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games"])
|
||||
assert parse_args().list_games
|
||||
|
||||
|
||||
def test_export_custom_artwork(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _get_game_metadata
|
||||
from portprotonqt.config.portproton import resolve_custom_data_dir
|
||||
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_steam_game_info", lambda *args: {
|
||||
"name": "Cached", "description": "Cached description", "appid": "123",
|
||||
})
|
||||
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "en")
|
||||
monkeypatch.setattr("portprotonqt.time_utils.get_playtime_for_exe", lambda *args: 42)
|
||||
monkeypatch.setattr("portprotonqt.time_utils.get_last_launch_timestamp", lambda *args: 100)
|
||||
executable = str(tmp_path / "Game.exe")
|
||||
folder = Path(resolve_custom_data_dir(str(tmp_path / "PortProtonQt/custom_data"), executable))
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "metadata.txt").write_text("name_en=Custom\ndescription_en=Description\n")
|
||||
(folder / "cover.png").touch()
|
||||
(folder / "icon.png").touch()
|
||||
metadata = _get_game_metadata("Original", "launch", executable)
|
||||
assert metadata["name"] == "Custom"
|
||||
assert metadata["description"] == "Description"
|
||||
assert metadata["cover"] == str(folder / "cover.png")
|
||||
assert metadata["custom_icon"] == str(folder / "icon.png")
|
||||
assert metadata["appid"] == "123"
|
||||
assert metadata["playtime_seconds"] == 42
|
||||
|
||||
|
||||
def test_export_runtime(tmp_path: Path) -> None:
|
||||
from portprotonqt.cli import _get_game_runtime
|
||||
|
||||
executable = str(tmp_path / "Game.exe")
|
||||
Path(f"{executable}.ppdb").write_text(
|
||||
'export PW_WINE_USE="PROTON_LG"\nexport PW_PREFIX_NAME="GAME"\n'
|
||||
)
|
||||
runner = tmp_path / "data/dist/PROTON_TEST"
|
||||
runner.mkdir(parents=True)
|
||||
runtime = _get_game_runtime(tmp_path, executable, {"PW_PROTON_LG_VER": "PROTON_TEST"}, {})
|
||||
assert runtime["runner"] == "PROTON_TEST"
|
||||
assert "runner_version" not in runtime
|
||||
assert runtime["runner_path"] == str(runner)
|
||||
assert runtime["runner_installed"]
|
||||
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/GAME")
|
||||
|
||||
|
||||
def test_settings_do_not_execute_shell(tmp_path: Path) -> None:
|
||||
from portprotonqt.cli import _read_game_settings
|
||||
|
||||
marker = tmp_path / "should-not-exist"
|
||||
settings = tmp_path / "settings"
|
||||
settings.write_text(
|
||||
f'export PW_WINE_USE="$(touch {marker})"\n'
|
||||
'export PW_PREFIX_NAME="Game prefix"\n'
|
||||
'check_variables PW_VULKAN_USE "1"\n'
|
||||
)
|
||||
assert _read_game_settings(settings) == {"PW_PREFIX_NAME": "Game prefix", "PW_VULKAN_USE": "1"}
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_list_games_exits_before_gui(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(list_games=True, game_info=None, edit_game=None, pretty=True, fields=None, all_games=True))
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{"name": "Игра"}])
|
||||
gui = MagicMock(side_effect=AssertionError("GUI must not start"))
|
||||
monkeypatch.setattr(app, "QApplication", gui)
|
||||
assert app.main() == 0
|
||||
output = capsys.readouterr().out
|
||||
assert orjson.loads(output) == [{"name": "Игра"}]
|
||||
assert output == '[\n {\n "name": "Игра"\n }\n]\n'
|
||||
gui.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("external", [False, True])
|
||||
def test_runtime_wine_layout(tmp_path: Path, external: bool) -> None:
|
||||
from portprotonqt.cli import _get_game_runtime
|
||||
|
||||
runner = tmp_path / "External Wine" if external else tmp_path / "data/dist/PROTON_TEST"
|
||||
(runner / "files/bin-wow64").mkdir(parents=True)
|
||||
selected = str(runner) if external else "PROTON_TEST"
|
||||
runtime = _get_game_runtime(tmp_path, None, {}, {
|
||||
"PW_WINE_USE": selected, "PW_USE_WOW64": "1", "PW_PREFIX_NAME": "CUSTOM",
|
||||
})
|
||||
assert runtime["wine_path"] == str(runner / "files/bin-wow64/wine")
|
||||
assert runtime["wineserver_path"] == str(runner / "files/bin-wow64/wineserver")
|
||||
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/CUSTOM")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source,backend", [("gog", "gog_api.GOGAPI"), ("egs", "egs_api.EGSAPI")])
|
||||
def test_list_store_games(source: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_store_games
|
||||
|
||||
api = MagicMock()
|
||||
api.load_library.return_value = [
|
||||
{"app_id": "123", "title": "Installed", "description": "Details", "cover": "store.jpg"},
|
||||
{"app_id": "456", "title": "Owned"},
|
||||
]
|
||||
api.load_installed.return_value = {"123": {}}
|
||||
api.is_game_installed.side_effect = lambda app_id, installed: app_id in installed
|
||||
api.get_launch_target.return_value = "/games/game.exe"
|
||||
monkeypatch.setattr(f"portprotonqt.{backend}", lambda: api)
|
||||
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
|
||||
lambda source, app_id, name: (name, "custom.jpg"))
|
||||
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {"custom_icon": "icon.png"})
|
||||
games = _list_store_games(source, None, {}, {})
|
||||
assert [game["id"] for game in games] == [f"{source}:123", f"{source}:456"]
|
||||
assert all(game["source"] == source for game in games)
|
||||
assert games[0]["description"] == "Details"
|
||||
assert games[0]["cover"] == "custom.jpg"
|
||||
assert games[0]["icon"] == "icon.png"
|
||||
assert games[0]["exec"] == f"{source}://launch/123"
|
||||
assert games[1]["exec"] == f"{source}://install/456"
|
||||
assert games[1]["executable"] is None
|
||||
api.get_launch_target.assert_called_once_with("123")
|
||||
|
||||
|
||||
def test_list_steam_games(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_steam_games
|
||||
|
||||
monkeypatch.setattr("portprotonqt.steam_api.get_steam_installed_games", lambda: [("Game", 123, 100, 42)])
|
||||
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_full_steam_game_info",
|
||||
lambda *args: {"description": "Details", "cover": "cached.jpg"})
|
||||
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
|
||||
lambda *args: ("Custom", "custom.jpg"))
|
||||
game = _list_steam_games()[0]
|
||||
assert game["id"] == "steam:123"
|
||||
assert game["source"] == "steam"
|
||||
assert game["name"] == "Custom"
|
||||
assert game["cover"] == "custom.jpg"
|
||||
assert game["exec"] == "steam://rungameid/123"
|
||||
assert game["playtime_seconds"] == 42
|
||||
|
||||
|
||||
def test_list_all_sources_without_portproton(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import list_games
|
||||
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_scripts_path", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.cli._list_steam_games", lambda: [{"source": "steam"}])
|
||||
monkeypatch.setattr("portprotonqt.cli._list_store_games", lambda source, *args: [{"source": source}])
|
||||
assert [game["source"] for game in list_games()] == ["steam", "gog", "egs"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("format_flag,pretty", [("--pretty", True), ("--raw", False)])
|
||||
def test_json_format_args(format_flag: str, pretty: bool, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--game-info", "steam:123", format_flag])
|
||||
args = parse_args()
|
||||
assert args.game_info == "steam:123"
|
||||
assert args.pretty is pretty
|
||||
|
||||
|
||||
@pytest.mark.parametrize("game_id", ["steam:123", "gog:123", "missing"])
|
||||
def test_game_info_output(game_id: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(
|
||||
list_games=False, game_info=game_id, edit_game=None, pretty=False, fields=None,
|
||||
))
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{"id": "steam:123", "source": "steam"}, {"id": "gog:123", "source": "gog"},
|
||||
])
|
||||
code = app.main()
|
||||
output = capsys.readouterr().out
|
||||
if game_id == "missing":
|
||||
assert code == 1
|
||||
assert output == ""
|
||||
else:
|
||||
assert code == 0
|
||||
assert orjson.loads(output)["id"] == game_id
|
||||
assert len(output.splitlines()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--pretty", "--raw"], ["--list-games", "--game-info", "steam:123"], ["--game-info"],
|
||||
])
|
||||
def test_library_conflicting_args(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editable_game(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
|
||||
from portprotonqt.cli import _read_game_settings
|
||||
|
||||
executable = tmp_path / "Game with spaces.exe"
|
||||
executable.touch()
|
||||
ppdb = Path(f"{executable}.ppdb")
|
||||
ppdb.write_text('# Keep this comment\nexport PW_MANGOHUD="0"\nexport PW_WINDOWS_VER="10"\n')
|
||||
game = {"id": "portproton:game.desktop", "source": "portproton", "executable": str(executable)}
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{**game, "runtime": {"settings": _read_game_settings(ppdb)}},
|
||||
])
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
functions = "\n".join(
|
||||
name + helper.split(name, 1)[1].split("\n}\n", 1)[0] + "\n}"
|
||||
for name in ("set_several_variables () {", "edit_db_from_gui () {")
|
||||
)
|
||||
script = tmp_path / "start.sh"
|
||||
script.write_text(
|
||||
'#!/usr/bin/env bash\nset -e\n' + functions + '\n'
|
||||
'print_info () { printf "%s\\n" "$*"; }\n'
|
||||
'print_error () { printf "%s\\n" "$*" >&2; }\n'
|
||||
'PORTWINE_DB_FILE="${3}.ppdb"\n'
|
||||
'set_several_variables "${@:4}"\nedit_db_from_gui $keys_all\n'
|
||||
)
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command",
|
||||
lambda: ["bash", str(script)])
|
||||
return {**game, "ppdb": ppdb, "script": script}
|
||||
|
||||
|
||||
def test_edit_game_roundtrip(editable_game: dict) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
result = edit_game(editable_game["id"], [
|
||||
"PW_MANGOHUD=1", 'LAUNCH_PARAMETERS=--option=value "quoted"',
|
||||
"PW_PREFIX_NAME=GAME", "WINEDLLOVERRIDES=d3d11=n,b;dxgi=n",
|
||||
])
|
||||
settings = result["runtime"]["settings"]
|
||||
assert settings["PW_MANGOHUD"] == "1"
|
||||
assert settings["LAUNCH_PARAMETERS"] == '--option=value "quoted"'
|
||||
assert settings["WINEDLLOVERRIDES"] == "d3d11=n,b;dxgi=n"
|
||||
assert settings["PW_PREFIX_NAME"] == "GAME"
|
||||
assert settings["PW_WINDOWS_VER"] == "10"
|
||||
assert editable_game["ppdb"].read_text().startswith("# Keep this comment\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
"PW_MANGOHUD=2", "NOT_A_SETTING=1", "PW_MANGOHUD", "PW_PREFIX_NAME=../other",
|
||||
"PW_PREFIX_NAME=", "PW_MANGOHUD=$(touch /tmp/forbidden)", "PW_MANGOHUD=1\ntrue",
|
||||
"LAUNCH_PARAMETERS=a|b", "LAUNCH_PARAMETERS=`id`",
|
||||
])
|
||||
def test_edit_game_rejects_invalid_values(editable_game: dict, change: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
run = MagicMock()
|
||||
monkeypatch.setattr("subprocess.run", run)
|
||||
before = editable_game["ppdb"].read_bytes()
|
||||
with pytest.raises(ValueError):
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1", change])
|
||||
run.assert_not_called()
|
||||
assert editable_game["ppdb"].read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["portproton", "gog", "egs"])
|
||||
def test_edit_game_sources(editable_game: dict, source: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{**editable_game, "source": source}])
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
|
||||
assert 'export PW_MANGOHUD="1"' in editable_game["ppdb"].read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["missing", "steam", "uninstalled", "no-script", "failed", "not-saved"])
|
||||
def test_edit_game_errors(editable_game: dict, mode: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
if mode == "missing":
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
|
||||
elif mode in {"steam", "uninstalled"}:
|
||||
game = {**editable_game, "source": "steam"} if mode == "steam" else {**editable_game, "executable": None}
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [game])
|
||||
elif mode == "no-script":
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command", lambda: None)
|
||||
else:
|
||||
editable_game["script"].write_text("exit 4\n" if mode == "failed" else "exit 0\n")
|
||||
expected = RuntimeError if mode in {"failed", "not-saved"} else ValueError
|
||||
with pytest.raises(expected):
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--set", "PW_MANGOHUD=1"], ["--edit-game", "gog:123"],
|
||||
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--list-games"],
|
||||
])
|
||||
def test_edit_game_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [False, True])
|
||||
def test_edit_game_cli_output(failure: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"portprotonqt", "--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--raw",
|
||||
])
|
||||
edit = MagicMock(return_value={"id": "gog:123"})
|
||||
if failure:
|
||||
edit.side_effect = RuntimeError("write failed")
|
||||
monkeypatch.setattr("portprotonqt.cli.edit_game", edit)
|
||||
assert app.main() == (1 if failure else 0)
|
||||
output = capsys.readouterr().out
|
||||
assert output == "" if failure else orjson.loads(output) == {"id": "gog:123"}
|
||||
edit.assert_called_once_with("gog:123", ["PW_MANGOHUD=1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("single", [False, True])
|
||||
@pytest.mark.parametrize("pretty", [False, True])
|
||||
def test_library_selected_fields(single: bool, pretty: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
command = ["--game-info", "gog:123"] if single else ["--list-games"]
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"portprotonqt", *command, "--fields", "executable, icon,name,source",
|
||||
"--pretty" if pretty else "--raw",
|
||||
])
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{"id": "gog:123", "name": "Game", "icon": "icon.png", "source": "gog",
|
||||
"executable": "/games/game.exe", "description": "Not selected", "installed": True},
|
||||
{"id": "steam:456", "name": "Steam Game", "source": "steam", "installed": True},
|
||||
])
|
||||
assert app.main() == 0
|
||||
output = capsys.readouterr().out
|
||||
expected = {"executable": "/games/game.exe", "icon": "icon.png", "name": "Game", "source": "gog"}
|
||||
assert orjson.loads(output) == (expected if single else [
|
||||
expected, {"executable": None, "icon": None, "name": "Steam Game", "source": "steam"},
|
||||
])
|
||||
assert (len(output.splitlines()) > 1) is pretty
|
||||
|
||||
|
||||
def test_library_selected_fields_empty(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games", "--fields", "name"])
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
|
||||
assert app.main() == 0
|
||||
assert capsys.readouterr().out == "[]\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--fields", "name"], ["--list-games", "--fields", ""],
|
||||
["--list-games", "--fields", "name,,icon"], ["--list-games", "--fields", "name,"],
|
||||
["--list-games", "--fields", "name;icon"],
|
||||
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--fields", "name"],
|
||||
])
|
||||
def test_library_selected_fields_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_all", [False, True])
|
||||
def test_list_games_installed_filter(include_all: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
arguments = ["portprotonqt", "--list-games", "--fields", "id", "--raw"]
|
||||
if include_all:
|
||||
arguments.append("--all")
|
||||
monkeypatch.setattr(sys, "argv", arguments)
|
||||
games = [
|
||||
{"id": "portproton:missing.desktop", "installed": False},
|
||||
{"id": "steam:123", "installed": True},
|
||||
{"id": "gog:456", "installed": False},
|
||||
{"id": "egs:789", "installed": True},
|
||||
]
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: games)
|
||||
assert app.main() == 0
|
||||
expected = [game for game in games if include_all or game["installed"]]
|
||||
assert orjson.loads(capsys.readouterr().out) == [{"id": game["id"]} for game in expected]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [["--all"], ["--game-info", "gog:123", "--all"]])
|
||||
def test_all_requires_list_games(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
@@ -590,144 +590,3 @@ def test_non_actionable_findings_do_not_create_fixes() -> None:
|
||||
)
|
||||
|
||||
assert suggestions == []
|
||||
|
||||
|
||||
@mark.parametrize("case", [
|
||||
(True, False, False), (True, True, False), (False, True, True), (False, False, True),
|
||||
])
|
||||
def test_report_ppdb_lookup_conditions(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch, case: tuple[bool, bool, bool],
|
||||
) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from portprotonqt.dialogs import compatibility_report as dialog_module
|
||||
|
||||
automatic, existing, lookup = case
|
||||
executable = tmp_path / "game.exe"
|
||||
executable.touch()
|
||||
if existing:
|
||||
executable.with_suffix(".exe.ppdb").touch()
|
||||
monkeypatch.setattr(dialog_module.ui_config, "get_auto_download_ppdb", lambda: automatic)
|
||||
network = MagicMock()
|
||||
monkeypatch.setattr(dialog_module, "QNetworkAccessManager", network)
|
||||
monkeypatch.setattr(dialog_module, "QNetworkDiskCache", MagicMock())
|
||||
monkeypatch.setattr(dialog_module.proxy_config, "get_proxy", lambda: {})
|
||||
dialog = MagicMock()
|
||||
|
||||
dialog_module.CompatibilityReportDialog.check_ppdb(dialog, str(executable))
|
||||
|
||||
assert network.called is lookup
|
||||
if lookup:
|
||||
assert network.return_value.get.call_args.args[0].url().toString().endswith("/game.exe")
|
||||
|
||||
|
||||
@mark.parametrize("metadata,visible", [
|
||||
(b'{"ppdb_url":"https://linux-gaming.ru/ppdb/download"}', True),
|
||||
(b'{"ppdb_url":""}', False), (b'{}', False), (b'[]', False),
|
||||
(b'invalid', False), (b'{"ppdb_url":"file:///tmp/settings"}', False),
|
||||
])
|
||||
def test_report_ppdb_button_requires_download_url(metadata: bytes, visible: bool) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtCore import QByteArray
|
||||
from PySide6.QtNetwork import QNetworkReply
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
|
||||
dialog = MagicMock()
|
||||
reply = MagicMock()
|
||||
reply.error.return_value = QNetworkReply.NetworkError.NoError
|
||||
reply.readAll.return_value = QByteArray(metadata)
|
||||
|
||||
CompatibilityReportDialog._ppdb_lookup_finished(dialog, reply)
|
||||
|
||||
assert dialog.ppdb_button.show.called is visible
|
||||
reply.deleteLater.assert_called_once()
|
||||
|
||||
|
||||
@mark.parametrize("existing,failed", [
|
||||
(False, False), (True, False), (False, True), (True, True),
|
||||
])
|
||||
def test_report_ppdb_download_replaces_existing_file_only_on_success(
|
||||
tmp_path: Path, existing: bool, failed: bool,
|
||||
) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtCore import QByteArray
|
||||
from PySide6.QtNetwork import QNetworkReply
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
|
||||
path = tmp_path / "game.exe.ppdb"
|
||||
if existing:
|
||||
path.write_bytes(b"user settings")
|
||||
dialog = MagicMock(ppdb_path=str(path))
|
||||
reply = MagicMock()
|
||||
reply.error.return_value = (QNetworkReply.NetworkError.ConnectionRefusedError
|
||||
if failed else QNetworkReply.NetworkError.NoError)
|
||||
reply.readAll.return_value = QByteArray(b"downloaded settings")
|
||||
|
||||
CompatibilityReportDialog._ppdb_download_finished(dialog, reply)
|
||||
|
||||
if failed:
|
||||
if existing:
|
||||
assert path.read_bytes() == b"user settings"
|
||||
else:
|
||||
assert not path.exists()
|
||||
dialog.ppdb_button.setEnabled.assert_called_with(True)
|
||||
else:
|
||||
assert path.read_bytes() == b"downloaded settings"
|
||||
dialog.ppdb_button.setEnabled.assert_called_with(False)
|
||||
|
||||
|
||||
def test_report_close_releases_pending_ppdb_network(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtCore import QCoreApplication, QEvent
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from shiboken6 import isValid
|
||||
from portprotonqt.dialogs import compatibility_report as dialog_module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
theme = SimpleNamespace(
|
||||
COMPATIBILITY_REPORT_DIALOG={
|
||||
"minimum_width": 400, "minimum_height": 300, "margin": 10, "spacing": 10,
|
||||
},
|
||||
MAIN_WINDOW_STYLE="", COMPATIBILITY_REPORT_DIALOG_STYLE="", SCROLL_STYLE="",
|
||||
ACTION_BUTTON_STYLE="", ACTION_BUTTON_ACTIVE_STYLE="",
|
||||
)
|
||||
executable = tmp_path / "game.exe"
|
||||
executable.touch()
|
||||
monkeypatch.setattr(dialog_module, "CACHE_DIR", tmp_path / "cache")
|
||||
monkeypatch.setattr(dialog_module.ui_config, "get_auto_download_ppdb", lambda: False)
|
||||
monkeypatch.setattr(dialog_module.proxy_config, "get_proxy", lambda: {})
|
||||
get = MagicMock()
|
||||
monkeypatch.setattr(dialog_module.QNetworkAccessManager, "get", get)
|
||||
dialog = dialog_module.CompatibilityReportDialog(None, theme, "report")
|
||||
dialog.check_ppdb(str(executable))
|
||||
manager = dialog.ppdb_network
|
||||
assert manager.parent() is dialog
|
||||
assert dialog.ppdb_button.isHidden()
|
||||
|
||||
dialog.reject()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
assert not isValid(manager)
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_report_ppdb_redownload_bypasses_cache(tmp_path: Path) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtNetwork import QNetworkRequest
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
|
||||
path = tmp_path / "game.exe.ppdb"
|
||||
path.write_bytes(b"old settings")
|
||||
dialog = MagicMock(ppdb_path=str(path), ppdb_url=QUrl("https://linux-gaming.ru/ppdb"))
|
||||
|
||||
CompatibilityReportDialog._download_ppdb(dialog)
|
||||
|
||||
request = dialog.ppdb_network.get.call_args.args[0]
|
||||
assert request.url() == dialog.ppdb_url
|
||||
assert request.attribute(QNetworkRequest.Attribute.CacheLoadControlAttribute) == (
|
||||
QNetworkRequest.CacheLoadControl.AlwaysNetwork
|
||||
)
|
||||
dialog.ppdb_button.setEnabled.assert_called_with(False)
|
||||
assert path.read_bytes() == b"old settings"
|
||||
+74
-67
@@ -1,13 +1,27 @@
|
||||
"""Tests for scripts_utils/dbus_tools.py — D-Bus helpers, notifications, power profiles."""
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from dbus_fast import BusType, DBusError, Message, Variant
|
||||
from dbus_fast import DBusError, Message, Variant
|
||||
|
||||
from portprotonqt.scripts_utils.dbus_tools import (
|
||||
ACTIVE_PROFILE,
|
||||
DBUS_TIMEOUT,
|
||||
NOTIFICATIONS_BUS_NAME,
|
||||
NOTIFICATIONS_BUS_PATH,
|
||||
PORTAL_BUS_NAME,
|
||||
PORTAL_BUS_PATH,
|
||||
PORTAL_IDLE_FLAG,
|
||||
PORTAL_INTERFACE,
|
||||
PORTAL_NOTIFICATION_INTERFACE,
|
||||
PORTAL_REQUEST_INTERFACE,
|
||||
POWER_PROFILE_ENDPOINTS,
|
||||
PROFILES,
|
||||
PROPERTIES_INTERFACE,
|
||||
SCREENSAVER_BUS_NAME,
|
||||
SCREENSAVER_BUS_PATH,
|
||||
NotificationRequest,
|
||||
_active_power_profile,
|
||||
_bus_call,
|
||||
@@ -34,6 +48,21 @@ DUMMY_MSG = Message(
|
||||
)
|
||||
|
||||
|
||||
class TestNotificationRequest:
|
||||
def test_construction(self):
|
||||
req = NotificationRequest("app", "icon", "title", "body", 5000)
|
||||
assert req.app == "app"
|
||||
assert req.icon == "icon"
|
||||
assert req.title == "title"
|
||||
assert req.body == "body"
|
||||
assert req.timeout == 5000
|
||||
|
||||
def test_frozen(self):
|
||||
req = NotificationRequest("a", "i", "t", "b", 1)
|
||||
with pytest.raises(AttributeError):
|
||||
req.app = "x" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSerializeIcon:
|
||||
def test_file_path_with_slash(self):
|
||||
result = _serialize_icon("/usr/share/icon.png")
|
||||
@@ -184,12 +213,11 @@ class TestDbusCall:
|
||||
result = _run_async(dbus_call(coro()))
|
||||
assert result == "ok"
|
||||
|
||||
def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def slow() -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
monkeypatch.setattr("portprotonqt.scripts_utils.dbus_tools.DBUS_TIMEOUT", 0.01)
|
||||
def test_timeout(self):
|
||||
async def slow():
|
||||
await asyncio.sleep(100)
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
_run_async(dbus_call(slow()))
|
||||
_run_async(asyncio.wait_for(slow(), timeout=0.01))
|
||||
|
||||
|
||||
class TestBusCall:
|
||||
@@ -337,66 +365,6 @@ class TestRequestIdleInhibit:
|
||||
assert bus is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("session_available", [True, False])
|
||||
def test_idle_inhibit_falls_back_to_logind(session_available: bool) -> None:
|
||||
session = MagicMock()
|
||||
system = MagicMock()
|
||||
session.connect = AsyncMock(return_value=session)
|
||||
if not session_available:
|
||||
session.connect.side_effect = OSError("no session bus")
|
||||
system.connect = AsyncMock(return_value=system)
|
||||
system.call = AsyncMock(return_value=Message.new_method_return(
|
||||
Message(destination="a.b", path="/a", member="m", serial=1),
|
||||
signature="h", body=[0], unix_fds=[42],
|
||||
))
|
||||
with (
|
||||
patch("portprotonqt.scripts_utils.dbus_tools.MessageBus", side_effect=[session, system]) as factory,
|
||||
patch("portprotonqt.scripts_utils.dbus_tools._request_screensaver_inhibit", new_callable=AsyncMock) as screensaver,
|
||||
patch("portprotonqt.scripts_utils.dbus_tools._request_portal_inhibit", new_callable=AsyncMock) as portal,
|
||||
):
|
||||
screensaver.side_effect = RuntimeError("no screensaver")
|
||||
portal.side_effect = RuntimeError("no portal")
|
||||
bus, kind, payload = _run_async(request_idle_inhibit("app", "reason"))
|
||||
|
||||
assert (bus, kind, payload) == (system, "logind", 42)
|
||||
factory.assert_called_with(bus_type=BusType.SYSTEM, negotiate_unix_fd=True)
|
||||
assert session.disconnect.call_count == int(session_available)
|
||||
message = system.call.call_args.args[0]
|
||||
assert message.destination == "org.freedesktop.login1"
|
||||
assert message.path == "/org/freedesktop/login1"
|
||||
assert message.interface == "org.freedesktop.login1.Manager"
|
||||
assert message.member == "Inhibit"
|
||||
assert message.signature == "ssss"
|
||||
assert message.body == ["sleep:idle", "app", "reason", "block"]
|
||||
system.disconnect.assert_not_called()
|
||||
|
||||
|
||||
def test_idle_inhibit_disconnects_when_logind_fails() -> None:
|
||||
session = MagicMock()
|
||||
session.connect = AsyncMock(side_effect=OSError("no session bus"))
|
||||
system = MagicMock()
|
||||
system.connect = AsyncMock(return_value=system)
|
||||
system.call = AsyncMock(return_value=Message.new_error(
|
||||
Message(destination="a.b", path="/a", member="m", serial=1),
|
||||
"org.freedesktop.DBus.Error.AccessDenied", "denied",
|
||||
))
|
||||
with patch("portprotonqt.scripts_utils.dbus_tools.MessageBus", side_effect=[session, system]):
|
||||
with pytest.raises(DBusError, match="denied"):
|
||||
_run_async(request_idle_inhibit("app", "reason"))
|
||||
system.disconnect.assert_called_once()
|
||||
|
||||
|
||||
def test_release_logind_closes_inhibitor() -> None:
|
||||
bus = MagicMock()
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
_run_async(release_idle_inhibit(bus, "logind", write_fd))
|
||||
assert os.read(read_fd, 1) == b""
|
||||
bus.disconnect.assert_called_once()
|
||||
finally:
|
||||
os.close(read_fd)
|
||||
|
||||
|
||||
class TestReleaseIdleInhibit:
|
||||
def test_release_screensaver(self):
|
||||
async def run():
|
||||
@@ -615,3 +583,42 @@ class TestMain:
|
||||
with patch("sys.argv", ["dbus_tools"]):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_power_profile_endpoints_count(self):
|
||||
assert len(POWER_PROFILE_ENDPOINTS) == 2
|
||||
|
||||
def test_power_profile_endpoints_format(self):
|
||||
for name, path in POWER_PROFILE_ENDPOINTS:
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(path, str)
|
||||
assert path.startswith("/")
|
||||
|
||||
def test_bus_timeout(self):
|
||||
assert DBUS_TIMEOUT == 10
|
||||
|
||||
def test_portal_idle_flag(self):
|
||||
assert PORTAL_IDLE_FLAG == 8
|
||||
|
||||
def test_screensaver_bus(self):
|
||||
assert SCREENSAVER_BUS_NAME == "org.freedesktop.ScreenSaver"
|
||||
assert SCREENSAVER_BUS_PATH == "/org/freedesktop/ScreenSaver"
|
||||
|
||||
def test_portal_bus(self):
|
||||
assert PORTAL_BUS_NAME == "org.freedesktop.portal.Desktop"
|
||||
assert PORTAL_BUS_PATH == "/org/freedesktop/portal/desktop"
|
||||
|
||||
def test_notification_bus(self):
|
||||
assert NOTIFICATIONS_BUS_NAME == "org.freedesktop.Notifications"
|
||||
assert NOTIFICATIONS_BUS_PATH == "/org/freedesktop/Notifications"
|
||||
|
||||
def test_interfaces(self):
|
||||
assert PORTAL_INTERFACE == "org.freedesktop.portal.Inhibit"
|
||||
assert PORTAL_NOTIFICATION_INTERFACE == "org.freedesktop.portal.Notification"
|
||||
assert PORTAL_REQUEST_INTERFACE == "org.freedesktop.portal.Request"
|
||||
assert PROPERTIES_INTERFACE == "org.freedesktop.DBus.Properties"
|
||||
|
||||
def test_profile_constants(self):
|
||||
assert ACTIVE_PROFILE == "ActiveProfile"
|
||||
assert PROFILES == "Profiles"
|
||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest import MonkeyPatch, mark
|
||||
from pytest import MonkeyPatch
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
import portprotonqt.detail_pages.utils as detail_utils
|
||||
@@ -701,23 +701,3 @@ def test_steam_with_appid_shows_edit_not_add(monkeypatch) -> None:
|
||||
add_count = sum(1 for t in button_texts if "Add" in t or "Добавить" in t)
|
||||
assert edit_count == 1
|
||||
assert add_count == 0
|
||||
|
||||
|
||||
@mark.parametrize("managed", [False, True])
|
||||
def test_native_detail_folder_button_only_for_managed_games(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch, managed: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
|
||||
data = tmp_path / "data"
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(data))
|
||||
directory = data / "PortProtonQt/native_games" if managed else tmp_path / "usr/share/applications"
|
||||
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
|
||||
manager._create_game_buttons_layout({
|
||||
"game_source": "native",
|
||||
"exec_line": "native://" + str(directory / "hytale.desktop"),
|
||||
"name": "Hytale Launcher",
|
||||
})
|
||||
buttons = _get_added_buttons(manager)
|
||||
assert "Settings" in buttons
|
||||
assert "Create Log" in buttons
|
||||
assert ("Open Folder" in buttons) is managed
|
||||
@@ -12,9 +12,6 @@ from PySide6.QtWidgets import QApplication
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
THREAD_TIMEOUT_MS = 30000
|
||||
SYNC_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def application() -> QCoreApplication:
|
||||
@@ -79,7 +76,7 @@ def test_missing_destination_still_downloads_with_a_worker(
|
||||
|
||||
assert worker is not None
|
||||
assert worker in downloader._active_threads
|
||||
assert worker.wait(THREAD_TIMEOUT_MS)
|
||||
assert worker.wait(5000)
|
||||
assert results == []
|
||||
QApplication.processEvents()
|
||||
assert results == [str(target)]
|
||||
@@ -127,11 +124,11 @@ def test_download_started_from_a_worker_cleans_up_on_the_owner_thread(
|
||||
|
||||
caller = threading.Thread(target=start_download)
|
||||
caller.start()
|
||||
caller.join(timeout=SYNC_TIMEOUT_SECONDS)
|
||||
caller.join(timeout=5)
|
||||
assert not caller.is_alive()
|
||||
worker = workers[0]
|
||||
assert worker is not None
|
||||
assert worker.wait(THREAD_TIMEOUT_MS)
|
||||
assert worker.wait(5000)
|
||||
QApplication.processEvents()
|
||||
|
||||
assert results == [(path, QThread.currentThread())]
|
||||
@@ -149,13 +146,13 @@ def test_shutdown_waits_for_an_active_download(
|
||||
|
||||
def download(*_args: Any) -> str:
|
||||
entered.set()
|
||||
assert release.wait(SYNC_TIMEOUT_SECONDS)
|
||||
assert release.wait(5)
|
||||
return path
|
||||
|
||||
monkeypatch.setattr("portprotonqt.downloader.download_with_cache", download)
|
||||
worker = downloader.download_async("https://example.com/data.json", path)
|
||||
assert worker is not None
|
||||
assert entered.wait(SYNC_TIMEOUT_SECONDS)
|
||||
assert entered.wait(5)
|
||||
owner = cast(Any, MainWindowWorkersMixin())
|
||||
owner.downloader = downloader
|
||||
owner.compatibility_workers = []
|
||||
@@ -174,4 +171,4 @@ def test_shutdown_waits_for_an_active_download(
|
||||
finally:
|
||||
release.set()
|
||||
release_timer.join()
|
||||
worker.wait(THREAD_TIMEOUT_MS)
|
||||
worker.wait(5000)
|
||||
+3
-51
@@ -1,4 +1,3 @@
|
||||
import argparse
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -7,53 +6,7 @@ from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.scripts_utils.easyterm import (
|
||||
CONF_FALLBACK_SHELL,
|
||||
TerminalWidget,
|
||||
build_command,
|
||||
)
|
||||
|
||||
PROCESS_TIMEOUT_SECONDS = 30
|
||||
HELPER_PATH = "/usr/share/portproton/scripts/functions_helper"
|
||||
USER_SHELL = "/usr/bin/zsh"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_shell(monkeypatch: pytest.MonkeyPatch) -> str:
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.scripts_utils.easyterm.default_shell", lambda: USER_SHELL
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.scripts_utils.easyterm._portproton_functions_helper_path",
|
||||
lambda: HELPER_PATH,
|
||||
)
|
||||
return USER_SHELL
|
||||
|
||||
|
||||
def test_build_command_uses_bash_for_portproton_functions(user_shell: str) -> None:
|
||||
command = build_command(argparse.Namespace(e=["pw_run Game"]))
|
||||
|
||||
assert command == [
|
||||
CONF_FALLBACK_SHELL,
|
||||
"-lc",
|
||||
f"source {HELPER_PATH}; pw_run Game",
|
||||
]
|
||||
|
||||
|
||||
def test_build_command_uses_bash_for_exported_functions(
|
||||
user_shell: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("BASH_FUNC_launch_game%%", "() { :; }")
|
||||
|
||||
command = build_command(argparse.Namespace(e=["launch_game"]))
|
||||
|
||||
assert command[0] == CONF_FALLBACK_SHELL
|
||||
|
||||
|
||||
def test_build_command_keeps_user_shell_for_plain_commands(user_shell: str) -> None:
|
||||
command = build_command(argparse.Namespace(e=["ls -l"]))
|
||||
|
||||
assert command == [user_shell, "-lc", "ls -l"]
|
||||
from portprotonqt.scripts_utils.easyterm import TerminalWidget
|
||||
|
||||
|
||||
def test_terminal_keeps_failed_command_output(
|
||||
@@ -72,10 +25,9 @@ def test_terminal_keeps_failed_command_output(
|
||||
False,
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + PROCESS_TIMEOUT_SECONDS
|
||||
while terminal.exit_code is None and time.monotonic() < deadline:
|
||||
deadline = time.monotonic() + 5
|
||||
while terminal.child_pid is not None and time.monotonic() < deadline:
|
||||
application.processEvents()
|
||||
terminal._check_child()
|
||||
time.sleep(0.01)
|
||||
|
||||
assert terminal.exit_code == 7
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Tests for PE metadata consumed by PortProton shell scripts."""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from portprotonqt.scripts_utils import exe_metadata
|
||||
|
||||
|
||||
def test_reads_version_strings_and_closes_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
path = tmp_path / "Game.exe"
|
||||
pe = MagicMock()
|
||||
table = SimpleNamespace(entries={b"ProductName": b"Game", b"CompanyName": b"Sony"})
|
||||
pe.__enter__.return_value.FileInfo = [[SimpleNamespace(StringTable=[table])]]
|
||||
constructor = MagicMock(return_value=pe)
|
||||
monkeypatch.setattr(exe_metadata.pefile, "PE", constructor)
|
||||
|
||||
assert exe_metadata.read_metadata(str(path)) == {"ProductName": "Game", "CompanyName": "Sony"}
|
||||
constructor.assert_called_once_with(str(path), fast_load=True)
|
||||
pe.__enter__.return_value.parse_data_directories.assert_called_once_with(
|
||||
directories=[exe_metadata.pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]],
|
||||
)
|
||||
pe.__exit__.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", [exe_metadata.pefile.PEFormatError("invalid"), OSError("unreadable")])
|
||||
def test_unreadable_pe_allows_exiftool_fallback(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None:
|
||||
monkeypatch.setattr(exe_metadata.pefile, "PE", MagicMock(side_effect=error))
|
||||
assert exe_metadata.read_metadata("Game.exe") is None
|
||||
|
||||
|
||||
def test_cli_preserves_shell_field_labels(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
monkeypatch.setattr(exe_metadata.sys, "argv", ["exe_metadata", "/Games/Game.exe"])
|
||||
monkeypatch.setattr(exe_metadata, "read_metadata", lambda _path: {
|
||||
"ProductName": "Game", "FileDescription": "Description\r\nextra", "CompanyName": "Sony",
|
||||
})
|
||||
assert exe_metadata.main() == 0
|
||||
assert capsys.readouterr().out == (
|
||||
"File Name: Game.exe\nProduct Name: Game\nFile Description: Description extra\nCompany Name: Sony\n"
|
||||
)
|
||||
|
||||
|
||||
def test_cli_returns_failure_for_unsupported_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(exe_metadata.sys, "argv", ["exe_metadata", "Game.exe"])
|
||||
monkeypatch.setattr(exe_metadata, "read_metadata", lambda _path: None)
|
||||
assert exe_metadata.main() == 1
|
||||
@@ -1,17 +1,14 @@
|
||||
"""Tests for themed file explorer icons."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
from PySide6.QtCore import QSize
|
||||
from PySide6.QtGui import QColor, QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QStyle,
|
||||
@@ -19,8 +16,6 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
import portprotonqt.dialogs.file_explorer as file_explorer_module
|
||||
from portprotonqt.input_manager.file_explorer import FileExplorerInputMixin
|
||||
from portprotonqt.input_manager.constants import BUTTONS
|
||||
from portprotonqt.dialogs.file_explorer import (
|
||||
FileExplorer,
|
||||
FileIconDelegate,
|
||||
@@ -30,53 +25,6 @@ from portprotonqt.dialogs.file_explorer import (
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_executable_filter_keeps_exe_and_linux_executables(tmp_path: Path) -> None:
|
||||
from PySide6.QtCore import QMimeDatabase
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
for name in ("Game.EXE", "launcher", "notes.txt"):
|
||||
(tmp_path / name).touch()
|
||||
(tmp_path / "launcher").chmod(0o755)
|
||||
(tmp_path / "folder").mkdir()
|
||||
explorer = SimpleNamespace(
|
||||
file_list=QListWidget(), current_path=str(tmp_path), directory_only=False,
|
||||
thumbnail_cache={}, pending_thumbnails={}, deferred_thumbnails={}, file_items={},
|
||||
path_history={}, path_label=QLabel(), mime_db=QMimeDatabase(),
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
_get_list_icon=lambda name: None, _get_file_type_icon=lambda path: None,
|
||||
schedule_thumbnail_loading=lambda: None,
|
||||
)
|
||||
|
||||
FileExplorer.update_file_list(cast(FileExplorer, explorer))
|
||||
|
||||
assert [explorer.file_list.item(i).text() for i in range(explorer.file_list.count())] == [
|
||||
"../", "folder/", "Game.EXE", "launcher",
|
||||
]
|
||||
|
||||
|
||||
def test_gamepad_opens_file_explorer_context_menu(monkeypatch: MonkeyPatch) -> None:
|
||||
explorer = FileExplorer.__new__(FileExplorer)
|
||||
QDialog.__init__(explorer)
|
||||
explorer.file_list = QListWidget(explorer)
|
||||
explorer.file_list.addItem("folder/")
|
||||
explorer.file_list.setCurrentRow(0)
|
||||
context_manager = MagicMock()
|
||||
explorer.context_menu_manager = context_manager
|
||||
manager = SimpleNamespace(file_explorer=explorer, _parent=SimpleNamespace())
|
||||
monkeypatch.setattr(QApplication, "activeWindow", lambda: explorer)
|
||||
monkeypatch.setattr(QApplication, "activePopupWidget", lambda: None)
|
||||
|
||||
FileExplorerInputMixin.handle_file_explorer_button(
|
||||
cast(FileExplorerInputMixin, manager), next(iter(BUTTONS["context_menu"])), 1,
|
||||
)
|
||||
|
||||
item = explorer.file_list.currentItem()
|
||||
assert item is not None
|
||||
context_manager.show_file_explorer_context_menu.assert_called_once_with(
|
||||
explorer, explorer.file_list.visualItemRect(item).center(),
|
||||
)
|
||||
|
||||
|
||||
class FakeIcon:
|
||||
"""Record icon files assigned to Qt modes."""
|
||||
|
||||
@@ -147,61 +95,3 @@ def test_list_icon_uses_theme_state_colors(
|
||||
QIcon.Mode.Selected: "folder-#222222.svg",
|
||||
QIcon.Mode.Disabled: "folder-#333333.svg",
|
||||
}
|
||||
|
||||
|
||||
def test_appimage_thumbnail_worker_caches_icon_and_finishes(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.touch()
|
||||
cache = tmp_path / "cache" / "icon.png"
|
||||
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
|
||||
calls = []
|
||||
|
||||
def generate(path: str, output: str) -> bool:
|
||||
calls.append(path)
|
||||
image = QImage(16, 16, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor("red"))
|
||||
return image.save(output)
|
||||
|
||||
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", generate)
|
||||
pool = QThreadPool()
|
||||
for _attempt in range(2):
|
||||
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
|
||||
received = []
|
||||
loader.signals.thumbnail_ready.connect(lambda path, image, received=received: received.append((path, image)))
|
||||
pool.start(loader)
|
||||
assert pool.waitForDone(5000)
|
||||
_application.processEvents()
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == str(source)
|
||||
assert not received[0][1].isNull()
|
||||
assert calls == [str(source)]
|
||||
assert pool.activeThreadCount() == 0
|
||||
|
||||
|
||||
def test_appimage_thumbnail_failure_completes_without_replacing_icon(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.touch()
|
||||
cache = tmp_path / "cache" / "icon.png"
|
||||
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
|
||||
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", lambda *_args: False)
|
||||
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
|
||||
received = []
|
||||
loader.signals.thumbnail_ready.connect(lambda path, image: received.append((path, image)))
|
||||
loader.run()
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == str(source)
|
||||
assert received[0][1].isNull()
|
||||
item = MagicMock()
|
||||
explorer = SimpleNamespace(thumbnail_cache={}, file_items={str(source): item})
|
||||
FileExplorer._set_thumbnail_icon(cast(FileExplorer, explorer), str(source), QImage())
|
||||
assert str(source) in explorer.thumbnail_cache
|
||||
item.setIcon.assert_not_called()
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from pytest import MonkeyPatch, raises, mark
|
||||
from pytest import MonkeyPatch, raises
|
||||
|
||||
import portprotonqt.game_card as game_card_module
|
||||
import portprotonqt.image_utils as image_utils
|
||||
@@ -608,16 +608,9 @@ def test_game_card_uses_exe_fallback_without_valid_cover(
|
||||
]
|
||||
|
||||
|
||||
@mark.parametrize("url, filename", [
|
||||
("https://example.org/game.jpg", "game.jpg"),
|
||||
("https://cdn2.steamgriddb.com/grid/unique-cover.png", "unique-cover.png"),
|
||||
("https://deckyloader.ru/sgdb-cdn/grid/unique-cover.png", "unique-cover.png"),
|
||||
])
|
||||
def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
url: str,
|
||||
filename: str,
|
||||
) -> None:
|
||||
exe_path = tmp_path / "game.exe"
|
||||
exe_path.write_bytes(b"MZ")
|
||||
@@ -633,8 +626,6 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
timeout: int,
|
||||
callback: Callable[[str | None], None],
|
||||
) -> None:
|
||||
assert _url == url
|
||||
assert Path(_path).name == filename
|
||||
callbacks.append(callback)
|
||||
|
||||
monkeypatch.setattr("portprotonqt.icon_extractor.CACHE_DIR", tmp_path / "cache" / "PortProtonQt")
|
||||
@@ -651,102 +642,14 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
)
|
||||
|
||||
image_utils.load_pixmap_async(
|
||||
url, 100, 150,
|
||||
"https://example.org/game.jpg", 100, 150,
|
||||
lambda pixmap: loaded_paths.append(cast(FakePixmap, pixmap).path),
|
||||
app_name="game", fallback_exe=str(exe_path),
|
||||
fallback_icon_path=str(fallback_path),
|
||||
)
|
||||
callbacks[0](None)
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / filename
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / "game.jpg"
|
||||
downloaded_path.write_bytes(b"cover")
|
||||
callbacks[0](str(downloaded_path))
|
||||
|
||||
assert loaded_paths == [str(fallback_path), str(downloaded_path)]
|
||||
|
||||
|
||||
def test_custom_icon_overrides_extracted_icon(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
|
||||
from portprotonqt.config.portproton import get_custom_data_dir_name, get_custom_icon_path
|
||||
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
executable = "/games/custom/game.exe"
|
||||
folder = tmp_path / "PortProtonQt" / "custom_data" / get_custom_data_dir_name(executable)
|
||||
folder.mkdir(parents=True)
|
||||
icon = folder / "icon.png"
|
||||
icon.write_bytes(b"custom icon")
|
||||
assert get_custom_icon_path(executable) == str(icon)
|
||||
assert get_exe_icon_cache_path(executable) == str(icon)
|
||||
icon.unlink()
|
||||
assert get_custom_icon_path(executable) == ""
|
||||
assert get_exe_icon_cache_path(executable) != str(icon)
|
||||
|
||||
|
||||
@mark.parametrize("elf_class,endian", [(1, "<"), (2, "<"), (2, ">")])
|
||||
@mark.parametrize("svg", [False, True])
|
||||
def test_appimage_icon_extraction(tmp_path: Path, elf_class: int, endian: str, svg: bool) -> None:
|
||||
from portprotonqt.icon_extractor import APPIMAGE_EXTRACT_TIMEOUT, ELF_SECTION_LAYOUTS
|
||||
|
||||
header = bytearray(64)
|
||||
header[:4] = b"\x7fELF"
|
||||
header[4:6] = bytes([elf_class, 1 if endian == "<" else 2])
|
||||
header[8:11] = b"AI\x02"
|
||||
offset_position, offset_format, size_position = ELF_SECTION_LAYOUTS[elf_class]
|
||||
struct.pack_into(endian + offset_format, header, offset_position, len(header))
|
||||
struct.pack_into(endian + "HH", header, size_position, 0, 0)
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"hsqs")
|
||||
output = tmp_path / "icon.png"
|
||||
icon = io.BytesIO()
|
||||
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
|
||||
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">'
|
||||
b'<rect width="16" height="16" fill="red"/></svg>') if svg else icon.getvalue()
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", return_value=SimpleNamespace(stdout=payload)) as run:
|
||||
assert generate_thumbnail(str(source), str(output))
|
||||
run.assert_called_once_with(
|
||||
["unsquashfs", "-cat", "-o", "64", str(source), ".DirIcon"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
with Image.open(output) as image:
|
||||
assert image.size == (128, 128)
|
||||
assert image.convert("RGBA").getpixel((64, 64)) == (255, 0, 0, 255)
|
||||
|
||||
|
||||
def test_appimage_icon_missing_tool(tmp_path: Path) -> None:
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
struct.pack_into("<Q", header, 40, len(header))
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"hsqs")
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert not generate_thumbnail(str(source), str(tmp_path / "icon.png"))
|
||||
|
||||
|
||||
@mark.parametrize("target", ["icon.png", "../outside", ".DirIcon"])
|
||||
def test_dwarfs_icon_symlink(tmp_path: Path, target: str) -> None:
|
||||
import tarfile
|
||||
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
struct.pack_into("<Q", header, 40, len(header))
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"DWARFS")
|
||||
link_archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=link_archive, mode="w") as archive:
|
||||
member = tarfile.TarInfo(".DirIcon")
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = target
|
||||
archive.addfile(member)
|
||||
icon = io.BytesIO()
|
||||
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
|
||||
icon_archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=icon_archive, mode="w") as archive:
|
||||
member = tarfile.TarInfo("icon.png")
|
||||
member.size = len(icon.getvalue())
|
||||
archive.addfile(member, io.BytesIO(icon.getvalue()))
|
||||
results = [SimpleNamespace(stdout=data.getvalue()) for data in (link_archive, icon_archive)]
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=results) as run:
|
||||
assert generate_thumbnail(str(source), str(tmp_path / "icon.png")) is (target == "icon.png")
|
||||
assert run.call_count == (2 if target == "icon.png" else 1)
|
||||
assert run.call_args_list[0].args[0][0] == "dwarfsextract"
|
||||
assert "--skip-specials" in run.call_args_list[0].args[0]
|
||||
+6
-283
@@ -6,9 +6,8 @@ from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QStringListModel, Qt
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QWidget
|
||||
from pytest import MonkeyPatch, raises, mark
|
||||
from shiboken6 import delete
|
||||
|
||||
import portprotonqt.input_manager as input_manager
|
||||
import portprotonqt.input_manager.buttons as input_buttons
|
||||
@@ -36,34 +35,6 @@ HIDDEN_CARD_X = 20
|
||||
NEXT_CARD_X = 40
|
||||
|
||||
|
||||
@mark.parametrize("event_type", [QEvent.Type.Destroy, QEvent.Type.ChildRemoved, QEvent.Type.DeferredDelete])
|
||||
def test_keyboard_filter_ignores_deleted_layout(event_type: QEvent.Type) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
layout = QVBoxLayout()
|
||||
delete(layout)
|
||||
|
||||
assert manager.eventFilter(layout, QEvent(event_type)) is False
|
||||
assert app is not None
|
||||
|
||||
|
||||
@mark.parametrize("event_type", [QEvent.Type.Destroy, QEvent.Type.ChildRemoved, QEvent.Type.DeferredDelete])
|
||||
def test_keyboard_filter_teardown_does_not_lookup_application(
|
||||
monkeypatch: MonkeyPatch, event_type: QEvent.Type,
|
||||
) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
watched = QObject()
|
||||
lookup = MagicMock(side_effect=AssertionError("Application lookup during teardown"))
|
||||
monkeypatch.setattr(QApplication, "instance", lookup)
|
||||
|
||||
assert manager.eventFilter(watched, QEvent(event_type)) is False
|
||||
lookup.assert_not_called()
|
||||
assert app is not None
|
||||
|
||||
|
||||
@mark.parametrize("initial", [True, False])
|
||||
def test_gamepad_auto_fullscreen_shows_boot_on_hotplug(monkeypatch: MonkeyPatch, initial: bool) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
@@ -675,10 +646,7 @@ def test_confirm_button_opens_combo_popup() -> None:
|
||||
assert app is not None
|
||||
|
||||
|
||||
@mark.parametrize("table_name", ["advanced_table", "favorites_table"])
|
||||
def test_settings_gamepad_skips_separator_and_activates_download(
|
||||
monkeypatch: MonkeyPatch, table_name: str,
|
||||
) -> None:
|
||||
def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: MonkeyPatch) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.input_manager.constants import BUTTONS
|
||||
from portprotonqt.input_manager.settings import SettingsInputMixin
|
||||
@@ -704,7 +672,7 @@ def test_settings_gamepad_skips_separator_and_activates_download(
|
||||
cast(ExeSettingsDialog, settings), index, combo, "PW_WINE_USE",
|
||||
))
|
||||
manager = SimpleNamespace(
|
||||
settings_dialog=SimpleNamespace(advanced_table=None, settings_table=None, favorites_table=None),
|
||||
settings_dialog=SimpleNamespace(advanced_table=table, settings_table=None, favorites_table=None),
|
||||
_is_current_settings_tool_tab=lambda: False,
|
||||
_handle_table_action_navigation=lambda *_args: False,
|
||||
_get_dialog_action_buttons=lambda _dialog: [],
|
||||
@@ -712,13 +680,13 @@ def test_settings_gamepad_skips_separator_and_activates_download(
|
||||
_get_open_settings_combo=lambda: combo,
|
||||
_handle_common_ui_elements=lambda _code: False,
|
||||
)
|
||||
setattr(manager.settings_dialog, table_name, table)
|
||||
dialog.show()
|
||||
combo.showPopup()
|
||||
app.processEvents()
|
||||
SettingsInputMixin.handle_settings_dpad(cast(SettingsInputMixin, manager), PAD_DPAD_Y, 1, 0)
|
||||
assert combo.view().currentIndex().row() == 2
|
||||
assert combo.currentText() == "Proton"
|
||||
assert highlighted[-1] == 2
|
||||
assert combo.currentData() == "download_wine"
|
||||
SettingsInputMixin.handle_settings_button(
|
||||
cast(SettingsInputMixin, manager), next(iter(BUTTONS["confirm"])), 1,
|
||||
)
|
||||
@@ -757,7 +725,7 @@ def test_settings_combo_navigation_with_keyboard_events_intercepted() -> None:
|
||||
app.processEvents()
|
||||
for direction, expected in ((1, 1), (1, 2), (1, 2), (-1, 1), (-1, 0), (-1, 0)):
|
||||
SettingsInputMixin.handle_settings_dpad(cast(SettingsInputMixin, manager), PAD_DPAD_Y, direction, 0)
|
||||
assert combo.currentIndex() == 0
|
||||
assert combo.currentIndex() == expected
|
||||
assert combo.view().currentIndex().row() == expected
|
||||
assert combo.view().isVisible()
|
||||
finally:
|
||||
@@ -1459,248 +1427,3 @@ def test_settings_tool_gamepad_reaches_action_buttons(tool: str, monkeypatch: Mo
|
||||
manager.handle_settings_dpad(PAD_DPAD_Y, -1, 0)
|
||||
assert QApplication.focusWidget() is control
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_confirm_in_dialog_uses_its_own_keyboard() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = cast(Any, QDialog())
|
||||
field = QLineEdit(dialog)
|
||||
shown_for = []
|
||||
dialog.keyboard = SimpleNamespace(show_for_widget=shown_for.append)
|
||||
main_keyboard = MagicMock()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=main_keyboard))
|
||||
confirm = next(iter(input_manager.BUTTONS["confirm"]))
|
||||
|
||||
assert manager._route_button_to_text_input(dialog, field, confirm)
|
||||
assert shown_for == [field]
|
||||
main_keyboard.show_for_widget.assert_not_called()
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_dpad_stays_on_active_dialog_keyboard(monkeypatch: MonkeyPatch) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = cast(Any, QDialog())
|
||||
field = QLineEdit(dialog)
|
||||
key = QPushButton("Key", dialog)
|
||||
key.move(0, 40)
|
||||
down = MagicMock(side_effect=key.setFocus)
|
||||
dialog.keyboard = SimpleNamespace(isVisible=lambda: True, move_focus_left=MagicMock(), move_focus_right=MagicMock(),
|
||||
move_focus_up=MagicMock(), move_focus_down=down)
|
||||
dialog.show()
|
||||
dialog.activateWindow()
|
||||
app.processEvents()
|
||||
field.setFocus()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None, gamesListWidget=QWidget()))
|
||||
manager.axis_moving = False
|
||||
manager._gamepad_handling_enabled = True
|
||||
navigation = MagicMock()
|
||||
monkeypatch.setattr(manager, "_start_dpad_repeat", MagicMock())
|
||||
monkeypatch.setattr(manager, "_route_dpad_navigation", navigation)
|
||||
monkeypatch.setattr(QApplication, "activeWindow", lambda: dialog)
|
||||
manager._handle_default_dpad(PAD_DPAD_Y, 1, 0.0)
|
||||
down.assert_called_once()
|
||||
navigation.assert_not_called()
|
||||
assert QApplication.focusWidget() is key
|
||||
dialog.close()
|
||||
|
||||
|
||||
@mark.parametrize("mode", ["import", "exe", "directory"])
|
||||
def test_file_picker_x_does_not_put_folder_in_executable_field(
|
||||
tmp_path: Any, monkeypatch: MonkeyPatch, mode: str,
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QListWidget
|
||||
from portprotonqt.dialogs.base import FileSelectedSignal
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
folder = tmp_path / "Games"
|
||||
folder.mkdir()
|
||||
picker = cast(Any, QDialog())
|
||||
picker.current_path = str(tmp_path)
|
||||
picker.directory_only = mode == "directory"
|
||||
picker.file_list = QListWidget(picker)
|
||||
picker.file_list.addItem("Games/")
|
||||
picker.file_list.setCurrentRow(0)
|
||||
picker.file_signal = FileSelectedSignal()
|
||||
selected = []
|
||||
picker.file_signal.file_selected.connect(selected.append)
|
||||
action = QPushButton(picker)
|
||||
if mode == "import":
|
||||
action.setObjectName("gamepadAddButton")
|
||||
clicked = MagicMock()
|
||||
action.clicked.connect(clicked)
|
||||
picker.show()
|
||||
app.processEvents()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None))
|
||||
manager.file_explorer = picker
|
||||
monkeypatch.setattr(QApplication, "activeWindow", lambda: picker)
|
||||
monkeypatch.setattr(QApplication, "activePopupWidget", lambda: None)
|
||||
manager.handle_file_explorer_button(next(iter(input_manager.BUTTONS["add_game"])), 1)
|
||||
assert clicked.call_count == int(mode == "import")
|
||||
assert selected == ([str(folder)] if mode == "directory" else [])
|
||||
assert picker.result() == (QDialog.DialogCode.Accepted if mode == "directory" else QDialog.DialogCode.Rejected)
|
||||
picker.close()
|
||||
|
||||
|
||||
def test_x_edits_keyboard_without_triggering_dialog_action(monkeypatch: MonkeyPatch) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = cast(Any, QDialog())
|
||||
keyboard = QFrame(dialog)
|
||||
dialog.keyboard = keyboard
|
||||
action = QPushButton(dialog)
|
||||
action.setObjectName("gamepadAddButton")
|
||||
clicked = MagicMock()
|
||||
action.clicked.connect(clicked)
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None))
|
||||
handled = MagicMock()
|
||||
monkeypatch.setattr(manager, "_handle_common_ui_elements", lambda _button: False)
|
||||
monkeypatch.setattr(manager, "handle_virtual_keyboard", handled)
|
||||
monkeypatch.setattr(input_buttons, "VirtualKeyboard", QFrame)
|
||||
monkeypatch.setattr(QApplication, "activeWindow", lambda: dialog)
|
||||
button = next(iter(input_manager.BUTTONS["add_game"]))
|
||||
manager._handle_default_button(button, 1)
|
||||
handled.assert_called_once_with(button, 1)
|
||||
clicked.assert_not_called()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@mark.parametrize("row", [0, 1])
|
||||
def test_gamepad_combo_confirm_emits_activation(row: int) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
combo = QComboBox()
|
||||
combo.addItems(["GOG", "PortProton"])
|
||||
activated = []
|
||||
combo.activated.connect(activated.append)
|
||||
combo.show()
|
||||
combo.showPopup()
|
||||
combo.view().setCurrentIndex(combo.model().index(row, 0))
|
||||
manager = InputManager.__new__(InputManager)
|
||||
confirm = next(iter(input_manager.BUTTONS["confirm"]))
|
||||
|
||||
assert manager._handle_list_view_button(combo.view(), confirm)
|
||||
|
||||
assert activated == [row]
|
||||
assert combo.currentIndex() == row
|
||||
assert not combo.view().isVisible()
|
||||
combo.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_readonly_combo_confirm_opens_popup_without_keyboard() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
combo = QComboBox()
|
||||
combo.addItems(["GOG", "PortProton"])
|
||||
combo.setEditable(True)
|
||||
field = combo.lineEdit()
|
||||
assert field is not None
|
||||
field.setReadOnly(True)
|
||||
keyboard = MagicMock()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=keyboard))
|
||||
confirm = next(iter(input_manager.BUTTONS["confirm"]))
|
||||
combo.show()
|
||||
|
||||
assert not manager._route_button_to_text_input(combo, field, confirm)
|
||||
assert manager._handle_combo_button(field, confirm)
|
||||
assert combo.view().isVisible()
|
||||
keyboard.show_for_widget.assert_not_called()
|
||||
combo.hidePopup()
|
||||
combo.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_confirm_on_open_combo_activates_highlighted_row() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
combo = QComboBox()
|
||||
combo.addItems(["GOG", "PortProton"])
|
||||
activated = []
|
||||
combo.activated.connect(activated.append)
|
||||
combo.show()
|
||||
combo.showPopup()
|
||||
combo.view().setCurrentIndex(combo.model().index(1, 0))
|
||||
manager = InputManager.__new__(InputManager)
|
||||
confirm = next(iter(input_manager.BUTTONS["confirm"]))
|
||||
|
||||
assert manager._handle_combo_button(combo, confirm)
|
||||
|
||||
assert activated == [1]
|
||||
assert not combo.view().isVisible()
|
||||
combo.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@mark.parametrize("direction", [-1, 1])
|
||||
@mark.parametrize("column", [0, 1])
|
||||
def test_run_after_dpad_reaches_delete_from_entry(direction: int, column: int) -> None:
|
||||
from portprotonqt.input_manager.settings import SettingsInputMixin
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
table = QTableWidget(1, 2)
|
||||
entry = QLineEdit("game.exe")
|
||||
remove = QPushButton("delete")
|
||||
table.setCellWidget(0, 0, entry)
|
||||
table.setCellWidget(0, 1, remove)
|
||||
manager = SimpleNamespace(settings_dialog=SimpleNamespace(advanced_table=table))
|
||||
table.show()
|
||||
table.activateWindow()
|
||||
table.setCurrentCell(0, column)
|
||||
entry.setFocus()
|
||||
app.processEvents()
|
||||
assert SettingsInputMixin._move_settings_inline_button_focus(cast(SettingsInputMixin, manager), table, direction)
|
||||
assert QApplication.focusWidget() == remove
|
||||
assert SettingsInputMixin._move_settings_inline_button_focus(cast(SettingsInputMixin, manager), table, -direction)
|
||||
assert QApplication.focusWidget() == entry
|
||||
table.close()
|
||||
table.deleteLater()
|
||||
|
||||
|
||||
def test_mouse_device_follows_emulation_toggle(monkeypatch: MonkeyPatch) -> None:
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
manager.mouse_emulation_enabled = True
|
||||
manager.emulation_triggered = False
|
||||
manager.mouse_emulation_thread = None
|
||||
manager.ui = None
|
||||
create = MagicMock()
|
||||
monkeypatch.setattr(manager, "_async_enable_mouse_emulation", create)
|
||||
|
||||
manager._update_emulation_flag()
|
||||
create.assert_not_called()
|
||||
manager.emulation_triggered = True
|
||||
manager._update_emulation_flag()
|
||||
create.assert_called_once_with()
|
||||
|
||||
device = MagicMock()
|
||||
manager.ui = device
|
||||
manager.emulation_triggered = False
|
||||
manager._update_emulation_flag()
|
||||
device.close.assert_called_once_with()
|
||||
assert manager.ui is None
|
||||
assert manager.mouse_emulation_enabled
|
||||
manager.emulation_triggered = True
|
||||
manager._update_emulation_flag()
|
||||
assert create.call_count == 2
|
||||
|
||||
|
||||
def test_mouse_shutdown_collects_pending_device() -> None:
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
device = MagicMock()
|
||||
thread = MagicMock()
|
||||
thread.get_device.return_value = device
|
||||
manager.mouse_emulation_thread = thread
|
||||
manager.mouse_emulation_enabled = True
|
||||
manager.ui = None
|
||||
|
||||
manager.disable_mouse_emulation()
|
||||
manager._on_mouse_emulation_finished(True)
|
||||
|
||||
thread.wait.assert_called_once_with()
|
||||
device.close.assert_called_once_with()
|
||||
assert manager.ui is None
|
||||
assert manager.mouse_emulation_thread is None
|
||||
+16
-138
@@ -1,6 +1,8 @@
|
||||
"""Progressive library loading, drawing and input regressions."""
|
||||
from collections.abc import Callable, Generator
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
@@ -13,9 +15,7 @@ from PySide6.QtWidgets import QApplication, QLineEdit, QWidget
|
||||
from portprotonqt import image_utils
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
|
||||
LOAD_TIMEOUT_MS = 30000
|
||||
from portprotonqt.theme_manager import load_theme
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -54,23 +54,6 @@ def library_scene(
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
def test_native_card_uses_linux_source_corner(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
monkeypatch.setattr(ThemeManager(), "current_theme_name", "standart")
|
||||
card = manager._create_game_card((
|
||||
"Native", "", "", "", "", "native:///game.desktop", "", "", "", "", 0, 0, "native",
|
||||
))
|
||||
assert card.native_visible
|
||||
assert isinstance(card.nativeLabel._icon, str)
|
||||
assert Path(card.nativeLabel._icon).name == "badge_linux.svg"
|
||||
assert Path(card.nativeLabel._icon).is_file()
|
||||
assert not card.portproton_visible
|
||||
card.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
|
||||
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
|
||||
def test_new_horizontal_cards_fit_before_layout_insertion(
|
||||
@@ -416,7 +399,7 @@ def real_cover_scene(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("animation_type", ["gradient", "glow"])
|
||||
def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
def test_real_cover_loading_and_first_paint_focus_latency(
|
||||
real_cover_scene: tuple[GameLibraryManager, Path],
|
||||
monkeypatch: pytest.MonkeyPatch, animation_type: str,
|
||||
) -> None:
|
||||
@@ -424,14 +407,14 @@ def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
monkeypatch.setattr(manager.theme, "GAME_CARD_ANIMATION",
|
||||
{**manager.theme.GAME_CARD_ANIMATION, "card_animation_type": animation_type})
|
||||
threads: list[bool] = []
|
||||
paints: list[bool] = []
|
||||
ticks: list[tuple[int, int]] = []
|
||||
paints: list[tuple[float, bool]] = []
|
||||
ticks: list[float] = []
|
||||
original_paint = GameCard.paintEvent
|
||||
original_cover = GameCard.on_cover_loaded
|
||||
|
||||
def record_paint(card: GameCard, event: Any) -> None:
|
||||
if card.name == "Game 0":
|
||||
paints.append(card._focused or card._hovered)
|
||||
paints.append((perf_counter(), card._focused or card._hovered))
|
||||
original_paint(card, event)
|
||||
|
||||
def record_cover(card: GameCard, pixmap: QPixmap) -> None:
|
||||
@@ -439,7 +422,7 @@ def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
original_cover(card, pixmap)
|
||||
|
||||
def sample() -> None:
|
||||
ticks.append((len(manager.game_card_cache), len(threads)))
|
||||
ticks.append(perf_counter())
|
||||
if len(threads) == len(games) and not manager._incremental_add_queue:
|
||||
loop.quit()
|
||||
|
||||
@@ -451,14 +434,18 @@ def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
timer.setInterval(5)
|
||||
timer.timeout.connect(sample)
|
||||
timer.start()
|
||||
QTimer.singleShot(LOAD_TIMEOUT_MS, loop.quit)
|
||||
QTimer.singleShot(2000, loop.quit)
|
||||
manager.set_games(games)
|
||||
loop.exec()
|
||||
timer.stop()
|
||||
assert len(threads) == len(games) and all(threads), threads
|
||||
assert paints and paints[0], paints
|
||||
assert any(0 < cards < len(games) for cards, _ in ticks), ticks
|
||||
assert any(0 < covers < len(games) for _, covers in ticks), ticks
|
||||
highlighted = next((paint for paint in paints if paint[1]), None)
|
||||
assert highlighted is not None
|
||||
focus_delay = highlighted[0] - paints[0][0]
|
||||
max_gap = max(after - before for before, after in zip(ticks, ticks[1:], strict=False))
|
||||
logging.getLogger(__name__).info("%s: highlight %.1f ms, UI pause %.1f ms", animation_type, focus_delay * 1000, max_gap * 1000)
|
||||
assert focus_delay < 0.05, paints
|
||||
assert max_gap < 0.1, ticks
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", [("grid", 0), ("grid", 1), ("vertical", 0), ("vertical", 1)])
|
||||
@@ -468,7 +455,6 @@ def test_failed_protondb_completion_preserves_library_scroll(
|
||||
) -> None:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import api
|
||||
import portprotonqt.portproton_api as portproton_api
|
||||
|
||||
manager, _ = library_scene
|
||||
manager.rebuild_library_layout(scenario[0])
|
||||
@@ -477,7 +463,6 @@ def test_failed_protondb_completion_preserves_library_scroll(
|
||||
main.getColorPalette_from_pixmap = MagicMock()
|
||||
main.game_library_manager = manager
|
||||
main._get_portproton_desktop_files = lambda: set()
|
||||
monkeypatch.setattr(portproton_api, "get_user_conf_setting", lambda _key: None)
|
||||
monkeypatch.setattr(api, "load_protondb_status", lambda _appid: None)
|
||||
monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace(cache_dir=tmp_path))
|
||||
responses: list[Callable[[str | None], None]] = []
|
||||
@@ -560,110 +545,3 @@ def test_vertical_library_headers_align_with_values(
|
||||
assert heading.alignment() == value.alignment() == Qt.AlignmentFlag.AlignCenter
|
||||
assert abs(heading.mapToGlobal(heading.rect().center()).x()
|
||||
- value.mapToGlobal(value.rect().center()).x()) <= 1
|
||||
|
||||
|
||||
def test_space_enters_and_toggles_card_selection_without_opening_details(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
game = ("Game", "", "", "", "", "game.exe", "", "", "", "", 0, 0, "portproton")
|
||||
card = manager._create_game_card(game)
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
|
||||
selection_manager = object.__new__(ContextMenuManager)
|
||||
selection_manager.game_library_manager = manager
|
||||
selection_manager.card_selection_mode = False
|
||||
manager.game_card_cache[(card.name, card.exec_line)] = card
|
||||
card.context_menu_manager = selection_manager
|
||||
event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Space, Qt.KeyboardModifier.NoModifier)
|
||||
card.keyPressEvent(event)
|
||||
assert card.batch_selected
|
||||
assert selection_manager.card_selection_mode
|
||||
event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Space, Qt.KeyboardModifier.NoModifier)
|
||||
card.keyPressEvent(event)
|
||||
assert not card.batch_selected
|
||||
assert not selection_manager.card_selection_mode
|
||||
cast(Any, manager.main_window).openGameDetailPage.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("activation", ["mouse", "gamepad"])
|
||||
@pytest.mark.parametrize("game_source", ["portproton", "gog", "egs"])
|
||||
def test_selection_mode_toggles_without_opening_game(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, activation: str, game_source: str,
|
||||
) -> None:
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
library, _ = library_scene
|
||||
menu = object.__new__(ContextMenuManager)
|
||||
menu.game_library_manager = library
|
||||
game = ("Game", "", "", "", "", "missing.exe", "", "", "", "", 0, 0, game_source)
|
||||
card = library._create_game_card(game)
|
||||
card.context_menu_manager = menu
|
||||
library.game_card_cache[(card.name, card.exec_line)] = card
|
||||
monkeypatch.setattr(QApplication, "focusWidget", lambda: card)
|
||||
menu.start_card_selection(card)
|
||||
assert menu.card_selection_mode and card.batch_selected
|
||||
for selected in (False, True):
|
||||
if activation == "mouse":
|
||||
position = card.rect().center().toPointF()
|
||||
card.mousePressEvent(QMouseEvent(
|
||||
QEvent.Type.MouseButtonPress, position, position,
|
||||
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
||||
))
|
||||
else:
|
||||
MainWindow.activateFocusedWidget(cast(Any, library.main_window))
|
||||
assert card.batch_selected is selected
|
||||
cast(Any, library.main_window).openGameDetailPage.assert_not_called()
|
||||
menu.clear_card_selection()
|
||||
assert not menu.card_selection_mode and not card.batch_selected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["grid", "horizontal", "horizontal_top", "list", "vertical"])
|
||||
def test_search_preserves_selection_across_different_queries(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, layout_mode: str,
|
||||
) -> None:
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
|
||||
library, _ = library_scene
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_library_layout_mode", lambda _: layout_mode)
|
||||
monkeypatch.setattr(library, "_incremental_batch_size", 1)
|
||||
library.rebuild_library_layout(layout_mode)
|
||||
games = [(name, "", "", "", "", name, "", "", "", "", 0, 0, "portproton")
|
||||
for name in ("DOOM64", "Akalabeth of Doom", "Biomance")]
|
||||
cast(Any, library.main_window)._loaded_library_cache = {"portproton": games}
|
||||
library.set_games(games, focus_first_card=False)
|
||||
assert library._update_timer is not None and library._incremental_add_timer is not None
|
||||
library._update_timer.stop()
|
||||
library._update_game_grid_immediate()
|
||||
while library._incremental_add_queue:
|
||||
library._process_incremental_add_batch()
|
||||
library._incremental_add_timer.stop()
|
||||
menu = object.__new__(ContextMenuManager)
|
||||
menu.game_library_manager = library
|
||||
cards = {card.name: card for card in library.game_card_cache.values()}
|
||||
for card in cards.values():
|
||||
card.context_menu_manager = menu
|
||||
for query in ("doom", "bio", ""):
|
||||
library.main_window.searchEdit.setText(query)
|
||||
library.filter_games_delayed()
|
||||
library._update_timer.stop()
|
||||
library._update_game_grid_immediate()
|
||||
while library._incremental_add_queue:
|
||||
library._process_incremental_add_batch()
|
||||
library._incremental_add_timer.stop()
|
||||
if query == "doom":
|
||||
menu.start_card_selection(cards["DOOM64"])
|
||||
cards["Akalabeth of Doom"].click()
|
||||
elif query == "bio":
|
||||
cards["Biomance"].click()
|
||||
assert not cards["DOOM64"].isVisible()
|
||||
if query != "doom":
|
||||
assert all(card.batch_selected for card in cards.values())
|
||||
assert menu.card_selection_mode
|
||||
menu.clear_card_selection()
|
||||
assert not any(card.batch_selected for card in cards.values())
|
||||
cast(Any, library.main_window).openGameDetailPage.assert_not_called()
|
||||
+30
-429
@@ -11,10 +11,9 @@ from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import psutil
|
||||
from pytest import MonkeyPatch, fixture, mark, raises
|
||||
from pytest import MonkeyPatch, fixture, mark
|
||||
from PySide6.QtCore import QEvent, QEventLoop, QObject, QPoint, QPointF, Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QMouseEvent, QPixmap, QWheelEvent
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
@@ -23,9 +22,6 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QMenu,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QStyle,
|
||||
QStyleOptionComboBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
@@ -35,11 +31,10 @@ from PySide6.QtWidgets import (
|
||||
from portprotonqt.animations.library_controls import _animation_duration
|
||||
from portprotonqt.animations.game_card import GameCardAnimations
|
||||
from portprotonqt.config import game_config
|
||||
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton
|
||||
from portprotonqt.detail_pages import DetailPageManager
|
||||
from portprotonqt.game_card import GameCard, SourceCorner
|
||||
from portprotonqt.game_library_manager import FullLibraryTile, GameLibraryManager
|
||||
from portprotonqt.input_manager import InputManager
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.search_utils import SearchOptimizer, search_index
|
||||
from portprotonqt.tray_manager import TrayManager
|
||||
@@ -74,125 +69,6 @@ from portprotonqt.tabs.control_hints import MainWindowControlHintsMixin
|
||||
import portprotonqt.localization as localization
|
||||
|
||||
|
||||
@mark.parametrize("game_path,script_path,expected", [
|
||||
("/games/Game.exe", "/opt/PortProton/start.sh", False),
|
||||
("/games/Игра/Game.exe", "/opt/PortProton/start.sh", True),
|
||||
("/games/Ґра.exe", "/opt/PortProton/start.sh", True),
|
||||
("/games/Game.exe", "/home/Игрок/PortProton/start.sh", True),
|
||||
("/games/日本語/Game.exe", "/opt/PortProton/start.sh", False),
|
||||
])
|
||||
def test_cyrillic_launch_warning(
|
||||
monkeypatch: MonkeyPatch, game_path: str, script_path: str, expected: bool,
|
||||
) -> None:
|
||||
message_box = MagicMock()
|
||||
message_box.addButton.return_value = object()
|
||||
message_box_type = MagicMock(return_value=message_box)
|
||||
message_box_type.Icon = QMessageBox.Icon
|
||||
message_box_type.StandardButton = QMessageBox.StandardButton
|
||||
message_box_type.ButtonRole = QMessageBox.ButtonRole
|
||||
window = SimpleNamespace(start_sh=[script_path])
|
||||
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
|
||||
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
|
||||
|
||||
assert MainWindow._warn_cyrillic_launch_paths(cast(Any, window), game_path) is expected
|
||||
|
||||
assert message_box.exec.call_count == int(expected)
|
||||
if expected:
|
||||
assert (game_path if "games/" in game_path and game_path != "/games/Game.exe"
|
||||
else script_path) in message_box.setText.call_args.args[0]
|
||||
|
||||
|
||||
def test_cyrillic_launch_warning_resolves_symlinks(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "Игры"
|
||||
target.mkdir()
|
||||
link = tmp_path / "games"
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
message_box = MagicMock()
|
||||
message_box.addButton.return_value = object()
|
||||
message_box_type = MagicMock(return_value=message_box)
|
||||
message_box_type.Icon = QMessageBox.Icon
|
||||
message_box_type.StandardButton = QMessageBox.StandardButton
|
||||
message_box_type.ButtonRole = QMessageBox.ButtonRole
|
||||
window = SimpleNamespace(start_sh=[])
|
||||
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
|
||||
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
|
||||
|
||||
MainWindow._warn_cyrillic_launch_paths(cast(Any, window), str(link / "game.exe"))
|
||||
|
||||
message_box.exec.assert_called_once()
|
||||
assert str(target / "game.exe") in message_box.setText.call_args.args[0]
|
||||
|
||||
|
||||
def test_cyrillic_launch_warning_can_be_disabled(monkeypatch: MonkeyPatch) -> None:
|
||||
set_disabled = MagicMock()
|
||||
disable_button = object()
|
||||
message_box = MagicMock()
|
||||
message_box.addButton.return_value = disable_button
|
||||
message_box.clickedButton.return_value = disable_button
|
||||
message_box_type = MagicMock(return_value=message_box)
|
||||
message_box_type.Icon = QMessageBox.Icon
|
||||
message_box_type.StandardButton = QMessageBox.StandardButton
|
||||
message_box_type.ButtonRole = QMessageBox.ButtonRole
|
||||
window = SimpleNamespace(start_sh=[])
|
||||
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
|
||||
monkeypatch.setattr(main_window_module.ui_config, "set_disable_cyrillic_path_check", set_disabled)
|
||||
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
|
||||
|
||||
MainWindow._warn_cyrillic_launch_paths(cast(Any, window), "/games/Игра.exe")
|
||||
|
||||
set_disabled.assert_called_once_with(True)
|
||||
|
||||
|
||||
@mark.parametrize("source", ["local", "gog", "egs"])
|
||||
def test_cyrillic_paths_continue_launch(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path, source: str,
|
||||
) -> None:
|
||||
target = tmp_path / "Игры" / "Breach.exe"
|
||||
target.parent.mkdir()
|
||||
target.touch()
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
window.start_sh = ["/opt/PortProton/start.sh"]
|
||||
window.game_processes = []
|
||||
window.target_exe = None
|
||||
window.current_play_button = None
|
||||
window._check_alt_i586_dependencies_before_launch = lambda: True
|
||||
window._check_missing_prefix_before_launch = MagicMock()
|
||||
window._finish_silent_launch = MagicMock()
|
||||
window._start_gog_support_setup = MagicMock()
|
||||
api = SimpleNamespace(
|
||||
ensure_launch_parameters=lambda _app_id: None,
|
||||
get_installed_path=lambda _app_id: target.parent,
|
||||
get_launch_target=lambda _app_id: str(target),
|
||||
needs_support_setup=lambda _app_id: True,
|
||||
build_command=lambda arguments: arguments,
|
||||
get_environment=lambda: {},
|
||||
)
|
||||
window.gog_api = api
|
||||
window.egs_api = api
|
||||
warning = MagicMock(return_value=True)
|
||||
popen = MagicMock(side_effect=RuntimeError("launch reached"))
|
||||
window._warn_cyrillic_launch_paths = warning
|
||||
monkeypatch.setattr(main_window_module.subprocess, "Popen", popen)
|
||||
monkeypatch.setattr(main_window_module.SoundManager, "play", MagicMock())
|
||||
monkeypatch.setattr(main_window_module.QMessageBox, "warning", MagicMock())
|
||||
|
||||
if source == "local":
|
||||
window.toggleGame(shlex.quote(str(target)), game_name="Breach")
|
||||
elif source == "egs":
|
||||
with raises(RuntimeError, match="launch reached"):
|
||||
window._launch_egs_game("123")
|
||||
else:
|
||||
window._launch_gog_game("123")
|
||||
|
||||
warning.assert_called_once()
|
||||
if source == "gog":
|
||||
window._start_gog_support_setup.assert_called_once_with("123", None)
|
||||
else:
|
||||
popen.assert_called_once()
|
||||
|
||||
|
||||
@mark.parametrize("fullscreen, enabled", [(False, True), (True, True), (True, False)])
|
||||
def test_fullscreen_cursor_hides_after_inactivity(monkeypatch: MonkeyPatch, fullscreen: bool, enabled: bool) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
@@ -316,7 +192,7 @@ def _tile_theme() -> Any:
|
||||
|
||||
@mark.parametrize(
|
||||
"worker_name",
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "native_library_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "store_dlc_worker"),
|
||||
)
|
||||
def test_shutdown_waits_for_store_library_workers(worker_name: str) -> None:
|
||||
worker = MagicMock()
|
||||
@@ -434,17 +310,15 @@ def test_minimal_tray_exits_after_stopping_game(monkeypatch: MonkeyPatch) -> Non
|
||||
manager.tray_icon.hide.assert_called_once_with()
|
||||
quit_app.assert_called_once_with()
|
||||
|
||||
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, True)])
|
||||
@mark.parametrize("source", ["portproton", "native"])
|
||||
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, False)])
|
||||
def test_source_ribbon_visibility(
|
||||
monkeypatch: MonkeyPatch,
|
||||
vertical_layout: bool,
|
||||
ribbon_visible: bool,
|
||||
source: str,
|
||||
) -> None:
|
||||
card = MagicMock()
|
||||
card.coverLabel = MagicMock()
|
||||
card.game_source = source
|
||||
card.game_source = "portproton"
|
||||
card.ppdb_id = "report-id"
|
||||
card.protondb_tier = "Platinum"
|
||||
card.anticheat_status = "Supported"
|
||||
@@ -455,9 +329,9 @@ def test_source_ribbon_visibility(
|
||||
card.parentWidget.return_value = None
|
||||
monkeypatch.setattr("portprotonqt.game_card.ui_config.get_economy_mode", lambda: False)
|
||||
|
||||
GameCard.update_badge_visibility(card, source)
|
||||
GameCard.update_badge_visibility(card, "portproton")
|
||||
|
||||
getattr(card, f"{source}Label").setVisible.assert_called_with(ribbon_visible)
|
||||
card.portprotonLabel.setVisible.assert_called_with(ribbon_visible)
|
||||
card.ppdbLabel.setVisible.assert_called_with(False)
|
||||
card.protondbLabel.setVisible.assert_called_with(False)
|
||||
card.anticheatLabel.setVisible.assert_called_with(False)
|
||||
@@ -793,9 +667,6 @@ def test_vertical_theme_rebuilds_autoinstall_layout() -> None:
|
||||
window.auto_size_slider.maximum.return_value = 250
|
||||
window.autoInstallLoading = False
|
||||
window._start_autoinstall_load = MagicMock()
|
||||
window.auto_install_tab_index = 1
|
||||
window.stackedWidget = MagicMock()
|
||||
window.stackedWidget.currentIndex.return_value = 1
|
||||
|
||||
window.refresh_autoinstall_layout()
|
||||
|
||||
@@ -1287,7 +1158,6 @@ def test_game_card_click_uses_select_callback() -> None:
|
||||
autoinstall_exe_name="",
|
||||
select_callback=select_callback,
|
||||
_get_missing_executable_path=lambda: "",
|
||||
context_menu_manager=None,
|
||||
)
|
||||
|
||||
GameCard.click(cast(Any, card))
|
||||
@@ -1427,7 +1297,7 @@ def test_library_source_filter_stays_top_aligned_without_checkbox(
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
test_window = cast(Any, window)
|
||||
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE="")
|
||||
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="")
|
||||
test_window._create_library_combo = lambda labels, _tooltip: QComboBox()
|
||||
test_window._set_combo_current_key = lambda *_args: None
|
||||
test_window._register_gamepad_tooltip = lambda *_args: None
|
||||
@@ -1709,8 +1579,7 @@ def test_open_local_autoinstall_card_uses_autoinstall_page(tmp_path: Path) -> No
|
||||
assert game_data["exec_line"] == f"autoinstall:{shlex.quote(str(script_path))}"
|
||||
assert return_tab_index == 0
|
||||
|
||||
@mark.parametrize("native", [False, True])
|
||||
def test_open_game_detail_starts_pending_log(tmp_path: Path, native: bool) -> None:
|
||||
def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
|
||||
exe_path = tmp_path / "Game.exe"
|
||||
exe_path.touch()
|
||||
started = []
|
||||
@@ -1735,12 +1604,11 @@ def test_open_game_detail_starts_pending_log(tmp_path: Path, native: bool) -> No
|
||||
window.detail_page_manager = FakeDetailPageManager()
|
||||
window.stackedWidget = FakeStackedWidget()
|
||||
window.currentDetailPage = None
|
||||
target = "native://" + str(tmp_path / "Native Game.desktop") if native else str(exe_path)
|
||||
window._pending_log_exe = target
|
||||
window._pending_log_exe = str(exe_path)
|
||||
|
||||
window.openGameDetailPage({"name": "Game", "exec_line": target})
|
||||
window.openGameDetailPage({"name": "Game", "exec_line": str(exe_path)})
|
||||
|
||||
assert started == [(target, window.detail_page_manager._debug_log_button)]
|
||||
assert started == [(str(exe_path), window.detail_page_manager._debug_log_button)]
|
||||
assert window._pending_log_exe is None
|
||||
|
||||
def test_launch_exe_skips_library_load_for_ppai() -> None:
|
||||
@@ -1892,7 +1760,7 @@ def test_dxvk_incompatibility_reports_after_manual_stop(
|
||||
reports = []
|
||||
launch = SimpleNamespace(executable="/games/game.exe", duration=60.0)
|
||||
window.portproton_location = "/portproton"
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=lambda *args: reports.append(args))
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=reports.append)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.has_dxvk_vulkan_incompatibility",
|
||||
lambda _path: True,
|
||||
@@ -1904,7 +1772,7 @@ def test_dxvk_incompatibility_reports_after_manual_stop(
|
||||
|
||||
window._build_compatibility_report(launch, stopped_by_user=True)
|
||||
|
||||
assert reports == [("forced report", launch.executable)]
|
||||
assert reports == ["forced report"]
|
||||
|
||||
def test_disabled_crash_reports_skip_launch_analysis(
|
||||
monkeypatch: MonkeyPatch,
|
||||
@@ -1973,7 +1841,7 @@ def test_nonzero_wine_exit_reports_after_long_game(monkeypatch: MonkeyPatch) ->
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
window.portproton_location = "/portproton"
|
||||
reports = []
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=lambda *args: reports.append(args))
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=reports.append)
|
||||
launch = main_window_module.CompatibilityLaunch("/games/Game.exe", 5, 120.0)
|
||||
monkeypatch.setattr(main_window_module, "has_dxvk_vulkan_incompatibility", lambda _path: False)
|
||||
monkeypatch.setattr(main_window_module.os.path, "isfile", lambda _path: True)
|
||||
@@ -1983,7 +1851,7 @@ def test_nonzero_wine_exit_reports_after_long_game(monkeypatch: MonkeyPatch) ->
|
||||
window._build_compatibility_report(launch, stopped_by_user=False)
|
||||
window._build_compatibility_report(launch, stopped_by_user=True)
|
||||
|
||||
assert reports == [("crash report", launch.executable)]
|
||||
assert reports == ["crash report"]
|
||||
|
||||
def test_update_prefix_log_does_not_mark_wine_launch_start() -> None:
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -2001,17 +1869,14 @@ def test_show_compatibility_report_uses_report_dialog(monkeypatch: MonkeyPatch)
|
||||
def __init__(self, parent: object, theme: object, report: str) -> None:
|
||||
calls.append((parent, theme, report))
|
||||
|
||||
def check_ppdb(self, executable: str) -> None:
|
||||
calls.append(executable)
|
||||
|
||||
def exec(self) -> None:
|
||||
calls.append("exec")
|
||||
|
||||
monkeypatch.setattr("portprotonqt.main_window.CompatibilityReportDialog", ReportDialog)
|
||||
|
||||
window._show_compatibility_report("report text", "/games/Game.exe")
|
||||
window._show_compatibility_report("report text")
|
||||
|
||||
assert calls == [(window, window.theme, "report text"), "/games/Game.exe", "exec"]
|
||||
assert calls == [(window, window.theme, "report text"), "exec"]
|
||||
|
||||
def test_refresh_theme_store_visibility_adds_store(monkeypatch: Any) -> None:
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -2268,12 +2133,10 @@ def test_steam_launch_plays_game_launch_sound(monkeypatch: MonkeyPatch) -> None:
|
||||
|
||||
assert launch_events == ["popen", "game_launch"]
|
||||
|
||||
@mark.parametrize("custom_name", [None, "Edited Local Game"])
|
||||
def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
tmp_config_dir: Path,
|
||||
tmp_path: Path,
|
||||
monkeypatch: Any,
|
||||
custom_name: str | None,
|
||||
) -> None:
|
||||
exe_path = tmp_path / "Game.exe"
|
||||
exe_path.write_text("", encoding="utf-8")
|
||||
@@ -2289,15 +2152,8 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
window.portproton_location = str(tmp_path)
|
||||
results: list[tuple | None] = []
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
if custom_name:
|
||||
from portprotonqt.config import get_custom_data_dir_name
|
||||
game_dir = custom_data_path / get_custom_data_dir_name(str(exe_path))
|
||||
game_dir.mkdir(parents=True)
|
||||
(game_dir / "metadata.txt").write_text(f"name={custom_name}\n", encoding="utf-8")
|
||||
|
||||
def fake_steam_info(_name: str, _exec_line: str, callback: Any) -> None:
|
||||
assert _name == "Test Game"
|
||||
callback({})
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -2317,9 +2173,10 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0] is not None
|
||||
assert results[0][0] == (custom_name or "Test Game")
|
||||
assert results[0][0] == "Test Game"
|
||||
assert results[0][5] == f"portproton {exe_path}"
|
||||
assert custom_data_path.exists() == bool(custom_name)
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
assert not custom_data_path.exists()
|
||||
|
||||
def test_remove_empty_custom_data_dirs_keeps_non_empty_dirs(tmp_config_dir: Path) -> None:
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
@@ -2620,7 +2477,7 @@ def test_horizontal_layout_menu_maps_endless_modes(
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_library_layout_mode", lambda _: "theme")
|
||||
window = cast(Any, SimpleNamespace(
|
||||
theme=SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=""),
|
||||
theme=SimpleNamespace(CHECKBOX_STYLE=""),
|
||||
_create_library_combo=lambda labels, tooltip: QComboBox(),
|
||||
_set_combo_current_key=MagicMock(),
|
||||
_register_gamepad_tooltip=MagicMock(),
|
||||
@@ -2661,7 +2518,7 @@ def test_library_layout_control_rebuilds_both_libraries(
|
||||
monkeypatch.setattr(
|
||||
library_tab_module.ui_config,
|
||||
"get_library_layout_mode",
|
||||
MagicMock(side_effect=["list", "grid", "grid"]),
|
||||
lambda theme_mode: "grid",
|
||||
)
|
||||
|
||||
LibraryMixin._on_library_layout_changed(window, 1)
|
||||
@@ -2738,7 +2595,6 @@ def test_add_game_dialog_does_not_reprocess_its_shortcut(
|
||||
exeEdit=SimpleNamespace(text=lambda: "/games/praest.exe"),
|
||||
coverEdit=SimpleNamespace(text=lambda: ""),
|
||||
getDesktopEntryData=lambda: (entry, str(desktop_path)),
|
||||
native_checkbox=SimpleNamespace(isChecked=lambda: False),
|
||||
last_cover_path="",
|
||||
)
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -2893,22 +2749,15 @@ def test_library_refresh_updates_connected_gog_library(
|
||||
("load", {"force_load": True}),
|
||||
]
|
||||
|
||||
@mark.parametrize("display_filter, stores", [
|
||||
("all", ["gog", "egs"]),
|
||||
("gog,portproton", ["gog"]),
|
||||
("steam,portproton", []),
|
||||
])
|
||||
def test_library_refresh_updates_connected_stores_for_all_filter(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
display_filter: str,
|
||||
stores: list[str],
|
||||
) -> None:
|
||||
gog_auth_path = tmp_path / "gog_auth.json"
|
||||
egs_user_path = tmp_path / "egs_user.json"
|
||||
gog_auth_path.touch()
|
||||
egs_user_path.touch()
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: display_filter)
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "all")
|
||||
monkeypatch.setattr(
|
||||
library_tab_module.QTimer,
|
||||
"singleShot",
|
||||
@@ -2930,7 +2779,11 @@ def test_library_refresh_updates_connected_stores_for_all_filter(
|
||||
|
||||
LibraryMixin.refreshGames(cast(Any, window))
|
||||
|
||||
assert calls == [*stores, ("load", {"force_load": True})]
|
||||
assert calls == [
|
||||
"gog",
|
||||
"egs",
|
||||
("load", {"force_load": True}),
|
||||
]
|
||||
|
||||
def test_library_refresh_skips_gog_for_portproton_filter(
|
||||
tmp_path: Path,
|
||||
@@ -3133,256 +2986,4 @@ def test_autoinstall_table_columns_match_header(
|
||||
assert abs(heading.mapTo(window, heading.rect().center()).x()
|
||||
- value.mapTo(window, value.rect().center()).x()) <= 1
|
||||
window.stackedWidget.deleteLater()
|
||||
|
||||
|
||||
def test_shutdown_stops_batch_before_store_processes() -> None:
|
||||
calls: list[str] = []
|
||||
process = MagicMock()
|
||||
process.terminate.side_effect = lambda: calls.append("terminate")
|
||||
process.waitForFinished.side_effect = [False, True]
|
||||
menu = SimpleNamespace(stop_card_actions=lambda: calls.append("stop_batch"))
|
||||
window = SimpleNamespace(
|
||||
context_menu_manager=menu, egs_process=process, store_download_queue=[("egs", {})],
|
||||
_stopWorkerThread=MagicMock(), _stopWorkerThreads=MagicMock(),
|
||||
)
|
||||
MainWindowWorkersMixin._stopBackgroundWorkers(cast(Any, window))
|
||||
assert calls == ["stop_batch", "terminate"]
|
||||
process.kill.assert_called_once_with()
|
||||
assert process.waitForFinished.call_count == 2
|
||||
assert window.store_download_queue == []
|
||||
|
||||
|
||||
@mark.parametrize("mode", ["grid", "list", "horizontal", "horizontal_top"])
|
||||
def test_hidden_autoinstall_defers_layout_rebuild(mode: str, monkeypatch: MonkeyPatch) -> None:
|
||||
window = cast(Any, AutoInstallMixin())
|
||||
window.theme = SimpleNamespace(LIBRARY_LAYOUT_MODE=mode)
|
||||
window.autoInstallContainer = MagicMock()
|
||||
window.stackedWidget = MagicMock()
|
||||
window.stackedWidget.currentIndex.return_value = 0
|
||||
window.auto_install_tab_index = 1
|
||||
window.autoInstallLoaded = True
|
||||
window._set_autoinstall_container_layout = MagicMock()
|
||||
window._start_autoinstall_load = MagicMock()
|
||||
monkeypatch.setattr(autoinstall_tab_module.ui_config, "get_library_layout_mode", lambda _: mode)
|
||||
|
||||
window.refresh_autoinstall_layout()
|
||||
window.refresh_autoinstall_layout()
|
||||
|
||||
assert window._autoinstall_layout_pending is True
|
||||
assert window.autoInstallLoaded is False
|
||||
window._set_autoinstall_container_layout.assert_not_called()
|
||||
window._start_autoinstall_load.assert_not_called()
|
||||
|
||||
|
||||
def test_autoinstall_load_applies_pending_layout_before_loading() -> None:
|
||||
window = cast(Any, AutoInstallMixin())
|
||||
window._autoinstall_layout_pending = True
|
||||
window.autoInstallLoaded = True
|
||||
window.refresh_autoinstall_layout = MagicMock()
|
||||
window.portproton_api = MagicMock()
|
||||
|
||||
window._start_autoinstall_load()
|
||||
|
||||
window.refresh_autoinstall_layout.assert_called_once_with()
|
||||
window.portproton_api.start_autoinstall_games_load.assert_not_called()
|
||||
|
||||
|
||||
def test_library_theme_grid_switch_preserves_cards(
|
||||
monkeypatch: MonkeyPatch, tmp_config_dir: Path,
|
||||
) -> None:
|
||||
window = cast(Any, SimpleNamespace(
|
||||
library_layout_keys=["theme", "grid"],
|
||||
theme=SimpleNamespace(LIBRARY_LAYOUT_MODE="grid"),
|
||||
game_library_manager=SimpleNamespace(rebuild_library_layout=MagicMock()),
|
||||
refresh_autoinstall_layout=MagicMock(),
|
||||
))
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_library_layout_mode", lambda _: "grid")
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "set_library_layout_mode", MagicMock())
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "set_horizontal_card_orientation", MagicMock())
|
||||
|
||||
LibraryMixin._on_library_layout_changed(window, 1)
|
||||
LibraryMixin._on_library_layout_changed(window, 0)
|
||||
|
||||
window.game_library_manager.rebuild_library_layout.assert_not_called()
|
||||
window.refresh_autoinstall_layout.assert_not_called()
|
||||
|
||||
|
||||
def test_deferred_autoinstall_layout_loads_latest_mode_once(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
window = cast(Any, AutoInstallMixin())
|
||||
window.theme = SimpleNamespace(LIBRARY_LAYOUT_MODE="grid")
|
||||
window.autoInstallContainer = MagicMock()
|
||||
window.autoInstallStatusHeader = MagicMock()
|
||||
window.auto_size_slider = MagicMock()
|
||||
window.auto_size_slider.maximum.return_value = 250
|
||||
window.stackedWidget = MagicMock()
|
||||
window.stackedWidget.currentIndex.return_value = 0
|
||||
window.auto_install_tab_index = 1
|
||||
window.autoInstallLoaded = True
|
||||
window.autoInstallLoading = False
|
||||
window._set_autoinstall_container_layout = MagicMock()
|
||||
window._on_autoinstall_games_loaded = MagicMock()
|
||||
window.portproton_api = MagicMock()
|
||||
mode = ["list"]
|
||||
monkeypatch.setattr(autoinstall_tab_module.ui_config, "get_library_layout_mode", lambda _: mode[0])
|
||||
|
||||
window.refresh_autoinstall_layout()
|
||||
mode[0] = "vertical"
|
||||
window.refresh_autoinstall_layout()
|
||||
window.stackedWidget.currentIndex.return_value = 1
|
||||
window._start_autoinstall_load()
|
||||
|
||||
window._set_autoinstall_container_layout.assert_called_once_with("vertical")
|
||||
window.portproton_api.start_autoinstall_games_load.assert_called_once_with(
|
||||
window._on_autoinstall_games_loaded, force_refresh=False,
|
||||
)
|
||||
assert window._autoinstall_layout_pending is False
|
||||
assert window.autoInstallLoading is True
|
||||
|
||||
|
||||
@mark.parametrize("initial, index, expected", [
|
||||
("all", 2, "gog"),
|
||||
("gog", 4, "gog,portproton"),
|
||||
("gog,portproton", 2, "portproton"),
|
||||
("portproton", 4, "all"),
|
||||
("gog,portproton", 0, "all"),
|
||||
("gog,portproton", 5, "favorites"),
|
||||
("favorites", 2, "gog"),
|
||||
])
|
||||
def test_library_source_multiselection(
|
||||
initial: str, index: int, expected: str, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
combo = QComboBox()
|
||||
labels = ["All", "Steam", "GOG", "Epic Games", "PortProton", "Favorites"]
|
||||
combo.addItems(labels)
|
||||
combo.setEditable(True)
|
||||
saved = MagicMock()
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: initial)
|
||||
monkeypatch.setattr(game_config, "set_display_filter", saved)
|
||||
window = cast(Any, SimpleNamespace(
|
||||
filter_keys=["all", "steam", "gog", "egs", "portproton", "favorites"],
|
||||
filter_labels=labels, gamesDisplayCombo=combo,
|
||||
onlyInstalledCheckBox=MagicMock(),
|
||||
_position_library_controls_widget=MagicMock(),
|
||||
_on_only_installed_changed=MagicMock(),
|
||||
))
|
||||
|
||||
combo.activated.connect(lambda row: LibraryMixin._on_library_filter_changed(window, row))
|
||||
combo.view().setCurrentIndex(combo.model().index(index, 0))
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._activate_list_view_selection(combo.view())
|
||||
|
||||
saved.assert_called_once_with(expected)
|
||||
assert combo.currentText() == " + ".join(
|
||||
label for key, label in zip(window.filter_keys, labels, strict=True)
|
||||
if key in expected.split(",")
|
||||
)
|
||||
for row, key in enumerate(window.filter_keys):
|
||||
assert combo.itemData(row, Qt.ItemDataRole.CheckStateRole) == (
|
||||
Qt.CheckState.Checked if key in expected.split(",") else Qt.CheckState.Unchecked
|
||||
)
|
||||
|
||||
|
||||
@mark.parametrize("source", ["portproton", "native"])
|
||||
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch, source: str) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: f"gog,{source}")
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
|
||||
callbacks = {}
|
||||
loaded = MagicMock()
|
||||
window = cast(Any, SimpleNamespace(
|
||||
_loading_games=False, launch_exe=None, games_loaded=SimpleNamespace(emit=loaded),
|
||||
_load_gog_games_async=lambda callback: callbacks.update(gog=callback),
|
||||
_load_portproton_games_async=MagicMock(), _load_native_games_async=MagicMock(),
|
||||
_load_steam_games_async=MagicMock(), _load_egs_games_async=MagicMock(),
|
||||
))
|
||||
setattr(window, f"_load_{source}_games_async", lambda callback: callbacks.update({source: callback}))
|
||||
|
||||
MainWindow.loadGames(window, force_load=True)
|
||||
pp_game = ("PP", "", "", "", "", "pp.exe")
|
||||
gog_game = ("GOG", "", "", "", "", "gog://launch/1")
|
||||
callbacks["gog"]([gog_game])
|
||||
loaded.assert_not_called()
|
||||
callbacks[source]([pp_game])
|
||||
|
||||
loaded.assert_called_once_with([pp_game, gog_game] if source == "portproton" else [gog_game, pp_game])
|
||||
getattr(window, f"_load_{'native' if source == 'portproton' else 'portproton'}_games_async").assert_not_called()
|
||||
window._load_steam_games_async.assert_not_called()
|
||||
window._load_egs_games_async.assert_not_called()
|
||||
|
||||
|
||||
@mark.parametrize("click_target", ["text", "arrow"])
|
||||
def test_library_source_filter_opens_across_entire_field(
|
||||
click_target: str, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
test_window = cast(Any, window)
|
||||
source_style = "QListView::indicator { image: none; }"
|
||||
test_window.theme = SimpleNamespace(
|
||||
CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=source_style,
|
||||
COMBOBOX_STYLE="QComboBox { combobox-popup: 0; }", SCROLL_STYLE="",
|
||||
)
|
||||
test_window._register_gamepad_tooltip = lambda *_args: None
|
||||
test_window._delay_library_controls_hover_close = lambda: None
|
||||
test_window._position_library_controls_widget = lambda: None
|
||||
test_window._on_only_installed_changed = lambda _checked: None
|
||||
saved = MagicMock()
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog")
|
||||
monkeypatch.setattr(game_config, "set_display_filter", saved)
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
|
||||
controls = QWidget()
|
||||
LibraryMixin._add_library_filter_controls(window, QGridLayout(controls))
|
||||
controls.show()
|
||||
application.processEvents()
|
||||
combo = test_window.gamesDisplayCombo
|
||||
line_edit = combo.lineEdit()
|
||||
option = QStyleOptionComboBox()
|
||||
combo.initStyleOption(option)
|
||||
arrow = combo.style().subControlRect(
|
||||
QStyle.ComplexControl.CC_ComboBox, option, QStyle.SubControl.SC_ComboBoxArrow, combo
|
||||
)
|
||||
global_point = combo.mapToGlobal(
|
||||
line_edit.geometry().center() if click_target == "text" else arrow.center()
|
||||
)
|
||||
target = QApplication.widgetAt(global_point)
|
||||
assert isinstance(target, QWidget) and target is (line_edit if click_target == "text" else combo)
|
||||
assert source_style in combo.styleSheet()
|
||||
|
||||
QTest.mouseMove(target, target.mapFromGlobal(global_point))
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton, pos=target.mapFromGlobal(global_point))
|
||||
application.processEvents()
|
||||
assert combo.view().isVisible()
|
||||
index = combo.model().index(test_window.filter_keys.index("portproton"), 0)
|
||||
QTest.mouseMove(combo.view().viewport(), combo.view().visualRect(index).center())
|
||||
QTest.mouseClick(combo.view().viewport(), Qt.MouseButton.LeftButton,
|
||||
pos=combo.view().visualRect(index).center())
|
||||
application.processEvents()
|
||||
saved.assert_called_once_with("gog,portproton")
|
||||
assert combo.currentText() == "GOG + PortProton"
|
||||
controls.close()
|
||||
|
||||
|
||||
@mark.parametrize("readonly", [True, False])
|
||||
def test_custom_combo_opens_readonly_field_on_mouse_release(readonly: bool) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
combo = CustomComboBox()
|
||||
combo.addItems(["GOG", "PortProton"])
|
||||
combo.setEditable(True)
|
||||
line_edit = combo.lineEdit()
|
||||
assert line_edit is not None
|
||||
line_edit.setReadOnly(readonly)
|
||||
line_edit.installEventFilter(combo)
|
||||
combo.show()
|
||||
application.processEvents()
|
||||
|
||||
QTest.mousePress(line_edit, Qt.MouseButton.LeftButton, pos=line_edit.rect().center())
|
||||
assert not combo.view().isVisible()
|
||||
QTest.mouseRelease(line_edit, Qt.MouseButton.LeftButton, pos=line_edit.rect().center())
|
||||
application.processEvents()
|
||||
assert combo.view().isVisible() is readonly
|
||||
combo.hidePopup()
|
||||
combo.close()
|
||||
window.deleteLater()
|
||||
@@ -94,6 +94,33 @@ class TestGetCurrentLauncherCommand:
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── Launcher tail extraction ─────────────────────────────────────────────────
|
||||
|
||||
class TestExtractLauncherTail:
|
||||
def test_appimage_with_silent(self):
|
||||
parts = ["/path/PortProtonQt.AppImage", "--silent", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) == ["/game.exe"]
|
||||
|
||||
def test_appimage_without_silent(self):
|
||||
parts = ["/path/PortProtonQt.AppImage", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) == ["/game.exe"]
|
||||
|
||||
def test_portprotonqt_with_silent(self):
|
||||
parts = ["portprotonqt", "--silent", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) == ["/game.exe"]
|
||||
|
||||
def test_start_sh_with_silent(self):
|
||||
parts = ["start.sh", "--silent", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) == ["/game.exe"]
|
||||
|
||||
def test_unknown_command(self):
|
||||
parts = ["/usr/bin/wine64", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) is None
|
||||
|
||||
def test_empty(self):
|
||||
assert _extract_launcher_tail([]) is None
|
||||
|
||||
|
||||
# ── Launcher line migration ──────────────────────────────────────────────────
|
||||
|
||||
class TestMigrateLauncherLine:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,9 @@
|
||||
"""Tests for config/portproton.py — exec_line parsing, icon sanitization, launcher tail extraction."""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
from pytest import MonkeyPatch, mark
|
||||
|
||||
from portprotonqt.config.portproton import (
|
||||
extract_exec_target_path,
|
||||
@@ -192,3 +193,180 @@ class TestThemedLaunchIconNames:
|
||||
|
||||
def test_reg_has_icon(self):
|
||||
assert "reg" in THEMED_LAUNCH_ICON_NAMES[".reg"]
|
||||
|
||||
|
||||
def test_run_after_batch_is_created_next_to_exe() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert 'run_after_dir="$(dirname "${PW_EXE_FILE}")"' in helper
|
||||
assert 'pw_exe_file_win="$("${WINELOADER}" winepath -w "${PW_EXE_FILE}"' in helper
|
||||
assert "chcp 65001 >nul" in helper
|
||||
assert 'start "" "${pw_exe_file_win}" ${LAUNCH_PARAMETERS}' in helper
|
||||
assert 'start "" /unix "${PW_RUN_AFTER_EXE}"' in helper
|
||||
assert 'LAUNCH_PARAMETERS="" proxy_launch_parameters="" \\' in helper
|
||||
assert 'pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"' in helper
|
||||
|
||||
|
||||
def test_game_launch_marker_is_emitted_for_wine_and_proton() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
marker = "printf '%s\\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'"
|
||||
assert helper.count(marker) == 2
|
||||
|
||||
|
||||
def test_user_conf_get_without_name_lists_active_values(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
definition = helper.split("manage_user_conf_value () {", 1)[1].split(
|
||||
"use_exiftool () {", 1
|
||||
)[0]
|
||||
user_conf = tmp_path / "user.conf"
|
||||
user_conf.write_text(
|
||||
'# comment\nexport PW_USE_ESYNC="1"\n# export PW_USE_FSYNC="1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
script = "manage_user_conf_value () {" + definition + '\nmanage_user_conf_value get\n'
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True,
|
||||
env={"PATH": os.defpath, "USER_CONF": str(user_conf)}, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == 'PW_USE_ESYNC="1"\n'
|
||||
|
||||
|
||||
@mark.parametrize("runtime,logging", [("0", "0"), ("0", "1"), ("1", "0"), ("1", "1")])
|
||||
def test_wine_exit_code_survives_log_output_and_wineserver_wait(
|
||||
tmp_path: Path, runtime: str, logging: str,
|
||||
) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
definition = helper.split("pw_run () {", 1)[1].split("export -f pw_run", 1)[0]
|
||||
script = "pw_run () {" + definition + '''
|
||||
check_variables () { :; }
|
||||
pw_launch_wrapper () { return 37; }
|
||||
wait_wineserver () { return 0; }
|
||||
print_info () { :; }
|
||||
PATH_TO_GAME="$1"
|
||||
PW_TMPFS_PATH="$1"
|
||||
PW_LOG_FILE="$1/wine.log"
|
||||
PW_EXE_FILE="$1/game.exe"
|
||||
PW_USE_RUNTIME="$2"
|
||||
PW_LOG="$3"
|
||||
pw_run "$PW_EXE_FILE"
|
||||
exit $?
|
||||
'''
|
||||
(tmp_path / "game.exe").touch()
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "bash", str(tmp_path), runtime, logging],
|
||||
capture_output=True, text=True, env={"PATH": os.defpath}, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 37, result.stderr
|
||||
assert result.stdout.count("PORTPROTONQT_GAME_EXIT_CODE=37") == 1
|
||||
|
||||
|
||||
def test_vk_gpu_info_uses_build_aux_binary() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert '/../../../bin/vk_gpu_info"' in helper
|
||||
assert "dev-scripts/vk_gpu_info" not in helper
|
||||
|
||||
|
||||
def test_reshade_prefers_directx_for_unity() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert "source=$(echo \"$detect\" | grep '^SOURCE=' | cut -d= -f2-)" in helper
|
||||
assert (
|
||||
'[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] '
|
||||
"&& vulkan=false"
|
||||
) in helper
|
||||
|
||||
|
||||
def test_reshade_and_optiscaler_are_mutually_exclusive() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"' in helper
|
||||
assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"' in helper
|
||||
|
||||
|
||||
def test_reshade_repeated_link_does_not_create_nested_shaders(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
link_function = helper.split("try_force_link_dir () {", 1)[1].split("\n}", 1)[0]
|
||||
shaders = tmp_path / "shaders"
|
||||
shaders.mkdir()
|
||||
game = tmp_path / "game"
|
||||
game.mkdir()
|
||||
script = f'try_force_link_dir () {{{link_function}\n}}\n'
|
||||
link_call = helper.split(' [[ "$PW_USE_RESHADE" != 1 ]] && return 0', 1)[1]
|
||||
link_call = link_call.split(' pw_specialk_set_ini_value', 1)[0]
|
||||
script += f'root="$1"\nPATH_TO_GAME="$2"\n{link_call * 2}'
|
||||
|
||||
subprocess.run(
|
||||
["bash", "-c", script, "bash", str(tmp_path), str(game)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert (game / "ReShade_shaders").resolve() == shaders
|
||||
assert not (shaders / "shaders").exists()
|
||||
|
||||
|
||||
def test_reshade_disable_cleans_unreal_shipping_directory(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
sync_function = helper.split("pw_reshade_sync_files () {", 1)[1].split("\n}", 1)[0]
|
||||
game_root = tmp_path / "game"
|
||||
game_root.mkdir()
|
||||
game_exe = game_root / "Praest.exe"
|
||||
game_exe.touch()
|
||||
game = game_root / "Praest" / "Binaries" / "Win64"
|
||||
game.mkdir(parents=True)
|
||||
runtime = tmp_path / "plugins" / "reshade_windows" / "reshade" / "6.8.0_Addon"
|
||||
runtime.mkdir(parents=True)
|
||||
reshade_dll = runtime / "ReShade32.dll"
|
||||
reshade_dll.touch()
|
||||
(game / "opengl32.dll").symlink_to(reshade_dll)
|
||||
(game / "opengl32.dll.b").symlink_to(reshade_dll)
|
||||
(game / "ReShadePreset.ini").write_text("[GENERAL]\n")
|
||||
(game / "reshade_version").write_text("6.8.0_Addon\nopengl32.dll\n")
|
||||
script = f'pw_reshade_sync_files () {{{sync_function}\n}}\n'
|
||||
script += 'try_remove_file () { rm -f "$1"; }\npw_reshade_sync_files\n'
|
||||
|
||||
subprocess.run(
|
||||
["bash", "-c", script],
|
||||
check=True,
|
||||
env={
|
||||
**os.environ,
|
||||
"PW_EXE_FILE": str(game_exe),
|
||||
"PW_PLUGINS_PATH": str(tmp_path / "plugins"),
|
||||
"PW_USE_RESHADE": "0",
|
||||
"PW_RESHADE_API": "",
|
||||
"PW_RESHADE_EXE": "",
|
||||
"PATH_TO_GAME": str(game),
|
||||
},
|
||||
)
|
||||
|
||||
assert not (game / "opengl32.dll").exists()
|
||||
assert not (game / "opengl32.dll.b").exists()
|
||||
assert not (game / "ReShadePreset.ini").exists()
|
||||
|
||||
|
||||
def test_ini_writer_matches_spaces_around_equals() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert '^[[:space:]]*$key[[:space:]]*=' in helper
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user