Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e96550d155
|
||
|
|
eef9824596
|
||
|
|
c7acdd2ef4
|
||
|
|
4fb74c1d15
|
||
|
|
78dab249c1
|
||
|
|
9e91eeb0dc
|
||
|
|
cb8d149b2a
|
||
|
|
98475c0e59
|
||
|
|
0391b894ee
|
||
|
|
ca352d0ae9
|
||
|
|
b0b22481c1
|
||
|
|
147187a02a
|
||
|
|
a874ea6979
|
||
|
|
ca658fa84f
|
||
|
|
3bd78f5c73
|
||
|
|
b3824a3556
|
||
|
|
4d9e39d9e3
|
||
|
|
9c148c2710 | ||
|
|
0dae987520
|
||
|
|
00ec0baf35 | ||
|
|
28726a5cbd | ||
|
|
36a1994e4f
|
||
|
|
6bae4616ec
|
||
|
|
e1080d0185
|
||
|
|
e224904d4f | ||
|
|
2b4a0ede44 | ||
|
|
13079b3a50
|
||
|
|
6eae0612b8
|
||
|
|
d77d972da6
|
||
|
|
0e9bfa9d44
|
||
|
|
00c1d086b1
|
||
|
|
a3a7f61f8b
|
||
|
|
8adc0ebda7
|
||
|
|
6f73fa2b67
|
||
|
|
c4f172faf1 | ||
|
|
974802c409 | ||
|
|
4d4c392d85
|
||
|
|
10d034031b
|
||
|
|
04d1e44447
|
||
|
|
10cc22a88a
|
||
|
|
b8e06201e2
|
||
|
|
7f8c31fbb8
|
||
|
|
c4d28f8f72 | ||
|
|
06c3bf3ff4 | ||
|
|
45341a35a9 | ||
|
|
6c0da73df4 | ||
|
|
43f9219ecd | ||
|
|
635038c7c4 | ||
|
|
c5326efc48
|
||
|
|
a27cbf57d2
|
||
|
|
6fdcfb212e
|
||
|
|
78bb8391bf
|
||
|
|
2bb47c0d0e
|
||
|
|
4ef0184c81
|
||
|
|
1eb9800a30
|
||
|
|
2caab288dd
|
||
|
|
e80773c601
|
||
|
|
4b42d19bea
|
||
|
|
d04f8fdc9e
|
||
|
|
40a3cacc36
|
||
|
|
d54c84f723
|
||
|
|
2cfbe08323
|
||
|
|
d6684691a4
|
||
|
|
0bf87e172d
|
||
|
|
07bfa9611f
|
||
|
|
1f4ee890cf
|
||
|
|
8feb58c5e0
|
||
|
|
f47ff16e97
|
||
|
|
47f8faeaf2 | ||
|
|
2085fff8c3 | ||
|
|
e8ddd4372e | ||
|
|
1bfacb887b | ||
|
|
461527eb0c | ||
|
|
eb3d507f9d
|
||
|
|
d501dfcecf
|
||
|
|
e21bb09081
|
||
|
|
fa8e8478e5
|
||
|
|
51b78b506c
|
||
|
|
1776dd9e94
|
No files matched your search
@@ -31,6 +31,26 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif 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
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
|
||||
|
||||
@@ -69,6 +89,26 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif 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
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
|
||||
@@ -22,11 +22,14 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
steps:
|
||||
- name: Rank Arch mirrors
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif 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 - &&
|
||||
@@ -41,18 +44,18 @@ jobs:
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Install system dependencies
|
||||
- name: Install CI dependencies
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git nodejs npm mesa libarchive libpulse sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
pacman -Syu --noconfirm --needed git nodejs npm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Activate .venv & run pre-commit
|
||||
- name: Install project and check dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
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
|
||||
@@ -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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -80,12 +80,15 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Rank Arch mirrors
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif 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 - &&
|
||||
@@ -207,12 +210,15 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
|
||||
steps:
|
||||
- name: Rank Arch mirrors
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif 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 - &&
|
||||
@@ -290,7 +296,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
|
||||
image: debian:trixie-20261005@sha256:913f6706df59a68922d1dd08f78c2476560a8d367897200a6005b00e5f67c2d5
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
|
||||
@@ -9,6 +9,9 @@ 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,3 +45,4 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
|
||||
.codex
|
||||
CODE_QUALITY_PLAN.md
|
||||
.repowise
|
||||
.pz
|
||||
@@ -24,7 +24,7 @@ repos:
|
||||
hooks:
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: uv run pyright
|
||||
entry: pyright
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
@@ -62,7 +62,7 @@ repos:
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: uv run pytest tests/ -x -q --tb=line
|
||||
entry: 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 and focus regressions
|
||||
├── test_input_manager.py # Gamepad input navigation, dialog keyboards, 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,9 +433,10 @@ 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 shortcut creation, paths with spaces, .desktop entry
|
||||
├── test_shortcuts.py # Desktop shortcuts, folder import, edited exe paths, and gamepad navigation
|
||||
├── 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)
|
||||
@@ -1057,6 +1058,8 @@ 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,13 +7,64 @@
|
||||
|
||||
### 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/`
|
||||
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
|
||||
|
||||
### 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-14.1k-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-17.9k-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/Загрузки-14.1k-green?style=flat-square" 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="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,9 +2272,14 @@ manage_user_conf_value () {
|
||||
|
||||
use_exiftool () {
|
||||
if [[ ! -f "${PW_TMPFS_PATH}/exiftool.tmp" ]] \
|
||||
|| ! grep -q "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
|| ! grep -Fqx -- "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
echo "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
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
|
||||
if ! command -v exiftool &>/dev/null \
|
||||
|| ! timeout 3 exiftool "$1" &>> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
@@ -4007,6 +4012,25 @@ 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
|
||||
@@ -4188,9 +4212,22 @@ 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:-}" ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${run_after_valid}" == 1 ]] \
|
||||
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
|
||||
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
|
||||
then
|
||||
local wait_delay wait_delay_int run_after_bat
|
||||
@@ -4214,6 +4251,7 @@ 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
|
||||
@@ -4232,10 +4270,30 @@ timeout /t ${wait_delay_int} /nobreak
|
||||
start "" "%PW_RUN_AFTER_SECOND_PATH%"
|
||||
EOF
|
||||
|
||||
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}"
|
||||
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}"
|
||||
)
|
||||
try_remove_file "${run_after_bat}"
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "call of duty black ops cold war",
|
||||
"status": "Broken",
|
||||
"status": "Running",
|
||||
"slug": "call-of-duty-black-ops-cold-war"
|
||||
},
|
||||
{
|
||||
@@ -1286,7 +1286,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "nikke goddess of victory",
|
||||
"status": "Broken",
|
||||
"status": "Running",
|
||||
"slug": "nikke"
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
+20228
-796
File diff suppressed because it is too large.
Load diff
Binary file not shown.
+205
-10
@@ -384,6 +384,16 @@
|
||||
"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",
|
||||
@@ -404,6 +414,11 @@
|
||||
"normalized_name": "ayasa shadows of silence",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 43802,
|
||||
"normalized_name": "ball x pit",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2352,
|
||||
"normalized_name": "biomutant",
|
||||
@@ -429,6 +444,11 @@
|
||||
"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",
|
||||
@@ -534,6 +554,11 @@
|
||||
"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",
|
||||
@@ -919,6 +944,11 @@
|
||||
"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",
|
||||
@@ -989,6 +1019,16 @@
|
||||
"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",
|
||||
@@ -1599,6 +1639,11 @@
|
||||
"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",
|
||||
@@ -1789,6 +1834,11 @@
|
||||
"normalized_name": "everspace 2",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 32237,
|
||||
"normalized_name": "echo weaver",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 135061,
|
||||
"normalized_name": "eclipse",
|
||||
@@ -1829,6 +1879,11 @@
|
||||
"normalized_name": "enclave",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 142352,
|
||||
"normalized_name": "end of abyss",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1499,
|
||||
"normalized_name": "enshrouded",
|
||||
@@ -1899,6 +1954,21 @@
|
||||
"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.",
|
||||
@@ -1914,6 +1984,11 @@
|
||||
"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",
|
||||
@@ -1924,6 +1999,11 @@
|
||||
"normalized_name": "foundry",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 6470,
|
||||
"normalized_name": "fpv kamikaze drone",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 25751,
|
||||
"normalized_name": "front mission 1st remake",
|
||||
@@ -1939,6 +2019,11 @@
|
||||
"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",
|
||||
@@ -1977,7 +2062,7 @@
|
||||
{
|
||||
"id": 3730,
|
||||
"normalized_name": "fallout tactics brotherhood of steel",
|
||||
"overall_rating": "gold"
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1586,
|
||||
@@ -2012,7 +2097,7 @@
|
||||
{
|
||||
"id": 1509,
|
||||
"normalized_name": "far cry 4",
|
||||
"overall_rating": "broken"
|
||||
"overall_rating": "silver"
|
||||
},
|
||||
{
|
||||
"id": 112335,
|
||||
@@ -2049,6 +2134,11 @@
|
||||
"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)",
|
||||
@@ -2397,7 +2487,7 @@
|
||||
{
|
||||
"id": 134441,
|
||||
"normalized_name": "hard truck 2 king of the road",
|
||||
"overall_rating": "gold"
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2141,
|
||||
@@ -2430,8 +2520,13 @@
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 135348,
|
||||
"id": 116886,
|
||||
"normalized_name": "headquarters world war ii",
|
||||
"overall_rating": null
|
||||
},
|
||||
{
|
||||
"id": 95965,
|
||||
"normalized_name": "hearts of iron iv",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
@@ -2479,6 +2574,11 @@
|
||||
"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",
|
||||
@@ -2779,6 +2879,11 @@
|
||||
"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",
|
||||
@@ -3154,6 +3259,11 @@
|
||||
"normalized_name": "maid of the dead",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 117468,
|
||||
"normalized_name": "maneater",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3619,
|
||||
"normalized_name": "manhunt",
|
||||
@@ -3344,6 +3454,11 @@
|
||||
"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",
|
||||
@@ -3389,6 +3504,11 @@
|
||||
"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",
|
||||
@@ -3684,6 +3804,11 @@
|
||||
"normalized_name": "overgrowth",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 121097,
|
||||
"normalized_name": "overlord",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 23742,
|
||||
"normalized_name": "overthrown",
|
||||
@@ -3869,6 +3994,11 @@
|
||||
"normalized_name": "praetorians hd remaster",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 5719,
|
||||
"normalized_name": "praey for the gods",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 83824,
|
||||
"normalized_name": "prehistorik",
|
||||
@@ -3884,6 +4014,11 @@
|
||||
"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",
|
||||
@@ -3940,9 +4075,9 @@
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 134787,
|
||||
"id": 28860,
|
||||
"normalized_name": "quartet",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": null
|
||||
},
|
||||
{
|
||||
"id": 8765,
|
||||
@@ -4344,6 +4479,11 @@
|
||||
"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",
|
||||
@@ -4524,6 +4664,11 @@
|
||||
"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",
|
||||
@@ -4614,6 +4759,11 @@
|
||||
"normalized_name": "sleeping dogs",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3137,
|
||||
"normalized_name": "sniper elite",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1624,
|
||||
"normalized_name": "sniper elite 3",
|
||||
@@ -4654,6 +4804,11 @@
|
||||
"normalized_name": "sonic heroes",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2065,
|
||||
"normalized_name": "sonic mania",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 4357,
|
||||
"normalized_name": "sorcerer king rivals",
|
||||
@@ -4894,6 +5049,11 @@
|
||||
"normalized_name": "stoneshard",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144782,
|
||||
"normalized_name": "stranger things 3 the game",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 142281,
|
||||
"normalized_name": "stranglehold",
|
||||
@@ -5039,6 +5199,11 @@
|
||||
"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",
|
||||
@@ -5129,6 +5294,11 @@
|
||||
"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",
|
||||
@@ -5164,6 +5334,11 @@
|
||||
"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",
|
||||
@@ -5254,6 +5429,11 @@
|
||||
"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",
|
||||
@@ -5267,7 +5447,7 @@
|
||||
{
|
||||
"id": 1898,
|
||||
"normalized_name": "the last remnant",
|
||||
"overall_rating": "silver"
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 117639,
|
||||
@@ -5674,6 +5854,11 @@
|
||||
"normalized_name": "tropico 2 pirate cove",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 131874,
|
||||
"normalized_name": "tropico 5",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 87300,
|
||||
"normalized_name": "truck driver",
|
||||
@@ -5797,7 +5982,7 @@
|
||||
{
|
||||
"id": 143163,
|
||||
"normalized_name": "vk play",
|
||||
"overall_rating": "bronze"
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2997,
|
||||
@@ -6044,6 +6229,11 @@
|
||||
"normalized_name": "whiskerwood",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 107512,
|
||||
"normalized_name": "wild west pioneers",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116847,
|
||||
"normalized_name": "will rock",
|
||||
@@ -6067,7 +6257,7 @@
|
||||
{
|
||||
"id": 138835,
|
||||
"normalized_name": "witcher 3 wild hunt",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 6964,
|
||||
@@ -6109,6 +6299,11 @@
|
||||
"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",
|
||||
@@ -6277,7 +6472,7 @@
|
||||
{
|
||||
"id": 10633,
|
||||
"normalized_name": "калибр",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "silver"
|
||||
},
|
||||
{
|
||||
"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 --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --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"
|
||||
|
||||
# Values for options with arguments
|
||||
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -73,6 +73,14 @@ _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]'
|
||||
@@ -94,13 +102,21 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
|
||||
# Fish completion for portprotonqt
|
||||
|
||||
complete -c portprotonqt -f
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --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 -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"
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -167,7 +168,8 @@ class GameCardAnimations:
|
||||
"""Handle mouse enter event animations."""
|
||||
self.game_card._hovered = True
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, True)
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
if not isinstance(QApplication.focusWidget(), QLineEdit):
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
+121
-121
@@ -2,7 +2,7 @@ import argparse
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import signal
|
||||
import shlex
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -11,17 +11,16 @@ 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 QAction, QIcon
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from portprotonqt.config import (
|
||||
consume_download_counter_skip,
|
||||
@@ -66,6 +65,8 @@ 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():
|
||||
@@ -82,103 +83,6 @@ 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__)
|
||||
@@ -236,6 +140,38 @@ 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":
|
||||
@@ -393,7 +329,7 @@ def main():
|
||||
if theme_store_id is not None:
|
||||
ipc_message = f"theme:{theme_store_id}"
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtCore import QLockFile, QStandardPaths, QThread, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from requests import RequestException
|
||||
from portprotonqt.appimage_updater import (
|
||||
@@ -419,7 +355,10 @@ def main():
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
|
||||
# --- Single-instance logic ---
|
||||
server_name = __app_id__
|
||||
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)
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
@@ -432,7 +371,11 @@ def main():
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Remove old socket if it exists
|
||||
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.
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
@@ -548,10 +491,7 @@ def main():
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
if exe_path:
|
||||
window.tray_manager.tray_icon.hide()
|
||||
else:
|
||||
window.tray_manager.set_minimal_mode()
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = f"{source}://launch/{app_id}"
|
||||
@@ -576,11 +516,32 @@ def main():
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
loader(open_card)
|
||||
|
||||
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
|
||||
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 is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
run_silent_tray(app, start_sh, path)
|
||||
window.toggleGame(shlex.quote(path))
|
||||
return
|
||||
window.handle_launch_exe(path, log_mode=log_mode)
|
||||
return
|
||||
@@ -665,7 +626,7 @@ def main():
|
||||
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True)
|
||||
handle_game_request(target, True, confirm=True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
@@ -807,13 +768,14 @@ def main():
|
||||
return
|
||||
QMessageBox.warning(window, _("Error"), _("Failed to update AppImage."))
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
class GOGDLUpdateWorker(QThread):
|
||||
"""Update gogdl without blocking the UI thread."""
|
||||
@@ -900,14 +862,15 @@ def main():
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
if start_sh:
|
||||
# Initial setup can stop Wine and must not race a silent game launch.
|
||||
if start_sh and not silent_game_request:
|
||||
worker = InitialCommandWorker(start_sh)
|
||||
window.initialCommandWorker = worker
|
||||
worker.finished.connect(
|
||||
lambda: setattr(window, "initialCommandWorker", None)
|
||||
)
|
||||
worker.start()
|
||||
else:
|
||||
elif not start_sh:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
# --- Cleanup ---
|
||||
@@ -922,7 +885,44 @@ def main():
|
||||
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
sys.exit(app.exec())
|
||||
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)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,6 +2,7 @@ import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +18,27 @@ 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",
|
||||
@@ -106,10 +128,276 @@ 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,5 +1,4 @@
|
||||
"""Cache configuration and management."""
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -118,7 +117,7 @@ class CacheManager:
|
||||
temp_file = cache_file.with_name(f".{cache_file.name}.{time.time_ns()}.tmp")
|
||||
try:
|
||||
if pretty:
|
||||
content = json.dumps(data, indent=2).encode("utf-8")
|
||||
content = orjson.dumps(data, option=orjson.OPT_INDENT_2)
|
||||
else:
|
||||
content = orjson.dumps(data)
|
||||
with _CACHE_FILE_LOCK:
|
||||
|
||||
@@ -581,6 +581,14 @@ 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,
|
||||
@@ -606,6 +614,7 @@ 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,6 +519,15 @@ 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)
|
||||
|
||||
@@ -4,8 +4,8 @@ import shutil
|
||||
import tempfile
|
||||
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
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess, QThread
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
@@ -68,6 +68,14 @@ 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(
|
||||
@@ -355,8 +363,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -384,8 +392,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -449,7 +457,92 @@ class ContextMenuManager:
|
||||
return self.theme_manager.get_colored_icon_path(icon_name, color)
|
||||
return None
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
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):
|
||||
"""
|
||||
Show the context menu for a game card at the specified position.
|
||||
|
||||
@@ -463,6 +556,16 @@ 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)
|
||||
@@ -471,6 +574,8 @@ 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
|
||||
|
||||
@@ -698,16 +803,24 @@ class ContextMenuManager:
|
||||
if os.path.exists(menu_path)
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
@@ -805,7 +918,9 @@ class ContextMenuManager:
|
||||
if not entry:
|
||||
continue
|
||||
exec_line = entry.get("Exec", entry.get("exec", ""))
|
||||
if script_name.lower() in exec_line.lower():
|
||||
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():
|
||||
return file_path
|
||||
except OSError:
|
||||
continue
|
||||
@@ -1054,14 +1169,17 @@ 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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -1311,7 +1429,8 @@ 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)
|
||||
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)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
@@ -1425,6 +1544,27 @@ 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.
|
||||
@@ -1465,7 +1605,12 @@ 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"),
|
||||
@@ -1473,10 +1618,15 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
new_desktop_path = old_path
|
||||
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)
|
||||
@@ -1495,13 +1645,6 @@ 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:
|
||||
@@ -1521,11 +1664,9 @@ class ContextMenuManager:
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
get_custom_data_dir_name(new_exe_path),
|
||||
custom_folder = resolve_custom_data_dir(
|
||||
os.path.join(xdg_data_home, "PortProtonQt", "custom_data"),
|
||||
new_exe_path,
|
||||
)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
|
||||
@@ -1616,23 +1757,26 @@ class ContextMenuManager:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
if not exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
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:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
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)
|
||||
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"),
|
||||
@@ -1654,16 +1798,14 @@ 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)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam.").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
|
||||
@@ -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 Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
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.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -18,6 +18,19 @@ 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:
|
||||
|
||||
@@ -93,6 +93,9 @@ 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)
|
||||
@@ -1480,7 +1483,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.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
|
||||
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
|
||||
|
||||
result = msg_box.exec()
|
||||
|
||||
@@ -1638,10 +1641,15 @@ 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,25 +1,36 @@
|
||||
"""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 cast, TYPE_CHECKING
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
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.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
@@ -28,6 +39,7 @@ 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()
|
||||
@@ -125,16 +137,241 @@ 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"))
|
||||
@@ -195,6 +432,19 @@ 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()
|
||||
@@ -327,10 +577,44 @@ 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:
|
||||
@@ -338,6 +622,12 @@ class AddGameDialog(DraggableDialog):
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
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:
|
||||
@@ -351,6 +641,195 @@ 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)
|
||||
@@ -497,6 +976,8 @@ 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,8 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
import orjson
|
||||
|
||||
from PySide6.QtCore import QIODevice, QPoint, QSaveFile, Qt, QUrl
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager, QNetworkDiskCache, QNetworkProxy, QNetworkReply, QNetworkRequest,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -14,8 +20,12 @@ 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):
|
||||
@@ -70,6 +80,11 @@ 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)
|
||||
@@ -77,6 +92,81 @@ 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,7 +25,12 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
|
||||
dialog_actions = []
|
||||
|
||||
if context == 'file_explorer':
|
||||
if context == 'folder_import':
|
||||
dialog_actions = [
|
||||
("add_game", _("Apply")),
|
||||
("back", _("Close")),
|
||||
]
|
||||
elif context == 'file_explorer':
|
||||
dialog_actions = [
|
||||
("confirm", _("Open")),
|
||||
("add_game", _("Select Dir")),
|
||||
@@ -45,10 +50,9 @@ 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", _("Cancel")),
|
||||
("back", _("Close")),
|
||||
("prev_tab", _("Prev Tab")),
|
||||
("next_tab", _("Next Tab")),
|
||||
]
|
||||
|
||||
@@ -607,7 +607,9 @@ 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 isinstance(self.file_filter, str):
|
||||
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):
|
||||
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, 'input_manager'):
|
||||
if hasattr(parent_widget, 'get_button_icon'):
|
||||
self.main_window = parent_widget
|
||||
break
|
||||
parent_widget = parent_widget.parent()
|
||||
@@ -69,6 +69,14 @@ 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]:
|
||||
@@ -278,18 +286,6 @@ 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
|
||||
@@ -356,14 +352,9 @@ 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'):
|
||||
error_tab = QWidget()
|
||||
error_layout = QVBoxLayout(error_tab)
|
||||
error_layout.addWidget(error_label)
|
||||
self.tab_widget.addTab(error_tab, _("Error"))
|
||||
self.tab_widget.clear()
|
||||
self.create_installed_tab()
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
self.cpu_level = get_cpu_level()
|
||||
@@ -683,7 +674,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(_("Unknown"))
|
||||
size_item = QTableWidgetItem(_("Loading…"))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
@@ -962,8 +953,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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -29,6 +29,8 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSlider,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QTabWidget,
|
||||
QTableWidget,
|
||||
@@ -198,6 +200,12 @@ 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 = {}
|
||||
@@ -282,6 +290,11 @@ 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
|
||||
@@ -292,6 +305,70 @@ 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("~")
|
||||
@@ -395,7 +472,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, 230)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
|
||||
self.advanced_table.setWordWrap(True)
|
||||
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
@@ -472,7 +549,6 @@ 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)
|
||||
@@ -480,25 +556,20 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip_timer.timeout.connect(lambda: self.gamepad_tooltip.setVisible(False))
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
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.cancel_button = AutoSizeButton(_("Close"), 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)
|
||||
@@ -506,6 +577,7 @@ 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)
|
||||
|
||||
@@ -625,6 +697,8 @@ class ExeSettingsDialog(
|
||||
|
||||
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."""
|
||||
@@ -667,8 +741,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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -688,8 +762,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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -711,8 +785,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):
|
||||
@@ -859,13 +933,19 @@ class ExeSettingsDialog(
|
||||
prefix_line_edit = combo.lineEdit()
|
||||
if prefix_line_edit is not None:
|
||||
prefix_line_edit.setPlaceholderText(_("Enter prefix name"))
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
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.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.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
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.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'])
|
||||
@@ -887,7 +967,9 @@ class ExeSettingsDialog(
|
||||
current_val = reverse_map[current_raw]
|
||||
|
||||
current_val_text = current_val if isinstance(current_val, str) else ''
|
||||
if current_val_text and current_val_text not in setting['options']:
|
||||
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:
|
||||
combo.addItem(current_val_text)
|
||||
combo.setCurrentText(current_val_text)
|
||||
|
||||
@@ -938,10 +1020,12 @@ class ExeSettingsDialog(
|
||||
if setting['key'] == 'PW_RUN_AFTER_EXE':
|
||||
text_container = QWidget()
|
||||
text_container.setProperty("ppqt_run_after_exe_widget", True)
|
||||
text_layout = QHBoxLayout(text_container)
|
||||
text_layout.setContentsMargins(0, 0, 0, 0)
|
||||
text_layout.setSpacing(6)
|
||||
text_layout.addWidget(line_edit)
|
||||
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)
|
||||
|
||||
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
@@ -953,26 +1037,38 @@ class ExeSettingsDialog(
|
||||
def open_run_after_exe_selector(
|
||||
_checked: bool = False,
|
||||
target_line_edit=line_edit,
|
||||
file_filter="",
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
):
|
||||
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=initial_path,
|
||||
initial_path=self._get_setting_file_selector_path(
|
||||
target_line_edit.text().rsplit(",", 1)[-1].strip()
|
||||
),
|
||||
)
|
||||
file_explorer.file_signal.file_selected.connect(
|
||||
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
|
||||
lambda path, target=target_line_edit: target.setText(
|
||||
",".join(filter(None, [target.text().strip(), os.path.normpath(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:
|
||||
@@ -1095,6 +1191,8 @@ 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)
|
||||
@@ -1200,8 +1298,8 @@ class ExeSettingsDialog(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
combo.setEditable(source_widget.isEditable())
|
||||
for index in range(source_widget.count()):
|
||||
combo.addItem(source_widget.itemText(index), source_widget.itemData(index))
|
||||
combo.setInsertPolicy(source_widget.insertPolicy())
|
||||
combo.setModel(source_widget.model())
|
||||
combo.setCurrentText(source_widget.currentText())
|
||||
combo.setEnabled(source_widget.isEnabled())
|
||||
settings_combo_style = getattr(self.theme, "SETTINGS_TABLE_COMBOBOX_STYLE", "")
|
||||
@@ -1211,9 +1309,14 @@ class ExeSettingsDialog(
|
||||
+ self.theme.SCROLL_STYLE
|
||||
)
|
||||
combo.currentTextChanged.connect(source_widget.setCurrentText)
|
||||
combo.highlighted.connect(
|
||||
lambda row_idx, c=combo: self._on_combo_highlighted(row_idx, c),
|
||||
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.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:
|
||||
@@ -1249,9 +1352,7 @@ 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():
|
||||
@@ -1267,25 +1368,31 @@ class ExeSettingsDialog(
|
||||
if text_width <= item_rect.intersected(view.viewport().rect()).width():
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
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)
|
||||
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))
|
||||
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 = QGuiApplication.screenAt(pos) or QGuiApplication.primaryScreen()
|
||||
screen = combo.screen()
|
||||
if screen:
|
||||
ar = screen.availableGeometry()
|
||||
ar = screen.availableGeometry().intersected(self.rect().translated(self.mapToGlobal(QPoint())))
|
||||
w, h = min(w, ar.width()), min(h, ar.height())
|
||||
if pos.x() + w > ar.right():
|
||||
pos.setX(ar.right() - w)
|
||||
pos.setX(combo.mapToGlobal(combo.rect().topLeft()).x() - w)
|
||||
if pos.x() < ar.left():
|
||||
pos.setX(ar.left())
|
||||
if pos.y() + h > ar.bottom():
|
||||
pos.setY(ar.bottom() - h)
|
||||
if pos.y() < ar.top():
|
||||
pos.setY(ar.top())
|
||||
self.gamepad_tooltip.setText(text)
|
||||
self.gamepad_tooltip.hide()
|
||||
self.gamepad_tooltip.setFixedSize(w, h)
|
||||
self.gamepad_tooltip.move(pos)
|
||||
self.gamepad_tooltip.move(self.mapFromGlobal(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:
|
||||
@@ -1317,6 +1424,7 @@ 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:
|
||||
@@ -1324,6 +1432,7 @@ 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():
|
||||
@@ -1393,8 +1502,59 @@ class ExeSettingsDialog(
|
||||
self._filter_vkbasalt_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
|
||||
|
||||
@@ -1426,6 +1586,8 @@ 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 = ''
|
||||
@@ -1510,26 +1672,42 @@ class ExeSettingsDialog(
|
||||
if not changes:
|
||||
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
|
||||
|
||||
process = QProcess(self)
|
||||
self.save_process = QProcess(self)
|
||||
process = self.save_process
|
||||
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.close()
|
||||
self._finish_save()
|
||||
else:
|
||||
self.saving = False
|
||||
self.load_current_settings()
|
||||
return
|
||||
|
||||
@@ -1540,33 +1718,45 @@ 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())
|
||||
self.apply_button.setEnabled(True)
|
||||
process.deleteLater()
|
||||
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.load_current_settings()
|
||||
self.close()
|
||||
self._finish_save()
|
||||
|
||||
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)
|
||||
@@ -1625,15 +1815,20 @@ 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)
|
||||
@@ -1646,8 +1841,8 @@ class ExeSettingsDialog(
|
||||
palette.setBrush(QPalette.ColorRole.Text, item.foreground())
|
||||
label.setAutoFillBackground(selected)
|
||||
label.setPalette(palette)
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
if label.foregroundRole() != QPalette.ColorRole.Text:
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
name, separator, description = text.partition("\n")
|
||||
formatted_text = escape(name)
|
||||
if separator:
|
||||
@@ -1663,7 +1858,11 @@ class ExeSettingsDialog(
|
||||
if label.text() != formatted_text:
|
||||
label.setText(formatted_text)
|
||||
width = max(1, table.columnWidth(0))
|
||||
table.setRowHeight(row, label.heightForWidth(width) + table.fontMetrics().height())
|
||||
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)
|
||||
for row in range(table.rowCount()):
|
||||
widget = table.cellWidget(row, 1)
|
||||
if widget is None:
|
||||
@@ -1678,10 +1877,10 @@ class ExeSettingsDialog(
|
||||
return False
|
||||
|
||||
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),
|
||||
@@ -1759,8 +1958,9 @@ class ExeSettingsDialog(
|
||||
y = available_rect.top()
|
||||
|
||||
self.gamepad_tooltip.setFixedSize(required_width, required_height)
|
||||
self.gamepad_tooltip.move(x, y)
|
||||
self.gamepad_tooltip.move(self.mapFromGlobal(QPoint(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:
|
||||
@@ -1790,6 +1990,11 @@ 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)
|
||||
|
||||
+10
-4
@@ -266,9 +266,11 @@ 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) -> bool:
|
||||
def is_game_installed(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> bool:
|
||||
"""Return whether Legendary records a usable installation."""
|
||||
return self.get_launch_target(app_id) is not None
|
||||
return self.get_launch_target(app_id, installed_data) is not None
|
||||
|
||||
@staticmethod
|
||||
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
|
||||
@@ -285,9 +287,13 @@ class EGSAPI:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
def get_launch_target(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> str | None:
|
||||
"""Return the installed game's Windows executable."""
|
||||
installed = self.load_installed().get(app_id, {})
|
||||
installed = (
|
||||
installed_data if installed_data is not None else 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("/"))
|
||||
|
||||
+30
-12
@@ -16,6 +16,7 @@ from PySide6.QtGui import (
|
||||
QEnterEvent,
|
||||
QFocusEvent,
|
||||
QMouseEvent,
|
||||
QKeyEvent,
|
||||
QPaintEvent,
|
||||
)
|
||||
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
@@ -286,6 +287,10 @@ 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_()
|
||||
@@ -375,6 +380,7 @@ 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()
|
||||
@@ -873,10 +879,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 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),
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.egs_visible, self.egsLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
@@ -1046,15 +1052,11 @@ class GameCard(AnimatedCard):
|
||||
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 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.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
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)
|
||||
@@ -1256,6 +1258,12 @@ 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
|
||||
@@ -1285,6 +1293,16 @@ 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:
|
||||
|
||||
@@ -8,6 +8,7 @@ import io
|
||||
import pefile
|
||||
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__)
|
||||
@@ -49,6 +50,9 @@ 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))
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
|
||||
@@ -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"):
|
||||
if cover and cover.startswith(("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/")):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
|
||||
@@ -86,9 +86,8 @@ class MouseEmulationThread(QThread):
|
||||
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
|
||||
}, name="Virtual DPad Mouse")
|
||||
|
||||
self.finished.emit(True)
|
||||
# Store device in thread result
|
||||
self._device = ui
|
||||
self.finished.emit(True)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
|
||||
@@ -214,10 +213,6 @@ 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)
|
||||
@@ -265,6 +260,8 @@ 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:
|
||||
@@ -281,6 +278,22 @@ 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."""
|
||||
@@ -414,8 +427,10 @@ class InputManager(
|
||||
|
||||
# Stop the thread if still running
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
if thread:
|
||||
thread.wait()
|
||||
if self.ui is None:
|
||||
self.ui = thread.get_device()
|
||||
self.mouse_emulation_thread = None
|
||||
|
||||
if self.ui:
|
||||
|
||||
@@ -2,12 +2,14 @@ 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,
|
||||
@@ -133,10 +135,15 @@ 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']:
|
||||
focused.showPopup()
|
||||
if focused.view().isVisible():
|
||||
self._activate_list_view_selection(focused.view())
|
||||
else:
|
||||
focused.showPopup()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and focused.view().isVisible():
|
||||
focused.hidePopup()
|
||||
@@ -160,12 +167,11 @@ class ButtonInputMixin(InputMixin):
|
||||
def _route_button_to_text_input(
|
||||
self, active: QWidget, focused: QWidget | None, button_code: int
|
||||
) -> bool:
|
||||
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):
|
||||
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()
|
||||
):
|
||||
if keyboard:
|
||||
keyboard.show_for_widget(focused)
|
||||
return True
|
||||
@@ -176,7 +182,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 keyboard:
|
||||
if line_edit is not None and not line_edit.isReadOnly() and keyboard:
|
||||
line_edit.setFocus()
|
||||
keyboard.show_for_widget(line_edit)
|
||||
return True
|
||||
@@ -196,7 +202,7 @@ class ButtonInputMixin(InputMixin):
|
||||
parent = parent.parentWidget()
|
||||
return None
|
||||
|
||||
def _activate_list_view_selection(self, view: QListView) -> None:
|
||||
def _activate_list_view_selection(self, view: QAbstractItemView) -> None:
|
||||
index = view.currentIndex()
|
||||
if not index.isValid():
|
||||
return
|
||||
@@ -205,6 +211,7 @@ 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)
|
||||
@@ -246,6 +253,11 @@ 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,10 +317,7 @@ class DpadInputMixin(InputMixin):
|
||||
|
||||
def _handle_default_dpad(self, code: int, value: int, current_time: float) -> None:
|
||||
active_window = QApplication.activeWindow()
|
||||
if isinstance(active_window, AddGameDialog):
|
||||
keyboard = getattr(active_window, 'keyboard', None)
|
||||
else:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
keyboard = getattr(active_window, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
|
||||
if value == 0:
|
||||
self._reset_dpad_repeat()
|
||||
|
||||
@@ -138,9 +138,13 @@ 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_folder_context_menu(item_rect.center())
|
||||
self.file_explorer.show_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 QKeySequence
|
||||
from PySide6.QtGui import QKeyEvent, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QApplication,
|
||||
@@ -93,10 +93,6 @@ 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")
|
||||
@@ -134,7 +130,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if self._redirect_gamecard_input_to_search(event):
|
||||
if isinstance(event, QKeyEvent) and self._redirect_gamecard_input_to_search(event):
|
||||
return True
|
||||
key = self._qt_event_to_input_key(event)
|
||||
if key is not None:
|
||||
@@ -162,7 +158,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
|
||||
return False
|
||||
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
return False
|
||||
|
||||
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
|
||||
native_key = self._native_scan_to_input_key(event)
|
||||
@@ -239,7 +235,9 @@ class KeyboardInputMixin(InputMixin):
|
||||
1: 'autoInstallSearchLineEdit',
|
||||
}
|
||||
|
||||
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
|
||||
def _redirect_gamecard_input_to_search(self, event: QKeyEvent) -> bool:
|
||||
if event.key() == Qt.Key.Key_Space:
|
||||
return False
|
||||
focused = self._focused_widget()
|
||||
if not isinstance(focused, GameCard):
|
||||
return False
|
||||
|
||||
@@ -184,10 +184,7 @@ class SettingsInputMixin(InputMixin):
|
||||
return
|
||||
|
||||
# 4. Global Shortcuts
|
||||
if button_code in BUTTONS['add_game']: # X: Apply
|
||||
self.settings_dialog.apply_changes()
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
if button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
focused = QApplication.focusWidget()
|
||||
focused_combo = None
|
||||
if isinstance(focused, QTableWidget) and self.settings_dialog:
|
||||
@@ -342,12 +339,14 @@ 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:
|
||||
idx = cell_widget.currentIndex()
|
||||
view = cell_widget.view()
|
||||
idx = view.currentIndex().row()
|
||||
step = -1 if value < 0 else 1
|
||||
model = cell_widget.model()
|
||||
end = -1 if step < 0 else cell_widget.count()
|
||||
@@ -355,7 +354,8 @@ class SettingsInputMixin(InputMixin):
|
||||
row for row in range(idx + step, end, step)
|
||||
if model.flags(model.index(row, 0)) & Qt.ItemFlag.ItemIsEnabled
|
||||
), idx)
|
||||
cell_widget.setCurrentIndex(new_idx)
|
||||
view.setCurrentIndex(model.index(new_idx, 0))
|
||||
view.scrollTo(model.index(new_idx, 0), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
return # Consume event
|
||||
|
||||
# 3. Standard Table Navigation
|
||||
@@ -476,7 +476,14 @@ 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:
|
||||
@@ -492,15 +499,33 @@ 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():
|
||||
if line_edit and line_edit.isEnabled() and not line_edit.isHidden():
|
||||
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
|
||||
|
||||
@@ -508,15 +533,14 @@ class SettingsInputMixin(InputMixin):
|
||||
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
|
||||
return False
|
||||
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
widgets = [cell_widget.findChild(QLineEdit), *cell_widget.findChildren(QPushButton)]
|
||||
widgets = [widget for widget in widgets if widget and widget.isEnabled() and not widget.isHidden()]
|
||||
focused = QApplication.focusWidget()
|
||||
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
|
||||
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
|
||||
return False
|
||||
|
||||
def _focus_first_row_in_current_settings_table(self):
|
||||
|
||||
@@ -22,14 +22,22 @@ 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 ""
|
||||
|
||||
@@ -111,12 +119,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr ""
|
||||
@@ -142,9 +144,15 @@ 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 ""
|
||||
|
||||
@@ -269,14 +277,6 @@ 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,10 +294,6 @@ 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 ""
|
||||
@@ -308,10 +304,6 @@ 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 ""
|
||||
@@ -471,6 +463,16 @@ 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 ""
|
||||
|
||||
@@ -886,6 +888,22 @@ 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 ""
|
||||
|
||||
@@ -901,6 +919,9 @@ msgstr ""
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr ""
|
||||
|
||||
@@ -913,6 +934,30 @@ 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 ""
|
||||
|
||||
@@ -928,7 +973,13 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -952,9 +1003,6 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1096,9 +1144,6 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1186,9 +1231,6 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr ""
|
||||
|
||||
@@ -2117,9 +2159,6 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2192,6 +2231,9 @@ msgid ""
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2255,6 +2297,9 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2475,16 +2520,10 @@ 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-12 19:09+0000\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -21,14 +21,24 @@ 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"
|
||||
|
||||
@@ -114,12 +124,6 @@ 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}"
|
||||
@@ -147,9 +151,15 @@ 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"
|
||||
|
||||
@@ -279,14 +289,6 @@ 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}"
|
||||
@@ -306,10 +308,6 @@ 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}"
|
||||
@@ -322,10 +320,6 @@ 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}"
|
||||
@@ -483,6 +477,20 @@ 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."
|
||||
|
||||
@@ -958,6 +966,22 @@ 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:"
|
||||
|
||||
@@ -973,6 +997,10 @@ 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:"
|
||||
|
||||
@@ -985,6 +1013,32 @@ 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"
|
||||
|
||||
@@ -1002,8 +1056,14 @@ msgstr ""
|
||||
"Das Programm wurde beendet. Kompatibilitätsdetails wurden automatisch "
|
||||
"erfasst."
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
@@ -1026,9 +1086,6 @@ msgstr "Vorheriger Tab"
|
||||
msgid "Next Tab"
|
||||
msgstr "Nächster Tab"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Suchen"
|
||||
|
||||
@@ -1176,9 +1233,6 @@ 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!"
|
||||
|
||||
@@ -1266,9 +1320,6 @@ 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"
|
||||
|
||||
@@ -2215,9 +2266,6 @@ msgstr "Fehlgeschlagen"
|
||||
msgid "Repaired"
|
||||
msgstr "Repariert"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Schnellstart"
|
||||
|
||||
@@ -2292,6 +2340,9 @@ 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"
|
||||
|
||||
@@ -2355,6 +2406,9 @@ 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"
|
||||
|
||||
@@ -2579,15 +2633,9 @@ 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,9 +21,6 @@ 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."
|
||||
@@ -31,6 +28,17 @@ 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"
|
||||
|
||||
@@ -116,12 +124,6 @@ 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}"
|
||||
@@ -149,9 +151,16 @@ 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"
|
||||
|
||||
@@ -281,14 +290,6 @@ 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}"
|
||||
@@ -308,10 +309,6 @@ 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}"
|
||||
@@ -324,10 +321,6 @@ 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}"
|
||||
@@ -488,6 +481,16 @@ 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."
|
||||
|
||||
@@ -977,6 +980,22 @@ 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:"
|
||||
|
||||
@@ -992,6 +1011,10 @@ 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:"
|
||||
|
||||
@@ -1004,6 +1027,33 @@ 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"
|
||||
|
||||
@@ -1019,7 +1069,14 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1043,9 +1100,6 @@ msgstr "Pestaña anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Pestaña siguiente"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Guardar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
@@ -1191,9 +1245,6 @@ 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 ""
|
||||
|
||||
@@ -1284,9 +1335,6 @@ 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"
|
||||
|
||||
@@ -2226,10 +2274,6 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2307,6 +2351,9 @@ 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 ""
|
||||
|
||||
@@ -2370,6 +2417,9 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2592,15 +2642,9 @@ 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-11 01:04+0500\n"
|
||||
"POT-Creation-Date: 2026-10-24 18:13+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,14 +18,22 @@ 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 ""
|
||||
|
||||
@@ -107,12 +115,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr ""
|
||||
@@ -138,9 +140,15 @@ 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 ""
|
||||
|
||||
@@ -265,14 +273,6 @@ 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,10 +290,6 @@ 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,10 +300,6 @@ 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 ""
|
||||
@@ -467,6 +459,16 @@ 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 ""
|
||||
|
||||
@@ -882,6 +884,22 @@ 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 ""
|
||||
|
||||
@@ -897,6 +915,9 @@ msgstr ""
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr ""
|
||||
|
||||
@@ -909,6 +930,30 @@ 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 ""
|
||||
|
||||
@@ -924,7 +969,13 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -948,9 +999,6 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1092,9 +1140,6 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1182,9 +1227,6 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr ""
|
||||
|
||||
@@ -2113,9 +2155,6 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2188,6 +2227,9 @@ msgid ""
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2251,6 +2293,9 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2472,16 +2517,10 @@ 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-09-24 06:40+0000\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+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,12 +18,9 @@ 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.9.1\n"
|
||||
"X-Generator: Weblate 2026.10.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."
|
||||
@@ -31,6 +28,19 @@ 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 ""
|
||||
"O PortProtonQt já está em execução. Deseja encerrá-lo junto com todos os "
|
||||
"jogos abertos e reiniciá-lo para iniciar uma nova sessão?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Atualização concluída"
|
||||
|
||||
@@ -114,12 +124,6 @@ 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}"
|
||||
@@ -147,9 +151,15 @@ msgstr "A pasta já existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr "Selecionar vários"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Parar o Jogo"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
|
||||
@@ -279,14 +289,6 @@ 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}"
|
||||
@@ -306,10 +308,6 @@ 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}"
|
||||
@@ -322,10 +320,6 @@ 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}"
|
||||
@@ -482,6 +476,20 @@ 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 ""
|
||||
"Foram encontrados caracteres cirílicos nos seguintes caminhos. O Wine pode "
|
||||
"funcionar de forma instável. Mova o jogo ou o PortProton para um caminho sem "
|
||||
"caracteres cirílicos:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "Não lembrar novamente"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
@@ -959,6 +967,22 @@ msgstr "Atualizando AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "Verificado: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "Verificando os atalhos existentes: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "Adicionando: {name}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar atalho: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
|
||||
@@ -974,6 +998,9 @@ msgstr "Capa Personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Informe o caminho local ou a URL da imagem de capa"
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Ícone personalizado:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Adicionar atalho em:"
|
||||
|
||||
@@ -986,6 +1013,32 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr "Selecionar pasta"
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr "Processando jogos…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Processados: {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "Nenhum jogo encontrado na pasta selecionada."
|
||||
|
||||
msgid "Select games"
|
||||
msgstr "Selecionar jogos"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Falha ao baixar a capa"
|
||||
|
||||
@@ -1003,8 +1056,14 @@ msgstr ""
|
||||
"O programa foi encerrado. As informações de compatibilidade foram coletadas "
|
||||
"automaticamente."
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
@@ -1027,9 +1086,6 @@ msgstr "Aba Anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Próxima Aba"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Salvar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Pesquisar"
|
||||
|
||||
@@ -1177,9 +1233,6 @@ 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!"
|
||||
|
||||
@@ -1218,15 +1271,14 @@ msgstr "O download ou a extração foi cancelado."
|
||||
msgid "System WINE"
|
||||
msgstr "WINE do Sistema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Salvar Configurações"
|
||||
msgstr "Configurações globais dos jogos"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Configurações do Exe"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
msgstr "Editar o arquivo user.conf"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Pesquisar configurações…"
|
||||
@@ -1252,9 +1304,8 @@ msgstr "Editar PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Limpar PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Falha ao carregar temas"
|
||||
msgstr "Falha ao carregar as configurações globais dos jogos."
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "O caminho do executável não está disponível."
|
||||
@@ -1265,12 +1316,8 @@ 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 PPDB:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
msgstr "Falha ao abrir o arquivo:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar Limpeza do PPDB"
|
||||
@@ -1292,9 +1339,8 @@ msgstr "Padrão"
|
||||
msgid "disabled"
|
||||
msgstr "desativado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
msgstr "Baixar Wine/Proton..."
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Digite o nome do prefixo"
|
||||
@@ -2135,7 +2181,7 @@ msgid "GOG account connected"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
msgid "Log out"
|
||||
msgstr "Encerrar sessão"
|
||||
msgstr "Sair da sessão"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Abrir página de login"
|
||||
@@ -2217,9 +2263,6 @@ msgstr "Falhou"
|
||||
msgid "Repaired"
|
||||
msgstr "Reparado"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Início Rápido"
|
||||
|
||||
@@ -2259,16 +2302,14 @@ msgstr "Oculto"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Modo de Exibição do Selo:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Temas"
|
||||
msgstr "Tema"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
msgstr "Grade"
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Claro"
|
||||
msgstr "Lista"
|
||||
|
||||
msgid "Vertical"
|
||||
msgstr "Vertical"
|
||||
@@ -2296,6 +2337,9 @@ 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"
|
||||
|
||||
@@ -2326,15 +2370,14 @@ msgstr "Tipo de Controle:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Iniciar Aplicativo em Tela Cheia"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Forçar janelas em tela cheia"
|
||||
msgstr "Ocultar o cursor em tela cheia"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Tela Cheia Automática ao Conectar o Controle"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
msgstr "Desativar animação de inicialização"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Minimizar para a bandeja ao fechar"
|
||||
@@ -2360,8 +2403,11 @@ 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 "Desativar aviso de caminho em cirílico"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
@@ -2370,7 +2416,7 @@ msgid "Add to Steam compatibility tools"
|
||||
msgstr "Adicionar às ferramentas de compatibilidade da Steam"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
msgstr "Desativar a busca por instalações do Steam Proton"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
@@ -2582,15 +2628,9 @@ 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-09 11:06+0000\n"
|
||||
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
|
||||
"PO-Revision-Date: 2026-10-26 12:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
"Language: ru\n"
|
||||
@@ -19,12 +19,9 @@ 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.9.1\n"
|
||||
"X-Generator: Weblate 2026.10.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."
|
||||
@@ -32,6 +29,19 @@ 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 "Обновление завершено"
|
||||
|
||||
@@ -115,12 +125,6 @@ msgstr ""
|
||||
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Нет"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Не удалось удалить папку: {error}"
|
||||
@@ -147,9 +151,15 @@ 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 "Запустить игру"
|
||||
|
||||
@@ -277,14 +287,6 @@ 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}"
|
||||
@@ -304,10 +306,6 @@ 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}"
|
||||
@@ -320,10 +318,6 @@ 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}"
|
||||
@@ -480,6 +474,19 @@ 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 "Игра не найдена."
|
||||
|
||||
@@ -747,14 +754,14 @@ msgstr ""
|
||||
"run --user --scope --slice=app.slice"
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr "Ограничение ядер CPU"
|
||||
msgstr "Ограничение ядер ЦП"
|
||||
|
||||
msgid ""
|
||||
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||
"recommended to set the value equal to 8)"
|
||||
msgstr ""
|
||||
"Ограничение количества ядер CPU полезно для игр на Unity (рекомендуется "
|
||||
"установить значение равным 8)"
|
||||
"Ограничение количества ядер процессора полезно для игр на Unity "
|
||||
"(рекомендуется установить значение равным 8)"
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr "Версия OpenGL"
|
||||
@@ -958,6 +965,22 @@ 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 "Название:"
|
||||
|
||||
@@ -973,6 +996,9 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательский значок:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -985,6 +1011,32 @@ 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 "Не удалось скачать обложку"
|
||||
|
||||
@@ -1002,8 +1054,14 @@ msgstr ""
|
||||
"Программа остановилась. Информация о совместимости была собрана "
|
||||
"автоматически."
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
@@ -1026,9 +1084,6 @@ msgstr "Предыдущая вкладка"
|
||||
msgid "Next Tab"
|
||||
msgstr "Следующая вкладка"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
@@ -1176,9 +1231,6 @@ 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 успешно загружены!"
|
||||
|
||||
@@ -1265,9 +1317,6 @@ msgstr "Не удалось открыть файл PPDB:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Не удалось открыть файл:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Подтвердите очистку PPDB"
|
||||
|
||||
@@ -1547,7 +1596,7 @@ msgid "Failed to save custom preset."
|
||||
msgstr "Ошибка сохранения пользовательских настроек."
|
||||
|
||||
msgid "Overlay position"
|
||||
msgstr "Позиция MangoHud"
|
||||
msgstr "Положение MangoHud"
|
||||
|
||||
msgid "Device battery"
|
||||
msgstr "Батарея устройства"
|
||||
@@ -1562,7 +1611,7 @@ msgid "FCAT screen edge"
|
||||
msgstr "Край области FCAT"
|
||||
|
||||
msgid "Table columns"
|
||||
msgstr "Кол-во колонок"
|
||||
msgstr "Столбцов таблицы"
|
||||
|
||||
msgid "Network (tx/rx kb/s)"
|
||||
msgstr "Сеть (tx/rx kb/s)"
|
||||
@@ -1931,9 +1980,8 @@ 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."
|
||||
@@ -2207,9 +2255,6 @@ msgstr "Ошибка"
|
||||
msgid "Repaired"
|
||||
msgstr "Исправлено"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
@@ -2284,6 +2329,9 @@ msgstr ""
|
||||
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
|
||||
"приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProtonQt"
|
||||
|
||||
@@ -2347,6 +2395,9 @@ 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"
|
||||
|
||||
@@ -2567,15 +2618,9 @@ 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 "Выбрать тему:"
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Chinese (Simplified Han script) <https://"
|
||||
"translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/>\n"
|
||||
@@ -18,17 +18,25 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"X-Generator: Weblate 2026.10.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 "PortProtonQt 已 stacked运行。请关闭它及所有游戏,然后重新启动以应用新设置?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "更新完成"
|
||||
|
||||
@@ -110,12 +118,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr "确定要删除文件夹\"{folder_name}\"吗?此操作无法撤销。"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "删除文件夹失败:{error}"
|
||||
@@ -141,15 +143,20 @@ 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 "启动游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "已配对"
|
||||
msgstr "修复"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "从桌面移除"
|
||||
@@ -167,7 +174,7 @@ msgid "Delete"
|
||||
msgstr "删除"
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
msgstr "导入"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "编辑快捷方式"
|
||||
@@ -251,9 +258,9 @@ msgstr "桌面"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "保存自定义数据失败:{error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
msgstr "未找到可执行文件"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
@@ -273,14 +280,6 @@ 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}"
|
||||
@@ -298,10 +297,6 @@ 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}"
|
||||
@@ -312,10 +307,6 @@ 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}"
|
||||
@@ -463,19 +454,32 @@ msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
msgstr "导入('{0}'及其安装文件夹中的所有文件)?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
msgstr "另一个 Epic 操作正在运行中"
|
||||
|
||||
#, fuzzy
|
||||
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 ""
|
||||
"在以下路径中找到了西里尔字母字符。Wine 可能会运行不稳定。请将游戏或 "
|
||||
"PortProton 移动到不包含西里尔字母字符的路径:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "不再提示"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
@@ -925,6 +929,22 @@ 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 "名称:"
|
||||
|
||||
@@ -940,6 +960,10 @@ msgstr "自定义封面:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "输入封面图像的本地路径或 URL"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "自定义封面:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "添加快捷方式到:"
|
||||
|
||||
@@ -952,6 +976,35 @@ msgstr "应用"
|
||||
msgid "Invalid image"
|
||||
msgstr "图像无效"
|
||||
|
||||
#, fuzzy
|
||||
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 "在所选文件夹中未找到任何游戏。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "下载封面失败"
|
||||
|
||||
@@ -962,14 +1015,20 @@ msgid "No cover selected"
|
||||
msgstr "未选择封面"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
msgstr "兼容性报告"
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
msgstr "程序已停止。已自动收集兼容性详细信息。"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
@@ -992,9 +1051,6 @@ msgstr "上一标签页"
|
||||
msgid "Next Tab"
|
||||
msgstr "下一标签页"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "保存"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "搜索"
|
||||
|
||||
@@ -1142,9 +1198,6 @@ 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 已下载完成!"
|
||||
|
||||
@@ -1191,7 +1244,7 @@ msgid "Exe Settings"
|
||||
msgstr "可执行文件设置"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
msgstr "编辑 user.conf"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "搜索设置…"
|
||||
@@ -1217,9 +1270,8 @@ msgstr "编辑 PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "加载主题失败"
|
||||
msgstr "加载全局游戏设置失败"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "可执行文件路径不可用。"
|
||||
@@ -1230,12 +1282,8 @@ msgstr "PPDB 文件不存在于: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
msgstr "打开文件失败:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
@@ -1255,9 +1303,8 @@ msgstr "默认"
|
||||
msgid "disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
msgstr "下载 Wine/Proton…"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "输入前缀名称"
|
||||
@@ -2056,13 +2103,11 @@ msgstr "全部清除"
|
||||
msgid "Now Downloading"
|
||||
msgstr "正在下载"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "警告"
|
||||
msgstr "请稍候…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "链接:"
|
||||
msgstr "磁盘: "
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
@@ -2076,10 +2121,10 @@ msgid "Store"
|
||||
msgstr "存储"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
msgstr "将链接复制到剪贴板"
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
msgstr "退出 GOG 账号失败"
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "正在刷新…"
|
||||
@@ -2101,27 +2146,27 @@ msgid "Log out"
|
||||
msgstr "注销"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr ""
|
||||
msgstr "打开登录页面"
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
msgstr "未连接 GOG 账号"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
msgstr "正在刷新 GOG 藏品库… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
msgstr "退出 Epic 账号失败"
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
msgstr "将授权码复制到剪贴板"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
msgstr "Epic 授权中…"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
@@ -2135,33 +2180,33 @@ msgstr "已连接"
|
||||
msgid "Epic account not connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
msgstr "正在刷新 Epic 游戏库… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
msgstr "选择安装文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "正在下载"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
msgstr "另一个 GOG 操作正在运行中"
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
msgstr "未找到 GOG 安装"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
msgstr "所选文件夹不是此 GOG 游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
@@ -2173,11 +2218,11 @@ msgstr "正在下载… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
msgstr "磁盘:{0} MiB/s"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr ",预计剩余时间:{}秒"
|
||||
msgstr "ETA: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
@@ -2187,10 +2232,6 @@ msgstr "详细"
|
||||
msgid "Repaired"
|
||||
msgstr "已配对"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "快速启动"
|
||||
|
||||
@@ -2235,7 +2276,7 @@ msgid "Theme"
|
||||
msgstr "主题"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
msgstr "网格"
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
@@ -2245,21 +2286,17 @@ msgstr "浅色"
|
||||
msgid "Vertical"
|
||||
msgstr "部分"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平块"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平卡片"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平无限块"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平无限卡片"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Layout"
|
||||
@@ -2273,6 +2310,9 @@ msgstr ""
|
||||
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
|
||||
"据。"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton 设置"
|
||||
|
||||
@@ -2284,7 +2324,7 @@ msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
msgstr "账号"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "简要"
|
||||
@@ -2312,7 +2352,7 @@ msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "连接手柄时自动全屏"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
msgstr "禁用开机动画"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "关闭时最小化到系统托盘"
|
||||
@@ -2327,19 +2367,22 @@ msgid "Hide Auto-Install Tab"
|
||||
msgstr "隐藏自动安装标签页"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
msgstr "隐藏操作提示"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
msgstr "强制使用英语"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
msgstr "游戏崩溃后显示兼容性报告"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "禁用西里尔字母路径警告"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
msgstr "禁用 ALT Linux i586 依赖项检查"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "禁用运行时下载"
|
||||
@@ -2348,7 +2391,7 @@ msgid "Add to Steam compatibility tools"
|
||||
msgstr "添加到 Steam 兼容工具"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
msgstr "禁用 Steam Proton 扫描"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
@@ -2437,7 +2480,7 @@ msgstr "确定要清除缓存吗?此操作无法撤销。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr ""
|
||||
msgstr "AppImage 已集成于:{path}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
@@ -2560,15 +2603,9 @@ 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 "选择主题:"
|
||||
|
||||
@@ -2591,13 +2628,12 @@ msgstr "作者:"
|
||||
msgid "Link:"
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "选择主题:"
|
||||
msgstr "删除主题“{0}”?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "加载主题失败"
|
||||
msgstr "删除主题失败。"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
@@ -3,7 +3,7 @@ import configparser
|
||||
from pathlib import Path
|
||||
import locale
|
||||
import os
|
||||
from babel import Locale
|
||||
from babel import Locale, UnknownLocaleError
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -85,8 +85,18 @@ def get_system_locale():
|
||||
|
||||
if ui_config.get_force_english():
|
||||
return 'en'
|
||||
loc = locale.getlocale()[0]
|
||||
return loc if loc else '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'
|
||||
|
||||
def get_steam_language():
|
||||
try:
|
||||
|
||||
+113
-31
@@ -7,6 +7,7 @@ import shlex
|
||||
import subprocess
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
@@ -17,6 +18,7 @@ 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
|
||||
@@ -115,7 +117,7 @@ class MainWindow(
|
||||
QMainWindow,
|
||||
):
|
||||
games_loaded = Signal(list)
|
||||
compatibility_report_ready = Signal(str)
|
||||
compatibility_report_ready = Signal(str, str)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
@@ -1069,9 +1071,13 @@ class MainWindow(
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
else:
|
||||
# For 'all' filter - load games from different sources in parallel to prevent blocking
|
||||
# Load selected sources in parallel.
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
selected_sources = set(display_filter.split(","))
|
||||
completed = {
|
||||
source: bool(selected_sources & results.keys()) and source not in selected_sources
|
||||
for source in results
|
||||
}
|
||||
|
||||
def on_all_games():
|
||||
seen = set()
|
||||
@@ -1113,11 +1119,13 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
# Load all sources in parallel
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
callbacks = {
|
||||
"portproton": portproton_callback, "steam": steam_callback,
|
||||
"gog": gog_callback, "egs": egs_callback,
|
||||
}
|
||||
for source, callback in callbacks.items():
|
||||
if not completed[source]:
|
||||
getattr(self, f"_load_{source}_games_async")(callback)
|
||||
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
@@ -1129,6 +1137,11 @@ 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)
|
||||
@@ -1192,6 +1205,23 @@ 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
|
||||
@@ -1205,9 +1235,19 @@ class MainWindow(
|
||||
name, custom_cover = MainWindow._get_custom_store_data(
|
||||
"egs", app_id, str(game.get("title", app_id))
|
||||
)
|
||||
installed = self.egs_api.is_game_installed(app_id)
|
||||
installed = (
|
||||
installed_by_id[app_id] if only_installed
|
||||
else self.egs_api.is_game_installed(app_id)
|
||||
)
|
||||
if not only_installed or installed:
|
||||
target = self.egs_api.get_launch_target(app_id) if installed else None
|
||||
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)
|
||||
description = str(game.get("description", ""))
|
||||
if not description or description == str(game.get("title", "")):
|
||||
description = str(steam_info.get("description", ""))
|
||||
@@ -1382,6 +1422,9 @@ 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):
|
||||
@@ -2493,11 +2536,12 @@ 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)
|
||||
self.compatibility_report_ready.emit(report, launch.executable)
|
||||
|
||||
def _show_compatibility_report(self, report: str) -> None:
|
||||
def _show_compatibility_report(self, report: str, executable: 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:
|
||||
@@ -2561,8 +2605,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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
env_vars["DISABLE_CP_DEFPFX"] = "1"
|
||||
@@ -2679,8 +2723,8 @@ class MainWindow(
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Ok)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Ok, _("OK"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
|
||||
msg_box.button(QMessageBox.StandardButton.Ok).setText(_("OK"))
|
||||
msg_box.button(QMessageBox.StandardButton.Cancel).setText(_("Cancel"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Ok:
|
||||
return self._install_alt_i586_dependencies()
|
||||
@@ -2764,22 +2808,23 @@ class MainWindow(
|
||||
|
||||
def _delete_egs_game(self, app_id: str) -> None:
|
||||
game = self.egs_api.load_installed().get(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
|
||||
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.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
|
||||
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
|
||||
self._start_egs_operation(
|
||||
app_id, ["uninstall", app_id, "-y"], _("Delete")
|
||||
)
|
||||
@@ -2870,6 +2915,37 @@ 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:
|
||||
@@ -2897,6 +2973,7 @@ 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,
|
||||
@@ -2958,6 +3035,9 @@ 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)
|
||||
@@ -3063,6 +3143,8 @@ 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:
|
||||
|
||||
@@ -186,6 +186,7 @@ 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',
|
||||
|
||||
@@ -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.setButtonText(QMessageBox.StandardButton.Open, _("Choose Folder"))
|
||||
message_box.button(QMessageBox.StandardButton.Open).setText(_("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,6 +2,7 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Awaitable
|
||||
@@ -168,17 +169,20 @@ 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 or portal D-Bus API."""
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
"""Request idle inhibit through screensaver, portal or logind D-Bus API."""
|
||||
bus = None
|
||||
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:
|
||||
bus.disconnect()
|
||||
raise
|
||||
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)
|
||||
|
||||
return bus, kind, payload
|
||||
|
||||
@@ -211,8 +215,33 @@ 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,11 +1026,7 @@ class TerminalWidget(QPlainTextEdit):
|
||||
max_fd = os.sysconf("SC_OPEN_MAX")
|
||||
except ValueError:
|
||||
max_fd = 256
|
||||
for fd in range(3, max_fd):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
os.closerange(3, max_fd)
|
||||
|
||||
if self.cwd:
|
||||
os.chdir(self.cwd)
|
||||
@@ -1873,11 +1869,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:
|
||||
return [
|
||||
default_shell(),
|
||||
"-lc",
|
||||
_with_portproton_functions(args.e[0]),
|
||||
]
|
||||
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 args.e
|
||||
return [default_shell()]
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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())
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Steam API functions for PortProtonQt."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
@@ -15,6 +16,7 @@ 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 (
|
||||
@@ -46,10 +48,12 @@ 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]) -> None:
|
||||
def fetch_sgdb_cover_async(
|
||||
game_name: str, callback: Callable[[str], None], image_type: str = "grids"
|
||||
) -> None:
|
||||
"""Asynchronously fetch cover image URL from steamgrid.usebottles.com."""
|
||||
encoded = urllib.parse.quote(game_name)
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
|
||||
encoded = urllib.parse.quote(game_name, safe="")
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}/{image_type}"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -66,6 +70,14 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
|
||||
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:
|
||||
@@ -74,7 +86,7 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
|
||||
|
||||
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}.json"
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}_{image_type}.json"
|
||||
if cache_file.exists() and time.time() - cache_file.stat().st_mtime < SGDB_CACHE_DURATION:
|
||||
process_response(str(cache_file))
|
||||
return
|
||||
@@ -182,6 +194,11 @@ 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}"
|
||||
@@ -224,7 +241,12 @@ def get_protondb_tier_async(appid: int, callback: Callable[[str], None]) -> None
|
||||
callback(cached.get("tier", ""))
|
||||
return
|
||||
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
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"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -623,6 +645,19 @@ 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://"))
|
||||
|
||||
@@ -646,7 +681,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if is_store_uri:
|
||||
if cached_match is not None or is_store_uri:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -685,9 +720,9 @@ 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 = None
|
||||
matching_app = cached_match
|
||||
|
||||
if not steam_apps or not steam_apps_index:
|
||||
if matching_app is None and (not steam_apps or not steam_apps_index):
|
||||
game_name = desktop_name or exe_name
|
||||
|
||||
def on_sgdb_cover_failure(cover: str) -> None:
|
||||
@@ -716,9 +751,11 @@ 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)
|
||||
matching_app = search_app(candidate, steam_apps_index or {})
|
||||
if matching_app:
|
||||
break
|
||||
|
||||
@@ -749,18 +786,23 @@ 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 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="false",
|
||||
steam_game="true",
|
||||
anticheat_status=anticheat_info.get("status", ""),
|
||||
anticheat_slug=anticheat_info.get("slug", ""),
|
||||
)
|
||||
@@ -797,4 +839,7 @@ def get_steam_game_info_async(
|
||||
|
||||
fetch_app_info_async(appid, on_app_info)
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
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)
|
||||
@@ -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[normalized] = app
|
||||
steam_apps_index.setdefault(normalized, app)
|
||||
return steam_apps_index
|
||||
|
||||
|
||||
|
||||
@@ -7,13 +7,16 @@ 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 portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -33,6 +36,7 @@ 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__)
|
||||
@@ -328,11 +332,12 @@ def _add_shortcut_via_vdf(
|
||||
|
||||
def _download_covers(
|
||||
appid: int,
|
||||
steam_appid: int,
|
||||
steam_appid: tuple[int | None, str],
|
||||
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"),
|
||||
@@ -349,7 +354,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(steam_name).suffix.lstrip('.')
|
||||
ext = Path(result_path).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}")
|
||||
@@ -358,18 +363,54 @@ 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}")
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
|
||||
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}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
|
||||
callback=partial(on_steam_download, i, steam_name, cover_file)
|
||||
)
|
||||
|
||||
|
||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
|
||||
def add_to_steam(
|
||||
game_name: str, exec_line: str, cover_path: str, icon_source: str | None = None
|
||||
) -> 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.
|
||||
@@ -382,7 +423,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
if not exe_path:
|
||||
return (False, error)
|
||||
|
||||
if not os.path.exists(exe_path):
|
||||
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
|
||||
if not is_store_uri and not os.path.exists(exe_path):
|
||||
logger.error(f"Executable not found: {exe_path}")
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
@@ -396,7 +438,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
if error:
|
||||
return (False, error)
|
||||
|
||||
icon_path = _generate_icon(exe_path, portproton_dir)
|
||||
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(exe_path)
|
||||
icon_source = custom_icon or icon_source or (None if is_store_uri else exe_path)
|
||||
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
|
||||
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
@@ -471,10 +515,10 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
nonlocal steam_appid
|
||||
steam_appid = game_info.get("appid")
|
||||
if not steam_appid or not isinstance(steam_appid, int):
|
||||
logger.info("No valid Steam appid found, skipping cover download")
|
||||
_download_covers(appid, (None, game_name), str(grid_dir), was_api_used)
|
||||
return
|
||||
logger.info(f"Found Steam AppID {steam_appid} for cover download")
|
||||
_download_covers(appid, steam_appid, str(grid_dir), was_api_used)
|
||||
_download_covers(appid, (steam_appid, game_name), str(grid_dir), was_api_used)
|
||||
|
||||
get_steam_game_info_async(game_name, exec_line, on_game_info)
|
||||
|
||||
@@ -487,10 +531,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
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}.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")
|
||||
os.path.join(grid_dir, f"{appid}{suffix}{extension}")
|
||||
for suffix in ("", "p", "_hero", "_logo")
|
||||
for extension in (".jpg", ".png", ".jpeg")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
|
||||
@@ -397,6 +397,11 @@ 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"))
|
||||
)
|
||||
@@ -487,6 +492,9 @@ 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,6 +226,7 @@ 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,6 +59,7 @@ 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:
|
||||
@@ -204,6 +205,35 @@ 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."""
|
||||
|
||||
@@ -324,6 +354,7 @@ 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 = ""
|
||||
@@ -818,9 +849,17 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
api = self.egs_api if source == "egs" else self.gog_api
|
||||
worker = StoreDLCWorker(api, str(game["app_id"]))
|
||||
worker.loaded.connect(lambda dlcs: self._on_store_dlcs_loaded(source, game, dlcs))
|
||||
worker.failed.connect(lambda error: QMessageBox.warning(self, _("Error"), error))
|
||||
results: list[list[dict]] = []
|
||||
errors: list[str] = []
|
||||
worker.loaded.connect(results.append)
|
||||
worker.failed.connect(errors.append)
|
||||
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()
|
||||
|
||||
@@ -1051,38 +1090,41 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
if install_path is None:
|
||||
self.gogAccountStatus.setText(_("GOG installation not found"))
|
||||
return
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game["title"]
|
||||
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.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:
|
||||
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:
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(install_path)
|
||||
except OSError as error:
|
||||
logger.error("Failed to delete GOG game %s: %s", app_id, error)
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
manifest_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
)
|
||||
try:
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
|
||||
self.gog_api.remove_installed_game(app_id)
|
||||
self.context_menu_manager.remove_gog_shortcuts(str(game["title"]))
|
||||
self.loadGames(force_load=True)
|
||||
self.gog_delete_worker = None
|
||||
if worker.error:
|
||||
self.gogAccountStatus.setText(worker.error)
|
||||
else:
|
||||
self.loadGames(force_load=True)
|
||||
worker.deleteLater()
|
||||
|
||||
def _start_gog_download(
|
||||
self, game: dict, install_path: Path, command: list[str], action: str
|
||||
@@ -1104,6 +1146,7 @@ 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
|
||||
@@ -1137,6 +1180,7 @@ 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
|
||||
@@ -1170,6 +1214,7 @@ 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(
|
||||
@@ -1185,6 +1230,12 @@ 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:
|
||||
|
||||
@@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
@@ -42,6 +43,7 @@ from portprotonqt.logger import get_logger
|
||||
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__)
|
||||
@@ -95,7 +97,24 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _on_library_filter_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.filter_keys):
|
||||
return
|
||||
display_filter = self.filter_keys[index]
|
||||
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
|
||||
))
|
||||
game_config.set_display_filter(display_filter)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
@@ -150,9 +169,17 @@ 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)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
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
|
||||
self.game_library_manager.rebuild_library_layout(
|
||||
ui_config.get_library_layout_mode(theme_mode)
|
||||
)
|
||||
@@ -346,11 +373,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
self._set_combo_current_key(
|
||||
self.gamesDisplayCombo,
|
||||
self.filter_keys,
|
||||
display_filter,
|
||||
self.gamesDisplayCombo, self.filter_keys, display_filter.split(",")[0]
|
||||
)
|
||||
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
|
||||
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.activated.connect(self._delay_library_controls_hover_close)
|
||||
|
||||
self.onlyInstalledCheckBox = QCheckBox(_("Show only installed games"))
|
||||
@@ -496,9 +539,13 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
source_refresh()
|
||||
|
||||
if not callable(source_loader):
|
||||
if self.gog_api.auth_path.is_file():
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "gog" in display_filter.split(",")
|
||||
) and self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if self.egs_api.user_path.is_file():
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "egs" in display_filter.split(",")
|
||||
) and self.egs_api.user_path.is_file():
|
||||
self._refresh_egs_library()
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
@@ -536,8 +583,8 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
for game in missing_games:
|
||||
@@ -604,18 +651,21 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile().lower()
|
||||
if path.endswith(
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + WINE_ARCHIVE_EXTENSIONS
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + THEME_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(WINE_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
if path_lower.endswith(THEME_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
wine_archives.append(path)
|
||||
continue
|
||||
if path_lower.endswith(BACKUP_EXTENSION):
|
||||
@@ -631,14 +681,56 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if wine_archives:
|
||||
show_proton_manager(
|
||||
self,
|
||||
self.portproton_location,
|
||||
input_manager=self.input_manager,
|
||||
local_archives=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,
|
||||
)
|
||||
|
||||
def openAddGameDialog(self, exe_path=None):
|
||||
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
|
||||
self.current_add_game_dialog.activateWindow()
|
||||
|
||||
@@ -508,6 +508,33 @@ 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()
|
||||
@@ -893,8 +920,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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
if reset_settings():
|
||||
@@ -913,8 +940,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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -930,8 +957,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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
cache_config.clear_cache()
|
||||
@@ -1195,6 +1222,9 @@ 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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
button = msg_box.exec()
|
||||
worker.submit_pairing_response("yes" if button == QMessageBox.StandardButton.Yes else "no")
|
||||
return
|
||||
|
||||
@@ -21,6 +21,7 @@ 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:
|
||||
@@ -126,6 +127,40 @@ 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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("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
|
||||
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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
|
||||
@@ -5,6 +5,17 @@ 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,
|
||||
@@ -22,6 +33,7 @@ class MainWindowWorkersMixin:
|
||||
"gog_library_worker": None,
|
||||
"gog_metadata_worker": None,
|
||||
"gog_repair_worker": None,
|
||||
"gog_delete_worker": None,
|
||||
"store_dlc_worker": None,
|
||||
"gog_auth_worker": None,
|
||||
"gog_account_worker": None,
|
||||
@@ -38,6 +50,8 @@ class MainWindowWorkersMixin:
|
||||
"_listWorkerPool",
|
||||
"_imageWorkerPool",
|
||||
"_detailImageWorkerPool",
|
||||
"_themeArchiveWorkers",
|
||||
"_gameFolderWorkers",
|
||||
):
|
||||
self._stopWorkerThreads(workers_name)
|
||||
downloader = getattr(self, "downloader", None)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -727,6 +727,25 @@ 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,6 +10,8 @@ bootAnimationTimeoutMs = 30000
|
||||
cursorHideTimeoutMs = 5000
|
||||
startupLibraryLoadDelayMs = 500
|
||||
autoSizeButtonPadding = (10, 20)
|
||||
sourceFilterIndicatorSize = 18
|
||||
sourceFilterIndicatorRadius = 2
|
||||
favoriteLabelSize = 48, 48
|
||||
favoriteLabelIconSize = 32
|
||||
detailCompactCoverFrameSize = 128
|
||||
@@ -366,3 +368,7 @@ 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,6 +177,9 @@ 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,6 +141,14 @@ 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")
|
||||
|
||||
@@ -188,18 +196,45 @@ 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,10 +11,6 @@ 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):
|
||||
@@ -56,7 +52,8 @@ class TestSaveLoadJson:
|
||||
|
||||
def test_pretty_print(self, tmp_path: Path):
|
||||
mgr = CacheManager(cache_dir=tmp_path)
|
||||
mgr.save_json("test", {"a": 1}, pretty=True)
|
||||
assert mgr.save_json("test", {"a": 1}, pretty=True)
|
||||
assert mgr._get_cache_file_path("test").read_text() == '{\n "a": 1\n}'
|
||||
loaded = mgr.load_json("test")
|
||||
assert loaded == {"a": 1}
|
||||
|
||||
|
||||
+536
-60
@@ -16,8 +16,6 @@ from portprotonqt.cli import (
|
||||
parse_portprotonqt_theme_url,
|
||||
parse_resolution,
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
PP_EXTENSIONS,
|
||||
PREFIX_BACKUP_EXTENSION,
|
||||
parse_args,
|
||||
)
|
||||
|
||||
@@ -37,53 +35,9 @@ 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 QtNetwork
|
||||
from PySide6 import QtCore, QtNetwork
|
||||
from portprotonqt import app, main_window, port_data_path_selector
|
||||
|
||||
monkeypatch.delenv("STEAM_COMPAT", raising=False)
|
||||
@@ -104,19 +58,21 @@ 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,
|
||||
tray=tray, window=window, exe=exe)
|
||||
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server, lock=lock,
|
||||
window=window, exe=exe)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_uri", [False, True])
|
||||
@@ -130,7 +86,6 @@ 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"
|
||||
@@ -147,11 +102,114 @@ 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"),
|
||||
@@ -171,7 +229,6 @@ 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()
|
||||
|
||||
|
||||
@@ -260,9 +317,6 @@ 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):
|
||||
@@ -275,9 +329,6 @@ 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):
|
||||
@@ -357,3 +408,428 @@ 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,3 +590,144 @@ 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"
|
||||
+67
-74
@@ -1,27 +1,13 @@
|
||||
"""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 DBusError, Message, Variant
|
||||
from dbus_fast import BusType, 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,
|
||||
@@ -48,21 +34,6 @@ 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")
|
||||
@@ -213,11 +184,12 @@ class TestDbusCall:
|
||||
result = _run_async(dbus_call(coro()))
|
||||
assert result == "ok"
|
||||
|
||||
def test_timeout(self):
|
||||
async def slow():
|
||||
await asyncio.sleep(100)
|
||||
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)
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
_run_async(asyncio.wait_for(slow(), timeout=0.01))
|
||||
_run_async(dbus_call(slow()))
|
||||
|
||||
|
||||
class TestBusCall:
|
||||
@@ -365,6 +337,66 @@ 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():
|
||||
@@ -583,42 +615,3 @@ 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"
|
||||
@@ -12,6 +12,9 @@ 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:
|
||||
@@ -76,7 +79,7 @@ def test_missing_destination_still_downloads_with_a_worker(
|
||||
|
||||
assert worker is not None
|
||||
assert worker in downloader._active_threads
|
||||
assert worker.wait(5000)
|
||||
assert worker.wait(THREAD_TIMEOUT_MS)
|
||||
assert results == []
|
||||
QApplication.processEvents()
|
||||
assert results == [str(target)]
|
||||
@@ -124,11 +127,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=5)
|
||||
caller.join(timeout=SYNC_TIMEOUT_SECONDS)
|
||||
assert not caller.is_alive()
|
||||
worker = workers[0]
|
||||
assert worker is not None
|
||||
assert worker.wait(5000)
|
||||
assert worker.wait(THREAD_TIMEOUT_MS)
|
||||
QApplication.processEvents()
|
||||
|
||||
assert results == [(path, QThread.currentThread())]
|
||||
@@ -146,13 +149,13 @@ def test_shutdown_waits_for_an_active_download(
|
||||
|
||||
def download(*_args: Any) -> str:
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
assert release.wait(SYNC_TIMEOUT_SECONDS)
|
||||
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(5)
|
||||
assert entered.wait(SYNC_TIMEOUT_SECONDS)
|
||||
owner = cast(Any, MainWindowWorkersMixin())
|
||||
owner.downloader = downloader
|
||||
owner.compatibility_workers = []
|
||||
@@ -171,4 +174,4 @@ def test_shutdown_waits_for_an_active_download(
|
||||
finally:
|
||||
release.set()
|
||||
release_timer.join()
|
||||
worker.wait(5000)
|
||||
worker.wait(THREAD_TIMEOUT_MS)
|
||||
+51
-3
@@ -1,3 +1,4 @@
|
||||
import argparse
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -6,7 +7,53 @@ from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.scripts_utils.easyterm import TerminalWidget
|
||||
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"]
|
||||
|
||||
|
||||
def test_terminal_keeps_failed_command_output(
|
||||
@@ -25,9 +72,10 @@ def test_terminal_keeps_failed_command_output(
|
||||
False,
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 5
|
||||
while terminal.child_pid is not None and time.monotonic() < deadline:
|
||||
deadline = time.monotonic() + PROCESS_TIMEOUT_SECONDS
|
||||
while terminal.exit_code is None and time.monotonic() < deadline:
|
||||
application.processEvents()
|
||||
terminal._check_child()
|
||||
time.sleep(0.01)
|
||||
|
||||
assert terminal.exit_code == 7
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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,14 +1,17 @@
|
||||
"""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,
|
||||
@@ -16,6 +19,8 @@ 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,
|
||||
@@ -25,6 +30,53 @@ 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."""
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from pytest import MonkeyPatch, raises
|
||||
from pytest import MonkeyPatch, raises, mark
|
||||
|
||||
import portprotonqt.game_card as game_card_module
|
||||
import portprotonqt.image_utils as image_utils
|
||||
@@ -608,9 +608,16 @@ 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")
|
||||
@@ -626,6 +633,8 @@ 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")
|
||||
@@ -642,14 +651,30 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
)
|
||||
|
||||
image_utils.load_pixmap_async(
|
||||
"https://example.org/game.jpg", 100, 150,
|
||||
url, 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" / "game.jpg"
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / filename
|
||||
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)
|
||||
+283
-6
@@ -6,8 +6,9 @@ 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, QWidget
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, 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
|
||||
@@ -35,6 +36,34 @@ 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([])
|
||||
@@ -646,7 +675,10 @@ def test_confirm_button_opens_combo_popup() -> None:
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: MonkeyPatch) -> None:
|
||||
@mark.parametrize("table_name", ["advanced_table", "favorites_table"])
|
||||
def test_settings_gamepad_skips_separator_and_activates_download(
|
||||
monkeypatch: MonkeyPatch, table_name: str,
|
||||
) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.input_manager.constants import BUTTONS
|
||||
from portprotonqt.input_manager.settings import SettingsInputMixin
|
||||
@@ -672,7 +704,7 @@ def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: Mo
|
||||
cast(ExeSettingsDialog, settings), index, combo, "PW_WINE_USE",
|
||||
))
|
||||
manager = SimpleNamespace(
|
||||
settings_dialog=SimpleNamespace(advanced_table=table, settings_table=None, favorites_table=None),
|
||||
settings_dialog=SimpleNamespace(advanced_table=None, 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: [],
|
||||
@@ -680,13 +712,13 @@ def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: Mo
|
||||
_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 highlighted[-1] == 2
|
||||
assert combo.currentData() == "download_wine"
|
||||
assert combo.currentText() == "Proton"
|
||||
SettingsInputMixin.handle_settings_button(
|
||||
cast(SettingsInputMixin, manager), next(iter(BUTTONS["confirm"])), 1,
|
||||
)
|
||||
@@ -725,7 +757,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() == expected
|
||||
assert combo.currentIndex() == 0
|
||||
assert combo.view().currentIndex().row() == expected
|
||||
assert combo.view().isVisible()
|
||||
finally:
|
||||
@@ -1427,3 +1459,248 @@ 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
|
||||
+120
-15
@@ -1,8 +1,6 @@
|
||||
"""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
|
||||
@@ -17,6 +15,8 @@ from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.theme_manager import load_theme
|
||||
|
||||
LOAD_TIMEOUT_MS = 30000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_scene(
|
||||
@@ -399,7 +399,7 @@ def real_cover_scene(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("animation_type", ["gradient", "glow"])
|
||||
def test_real_cover_loading_and_first_paint_focus_latency(
|
||||
def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
real_cover_scene: tuple[GameLibraryManager, Path],
|
||||
monkeypatch: pytest.MonkeyPatch, animation_type: str,
|
||||
) -> None:
|
||||
@@ -407,14 +407,14 @@ def test_real_cover_loading_and_first_paint_focus_latency(
|
||||
monkeypatch.setattr(manager.theme, "GAME_CARD_ANIMATION",
|
||||
{**manager.theme.GAME_CARD_ANIMATION, "card_animation_type": animation_type})
|
||||
threads: list[bool] = []
|
||||
paints: list[tuple[float, bool]] = []
|
||||
ticks: list[float] = []
|
||||
paints: list[bool] = []
|
||||
ticks: list[tuple[int, int]] = []
|
||||
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((perf_counter(), card._focused or card._hovered))
|
||||
paints.append(card._focused or card._hovered)
|
||||
original_paint(card, event)
|
||||
|
||||
def record_cover(card: GameCard, pixmap: QPixmap) -> None:
|
||||
@@ -422,7 +422,7 @@ def test_real_cover_loading_and_first_paint_focus_latency(
|
||||
original_cover(card, pixmap)
|
||||
|
||||
def sample() -> None:
|
||||
ticks.append(perf_counter())
|
||||
ticks.append((len(manager.game_card_cache), len(threads)))
|
||||
if len(threads) == len(games) and not manager._incremental_add_queue:
|
||||
loop.quit()
|
||||
|
||||
@@ -434,18 +434,14 @@ def test_real_cover_loading_and_first_paint_focus_latency(
|
||||
timer.setInterval(5)
|
||||
timer.timeout.connect(sample)
|
||||
timer.start()
|
||||
QTimer.singleShot(2000, loop.quit)
|
||||
QTimer.singleShot(LOAD_TIMEOUT_MS, loop.quit)
|
||||
manager.set_games(games)
|
||||
loop.exec()
|
||||
timer.stop()
|
||||
assert len(threads) == len(games) and all(threads), threads
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", [("grid", 0), ("grid", 1), ("vertical", 0), ("vertical", 1)])
|
||||
@@ -455,6 +451,7 @@ 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])
|
||||
@@ -463,6 +460,7 @@ 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]] = []
|
||||
@@ -545,3 +543,110 @@ 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()
|
||||
+414
-23
@@ -11,9 +11,10 @@ from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import psutil
|
||||
from pytest import MonkeyPatch, fixture, mark
|
||||
from pytest import MonkeyPatch, fixture, mark, raises
|
||||
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,
|
||||
@@ -22,6 +23,9 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QMenu,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QStyle,
|
||||
QStyleOptionComboBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
@@ -31,10 +35,11 @@ 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
|
||||
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton, CustomComboBox
|
||||
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
|
||||
@@ -69,6 +74,125 @@ 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([])
|
||||
@@ -192,7 +316,7 @@ def _tile_theme() -> Any:
|
||||
|
||||
@mark.parametrize(
|
||||
"worker_name",
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "store_dlc_worker"),
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
|
||||
)
|
||||
def test_shutdown_waits_for_store_library_workers(worker_name: str) -> None:
|
||||
worker = MagicMock()
|
||||
@@ -310,7 +434,7 @@ 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, False)])
|
||||
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, True)])
|
||||
def test_source_ribbon_visibility(
|
||||
monkeypatch: MonkeyPatch,
|
||||
vertical_layout: bool,
|
||||
@@ -667,6 +791,9 @@ 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()
|
||||
|
||||
@@ -1158,6 +1285,7 @@ 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))
|
||||
@@ -1297,7 +1425,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="")
|
||||
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_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
|
||||
@@ -1760,7 +1888,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=reports.append)
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=lambda *args: reports.append(args))
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.has_dxvk_vulkan_incompatibility",
|
||||
lambda _path: True,
|
||||
@@ -1772,7 +1900,7 @@ def test_dxvk_incompatibility_reports_after_manual_stop(
|
||||
|
||||
window._build_compatibility_report(launch, stopped_by_user=True)
|
||||
|
||||
assert reports == ["forced report"]
|
||||
assert reports == [("forced report", launch.executable)]
|
||||
|
||||
def test_disabled_crash_reports_skip_launch_analysis(
|
||||
monkeypatch: MonkeyPatch,
|
||||
@@ -1841,7 +1969,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=reports.append)
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=lambda *args: reports.append(args))
|
||||
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)
|
||||
@@ -1851,7 +1979,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"]
|
||||
assert reports == [("crash report", launch.executable)]
|
||||
|
||||
def test_update_prefix_log_does_not_mark_wine_launch_start() -> None:
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -1869,14 +1997,17 @@ 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")
|
||||
window._show_compatibility_report("report text", "/games/Game.exe")
|
||||
|
||||
assert calls == [(window, window.theme, "report text"), "exec"]
|
||||
assert calls == [(window, window.theme, "report text"), "/games/Game.exe", "exec"]
|
||||
|
||||
def test_refresh_theme_store_visibility_adds_store(monkeypatch: Any) -> None:
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -2133,10 +2264,12 @@ 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")
|
||||
@@ -2152,8 +2285,15 @@ 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(
|
||||
@@ -2173,10 +2313,9 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0] is not None
|
||||
assert results[0][0] == "Test Game"
|
||||
assert results[0][0] == (custom_name or "Test Game")
|
||||
assert results[0][5] == f"portproton {exe_path}"
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
assert not custom_data_path.exists()
|
||||
assert custom_data_path.exists() == bool(custom_name)
|
||||
|
||||
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"
|
||||
@@ -2477,7 +2616,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=""),
|
||||
theme=SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=""),
|
||||
_create_library_combo=lambda labels, tooltip: QComboBox(),
|
||||
_set_combo_current_key=MagicMock(),
|
||||
_register_gamepad_tooltip=MagicMock(),
|
||||
@@ -2518,7 +2657,7 @@ def test_library_layout_control_rebuilds_both_libraries(
|
||||
monkeypatch.setattr(
|
||||
library_tab_module.ui_config,
|
||||
"get_library_layout_mode",
|
||||
lambda theme_mode: "grid",
|
||||
MagicMock(side_effect=["list", "grid", "grid"]),
|
||||
)
|
||||
|
||||
LibraryMixin._on_library_layout_changed(window, 1)
|
||||
@@ -2749,15 +2888,22 @@ 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: "all")
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: display_filter)
|
||||
monkeypatch.setattr(
|
||||
library_tab_module.QTimer,
|
||||
"singleShot",
|
||||
@@ -2779,11 +2925,7 @@ def test_library_refresh_updates_connected_stores_for_all_filter(
|
||||
|
||||
LibraryMixin.refreshGames(cast(Any, window))
|
||||
|
||||
assert calls == [
|
||||
"gog",
|
||||
"egs",
|
||||
("load", {"force_load": True}),
|
||||
]
|
||||
assert calls == [*stores, ("load", {"force_load": True})]
|
||||
|
||||
def test_library_refresh_skips_gog_for_portproton_filter(
|
||||
tmp_path: Path,
|
||||
@@ -2986,4 +3128,253 @@ 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()
|
||||
window.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
|
||||
)
|
||||
|
||||
|
||||
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog,portproton")
|
||||
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=lambda callback: callbacks.update(portproton=callback),
|
||||
_load_steam_games_async=MagicMock(), _load_egs_games_async=MagicMock(),
|
||||
))
|
||||
|
||||
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["portproton"]([pp_game])
|
||||
|
||||
loaded.assert_called_once_with([pp_game, gog_game])
|
||||
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()
|
||||
@@ -94,33 +94,6 @@ 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:
|
||||
|
||||
@@ -43,20 +43,22 @@ gui_config () {
|
||||
|
||||
|
||||
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str) -> None:
|
||||
@mark.parametrize("copies", [1, 3])
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str, copies: int) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
condition = helper.split(' if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]', 1)[1].split(
|
||||
condition = helper.split(' local -a run_after_exes=', 1)[1].split(
|
||||
" then", 1,
|
||||
)[0]
|
||||
condition = ' local -a run_after_exes=' + condition
|
||||
main_exe = tmp_path / "Game.exe"
|
||||
second_exe = tmp_path / second_name
|
||||
main_exe.touch()
|
||||
second_exe.touch()
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]' + condition
|
||||
+ "then exit 0; else exit 1; fi"],
|
||||
["bash", "-c", 'check_run_after() {\n' + condition
|
||||
+ "then return 0; else return 1; fi; }; check_run_after"],
|
||||
env={**os.environ, "PW_EXE_FILE": str(main_exe),
|
||||
"PW_RUN_AFTER_EXE": str(second_exe)},
|
||||
"PW_RUN_AFTER_EXE": ",".join([str(second_exe)] * copies)},
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -112,7 +114,9 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
|
||||
)[0]
|
||||
main_path = "D:\\Games\\Test Game\\Test Game.exe"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'pw_run() { env; }\ncat > "$run_after_bat" <<EOF\n' + block],
|
||||
["bash", "-c", 'pw_run() { env; }\nlaunch() {\n'
|
||||
'local -a run_after_exes=(unused) run_after_environment=()\n'
|
||||
'cat > "$run_after_bat" <<EOF\n' + block + '\n}; launch'],
|
||||
env={**os.environ, "run_after_bat": str(tmp_path / "launch.bat"),
|
||||
"pw_exe_file_win": main_path, "run_after_exe_win": trainer_path,
|
||||
"wait_delay_int": "3", "LAUNCH_PARAMETERS": ""},
|
||||
@@ -120,6 +124,7 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
|
||||
)
|
||||
assert f"PW_RUN_AFTER_MAIN_PATH={main_path}" in result.stdout
|
||||
assert f"PW_RUN_AFTER_SECOND_PATH={trainer_path}" in result.stdout
|
||||
assert "PW_RUN_AFTER_EXE=\n" in result.stdout
|
||||
batch = (tmp_path / "launch.bat").read_text(encoding="ascii")
|
||||
assert 'start "" "%PW_RUN_AFTER_SECOND_PATH%"' in batch
|
||||
|
||||
@@ -192,3 +197,109 @@ def test_vk_gpu_info_uses_build_aux_binary() -> None:
|
||||
|
||||
assert '/../../../bin/vk_gpu_info"' in helper
|
||||
assert "dev-scripts/vk_gpu_info" not in helper
|
||||
|
||||
|
||||
@mark.parametrize("filename,pe_status,expected_calls", [
|
||||
("Game [one].EXE", "0", ["pe"]),
|
||||
("Game [one].exe", "1", ["pe", "exiftool"]),
|
||||
("Game [one].lnk", "0", ["exiftool"]),
|
||||
])
|
||||
def test_executable_metadata_uses_pefile_and_preserves_lnk_fallback(
|
||||
tmp_path: Path, filename: str, pe_status: str, expected_calls: list[str],
|
||||
) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
definition = helper.split("use_exiftool () {", 1)[1].split("\npw_create_gui_png () {", 1)[0]
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
exiftool = bin_dir / "exiftool"
|
||||
exiftool.write_text('#!/bin/sh\nprintf "exiftool\\n" >> "$CALLS"\nprintf "Product Name: Fallback\\n"\n')
|
||||
exiftool.chmod(0o755)
|
||||
calls = tmp_path / "calls"
|
||||
script = '''python_module () {
|
||||
printf 'pe\\n' >> "$CALLS"
|
||||
if [[ "$PE_STATUS" == 0 ]]; then
|
||||
printf 'Product Name: Game\\nFile Description: Description\\nCompany Name: Sony\\n'
|
||||
fi
|
||||
return "$PE_STATUS"
|
||||
}
|
||||
'''
|
||||
script += 'use_exiftool () {' + definition + '\nuse_exiftool "$TARGET"\nuse_exiftool "$TARGET"\n'
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True, check=False,
|
||||
env={**os.environ, "PATH": f"{bin_dir}:{os.defpath}", "PW_TMPFS_PATH": str(tmp_path),
|
||||
"CALLS": str(calls), "PE_STATUS": pe_status, "TARGET": str(tmp_path / filename)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert calls.read_text().splitlines() == expected_calls
|
||||
metadata = (tmp_path / "exiftool.tmp").read_text()
|
||||
assert metadata.splitlines()[0] == str(tmp_path / filename)
|
||||
assert "Product Name:" in metadata
|
||||
if expected_calls == ["pe"]:
|
||||
assert "File Description: Description" in metadata
|
||||
assert "Company Name: Sony" in metadata
|
||||
|
||||
|
||||
def test_run_after_batch_preserves_duplicate_entries(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
block = helper.split(' cat > "${run_after_bat}" <<EOF\n', 1)[1].split(
|
||||
' try_remove_file "${run_after_bat}"', 1,
|
||||
)[0]
|
||||
exe = tmp_path / "EVE online.exe"
|
||||
exe.touch()
|
||||
script = '''
|
||||
loader() { printf 'Z:%s' "$3"; }
|
||||
pw_run() { env; }
|
||||
launch() {
|
||||
local -a run_after_exes=("$EXE" "$EXE" "$EXE") run_after_environment=()
|
||||
local run_after_index run_after_entry
|
||||
WINELOADER=loader
|
||||
cat > "$run_after_bat" <<EOF
|
||||
''' + block + '\n}; launch'
|
||||
batch_path = tmp_path / "launch.bat"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True, check=True,
|
||||
env={**os.environ, "EXE": str(exe), "run_after_bat": str(batch_path),
|
||||
"pw_exe_file_win": "Z:\\EVE online.exe",
|
||||
"run_after_exe_win": "Z:\\EVE online.exe", "wait_delay_int": "3"},
|
||||
)
|
||||
batch = batch_path.read_text()
|
||||
assert batch.count('start ""') == 4
|
||||
assert '%PW_RUN_AFTER_PATH_1%' in batch
|
||||
assert '%PW_RUN_AFTER_PATH_2%' in batch
|
||||
expected = "Z:" + str(exe).replace("/", "\\")
|
||||
assert f"PW_RUN_AFTER_PATH_1={expected}" in result.stdout
|
||||
assert f"PW_RUN_AFTER_PATH_2={expected}" in result.stdout
|
||||
|
||||
|
||||
def test_run_after_wrapper_runs_script_list_with_duplicates(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
wrapper = helper.split("pw_launch_wrapper () {", 1)[1].split(
|
||||
"export -f pw_launch_wrapper", 1,
|
||||
)[0]
|
||||
first = tmp_path / "first script.sh"
|
||||
second = tmp_path / "second.sh"
|
||||
for script, message in [(first, "first"), (second, "second")]:
|
||||
script.write_text(f"#!/bin/bash\nprintf '{message}\\n'\n")
|
||||
script.chmod(0o700)
|
||||
result = subprocess.run(
|
||||
["bash", "-c", "pw_launch_wrapper () {" + wrapper
|
||||
+ "\npw_launch_wrapper printf 'game\\n'"],
|
||||
env={**os.environ, "PW_RUN_AFTER_EXE": f"{first},{second},{first}"},
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert result.stdout.splitlines() == ["first", "second", "first", "game"]
|
||||
|
||||
|
||||
def test_run_after_wrapper_preserves_command_with_comma(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
wrapper = helper.split("pw_launch_wrapper () {", 1)[1].split(
|
||||
"export -f pw_launch_wrapper", 1,
|
||||
)[0]
|
||||
result = subprocess.run(
|
||||
["bash", "-c", "pw_launch_wrapper () {" + wrapper
|
||||
+ "\npw_launch_wrapper printf 'game\\n'"],
|
||||
cwd=tmp_path,
|
||||
env={**os.environ, "PW_RUN_AFTER_EXE": "printf '%s\\n' first,second"},
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert result.stdout.splitlines() == ["first,second", "game"]
|
||||
@@ -2,17 +2,52 @@
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QMimeData, QUrl
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from portprotonqt.dialogs.proton_manager import ProtonManager
|
||||
from portprotonqt.dialogs.wine_extractor import ExtractionThread
|
||||
from portprotonqt.tabs import library_tab
|
||||
|
||||
|
||||
def test_nested_manager_registers_input_before_connecting_hints(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtWidgets import QDialog, QPushButton
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
main = cast(Any, QWidget())
|
||||
main.get_button_icon = MagicMock(return_value="placeholder")
|
||||
main.get_nav_icon = MagicMock(return_value="placeholder")
|
||||
settings = cast(Any, QDialog(main))
|
||||
events = []
|
||||
inputs = SimpleNamespace(
|
||||
gamepad=object(), gamepad_type="xbox",
|
||||
enable_proton_manager_mode=lambda dialog: events.append(("enable", dialog)),
|
||||
disable_proton_manager_mode=lambda: None,
|
||||
connect_surface_updates=lambda name, callback: events.append((name, callback)),
|
||||
)
|
||||
settings.input_manager = inputs
|
||||
monkeypatch.setattr(module.ProtonManager, "start_loading_wine_data", lambda self: None)
|
||||
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **_kwargs: QPushButton(text))
|
||||
manager = module.ProtonManager(settings, theme=ThemeWrapper(load_theme("standart")), input_manager=inputs)
|
||||
|
||||
assert manager.main_window is main
|
||||
assert events[0] == ("enable", manager)
|
||||
assert events[1][0] == "proton_manager_dialog"
|
||||
assert main.get_button_icon.called and main.get_nav_icon.called
|
||||
manager.close()
|
||||
settings.deleteLater()
|
||||
main.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_cachyos_v3_builds_require_supported_cpu_level(monkeypatch) -> None:
|
||||
manager = ProtonManager.__new__(ProtonManager)
|
||||
manager.cpu_level = 2
|
||||
@@ -111,6 +146,7 @@ def test_extraction_does_not_change_process_directory(tmp_path, monkeypatch) ->
|
||||
|
||||
|
||||
def test_library_drop_opens_manager_for_wine_archive(tmp_path, monkeypatch) -> None:
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
archive_path = tmp_path / "PROTON_LG_10-30.tar.xz"
|
||||
archive_path.touch()
|
||||
mime_data = QMimeData()
|
||||
@@ -126,9 +162,18 @@ def test_library_drop_opens_manager_for_wine_archive(tmp_path, monkeypatch) -> N
|
||||
def acceptProposedAction(self):
|
||||
self.accepted = True
|
||||
|
||||
class Window:
|
||||
class Window(QWidget):
|
||||
portproton_location = "/tmp/PortProtonQt"
|
||||
input_manager = None
|
||||
_on_dropped_theme_installed = library_tab.MainWindowLibraryTabMixin._on_dropped_theme_installed
|
||||
_on_dropped_theme_failed = library_tab.MainWindowLibraryTabMixin._on_dropped_theme_failed
|
||||
_on_dropped_wine_archives = library_tab.MainWindowLibraryTabMixin._on_dropped_wine_archives
|
||||
_install_dropped_theme_archives = library_tab.MainWindowLibraryTabMixin._install_dropped_theme_archives
|
||||
theme = SimpleNamespace(MESSAGE_BOX_STYLE="", PROGRESS_BAR_STYLE="")
|
||||
|
||||
from portprotonqt.tabs.theme_store_workers import ThemeArchiveInstallWorker
|
||||
|
||||
monkeypatch.setattr(ThemeArchiveInstallWorker, "start", lambda self: self.wine_archives.emit(self.paths))
|
||||
|
||||
monkeypatch.setattr(
|
||||
library_tab, "show_proton_manager", lambda *args, **kwargs: calls.append(kwargs)
|
||||
@@ -174,3 +219,74 @@ def test_disabled_steam_proton_scanning_keeps_local_versions(
|
||||
assert manager.get_installed_versions() == [("LocalWine", str(local_wine))]
|
||||
compat.assert_not_called()
|
||||
home.assert_not_called()
|
||||
|
||||
|
||||
def test_metadata_error_preserves_local_versions(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from PySide6.QtWidgets import QLabel, QStackedWidget, QTabWidget
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
local_version = tmp_path / "GE-Proton"
|
||||
local_version.mkdir()
|
||||
manager = SimpleNamespace(
|
||||
content_stack=QStackedWidget(),
|
||||
tab_widget=QTabWidget(),
|
||||
theme=SimpleNamespace(GETWINE_WINDOW_STYLE=""),
|
||||
)
|
||||
manager.content_stack.addWidget(QWidget())
|
||||
manager.content_stack.addWidget(QWidget())
|
||||
manager.tab_widget.addTab(QWidget(), "Loading")
|
||||
|
||||
def create_installed_tab() -> None:
|
||||
assert local_version.is_dir()
|
||||
manager.tab_widget.addTab(QLabel(local_version.name), "Installed")
|
||||
|
||||
manager.create_installed_tab = create_installed_tab
|
||||
monkeypatch.setattr(module, "_", lambda text: text)
|
||||
module.ProtonManager.on_wine_data_load_error(cast(Any, manager), "ConnectionError: " + "x" * 2000)
|
||||
|
||||
assert manager.content_stack.currentIndex() == 1
|
||||
assert manager.tab_widget.count() == 1
|
||||
assert manager.tab_widget.currentIndex() == 0
|
||||
assert manager.tab_widget.widget(0).text() == local_version.name
|
||||
manager.tab_widget.deleteLater()
|
||||
manager.content_stack.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_installed_sizes_update_after_metadata_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from PySide6.QtCore import QEventLoop, QTimer
|
||||
from PySide6.QtWidgets import QPushButton, QTableWidget
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
version = tmp_path / "GE-Proton"
|
||||
version.mkdir()
|
||||
(version / "wine").write_bytes(b"x" * 2048)
|
||||
monkeypatch.setattr(module.ProtonManager, "start_loading_wine_data", lambda self: None)
|
||||
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **kwargs: QPushButton(text))
|
||||
monkeypatch.setattr(module.ProtonManager, "get_installed_versions", lambda self: [("GE-Proton", str(version))])
|
||||
manager = module.ProtonManager(portproton_location=str(tmp_path), theme=ThemeWrapper(load_theme("standart")))
|
||||
manager.on_wine_data_load_error("Offline")
|
||||
workers = list(manager._installed_size_tables)
|
||||
loop = QEventLoop()
|
||||
workers[0].finished.connect(loop.quit)
|
||||
QTimer.singleShot(3000, loop.quit)
|
||||
loop.exec()
|
||||
app.processEvents()
|
||||
try:
|
||||
installed_tab = manager.tab_widget.widget(0)
|
||||
assert installed_tab is not None
|
||||
table = installed_tab.findChild(QTableWidget)
|
||||
assert table is not None
|
||||
size_item = table.item(0, 2)
|
||||
assert size_item is not None
|
||||
assert size_item.text() == "2.0 KiB"
|
||||
finally:
|
||||
from shiboken6 import isValid
|
||||
for worker in workers:
|
||||
if isValid(worker):
|
||||
worker.wait()
|
||||
manager.close()
|
||||
app.processEvents()
|
||||
@@ -3,7 +3,7 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent, QRect, Qt, QTimer
|
||||
@@ -26,7 +26,6 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from portprotonqt.custom_widgets import CustomComboBox
|
||||
from portprotonqt.tabs.settings_tab import SettingsButtonsLayout
|
||||
|
||||
from portprotonqt.dialogs.settings_gamescope import (
|
||||
GAMESCOPE_TOGGLE_CATEGORIES,
|
||||
GAMESCOPE_TOGGLE_DESCRIPTIONS,
|
||||
@@ -40,6 +39,7 @@ from portprotonqt.dialogs.settings_mangohud import (
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VkBasaltSettingsMixin
|
||||
|
||||
|
||||
PAINT_SETTLE_MS = 250
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@@ -458,13 +458,29 @@ def test_settings_table_descriptions_and_centered_controls(theme_name: str) -> N
|
||||
table.selectRow(1)
|
||||
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
|
||||
assert isinstance(table.cellWidget(1, 0), QLabel)
|
||||
label = table.cellWidget(1, 0)
|
||||
ExeSettingsDialog._set_favorite_text_cells(
|
||||
cast(ExeSettingsDialog, dialog), 1, "key", "Updated", "New <description>",
|
||||
)
|
||||
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
|
||||
assert table.cellWidget(1, 0) is label
|
||||
assert isinstance(label, QLabel)
|
||||
assert label.text() == theme.SETTINGS_SELECTED_TEXT_HTML.format(
|
||||
text="Updated<br>" + theme.SETTINGS_SELECTED_DESCRIPTION_HTML.format(
|
||||
description="New <description>",
|
||||
),
|
||||
)
|
||||
updated_item = table.item(1, 0)
|
||||
assert updated_item is not None
|
||||
assert updated_item.data(Qt.ItemDataRole.AccessibleTextRole) == "Updated\nNew <description>"
|
||||
assert updated_item.text() == ""
|
||||
table.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("close_dialog", [False, True])
|
||||
@pytest.mark.parametrize("themed", [False, True])
|
||||
def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed: bool) -> None:
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QComboBox, QDialog
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
@@ -472,7 +488,7 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
|
||||
QDialog.__init__(dialog)
|
||||
dialog.vkbasalt_toggle_key_button = None
|
||||
dialog.input_manager = None
|
||||
dialog.gamepad_tooltip = QLabel(dialog, Qt.WindowType.ToolTip)
|
||||
dialog.gamepad_tooltip = QLabel(dialog)
|
||||
dialog.gamepad_tooltip_timer = QTimer(dialog)
|
||||
dialog.gamepad_tooltip_timer.setSingleShot(True)
|
||||
combo = QComboBox(dialog)
|
||||
@@ -481,9 +497,16 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
|
||||
from portprotonqt.themes.standart import styles
|
||||
|
||||
combo.setStyleSheet(ThemeWrapper(styles).COMBOBOX_STYLE)
|
||||
dialog.gamepad_tooltip.setStyleSheet(ThemeWrapper(styles).TOOLTIP_STYLE)
|
||||
combo.addItems(["Proton", "WINE-11.18-AMD64-WOW64" if themed else "Proton " * 40])
|
||||
dialog.resize(600, 400)
|
||||
dialog.move(20, 20)
|
||||
combo.setGeometry(450, 20, 140, 40)
|
||||
combo.view().setFixedWidth(180)
|
||||
combo.highlighted.connect(lambda row: dialog._on_combo_highlighted(row, combo))
|
||||
combo.view().installEventFilter(dialog)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous: dialog._on_combo_highlighted(index.row(), combo),
|
||||
)
|
||||
combo.setCurrentIndex(1)
|
||||
dialog.show()
|
||||
combo.showPopup()
|
||||
@@ -491,10 +514,11 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
|
||||
|
||||
assert dialog.gamepad_tooltip.isVisible()
|
||||
assert dialog.gamepad_tooltip.text() == combo.itemText(1)
|
||||
dialog._on_combo_highlighted(0, combo)
|
||||
assert dialog.rect().contains(dialog.gamepad_tooltip.geometry())
|
||||
combo.view().setCurrentIndex(combo.model().index(0, 0))
|
||||
assert not dialog.gamepad_tooltip.isVisible()
|
||||
assert not dialog.gamepad_tooltip_timer.isActive()
|
||||
dialog._on_combo_highlighted(1, combo)
|
||||
combo.view().setCurrentIndex(combo.model().index(1, 0))
|
||||
assert dialog.gamepad_tooltip.isVisible()
|
||||
|
||||
if close_dialog:
|
||||
@@ -509,6 +533,112 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_favorite_combo_preserves_separator_actions_and_source_updates() -> None:
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
table = QTableWidget(1, 2)
|
||||
source = QComboBox(table)
|
||||
source.addItems(["Wine", "Proton"])
|
||||
source.insertSeparator(source.count())
|
||||
source.addItem("Download", "download_wine")
|
||||
activated = []
|
||||
source.activated.connect(activated.append)
|
||||
source.activated.connect(lambda _index: source.setCurrentIndex(0))
|
||||
highlighted = []
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.vkbasalt_toggle_key_button = None
|
||||
dialog.input_manager = None
|
||||
dialog.theme = ThemeWrapper(load_theme("standart"))
|
||||
dialog.favorites_table = table
|
||||
dialog._on_combo_highlighted = lambda row, combo: highlighted.append((row, combo))
|
||||
dialog._add_favorite_combo(0, source)
|
||||
combo = cast(QComboBox, table.cellWidget(0, 1))
|
||||
|
||||
assert combo.model() is source.model()
|
||||
assert not combo.model().flags(combo.model().index(2, 0)) & Qt.ItemFlag.ItemIsEnabled
|
||||
assert combo.itemData(3) == "download_wine"
|
||||
combo.setCurrentIndex(3)
|
||||
combo.activated.emit(3)
|
||||
assert activated == [3]
|
||||
assert combo.currentText() == source.currentText() == "Wine"
|
||||
source.addItem("New Wine")
|
||||
assert combo.itemText(combo.count() - 1) == "New Wine"
|
||||
highlighted.clear()
|
||||
combo.view().setCurrentIndex(combo.model().index(1, 0))
|
||||
assert highlighted == [(1, combo)]
|
||||
table.deleteLater()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def linked_settings_combos() -> tuple:
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.vkbasalt_toggle_key_button = None
|
||||
dialog.input_manager = None
|
||||
dialog.theme = ThemeWrapper(load_theme("standart"))
|
||||
dialog.gamepad_tooltip = QLabel(dialog)
|
||||
dialog.gamepad_tooltip.setStyleSheet(dialog.theme.TOOLTIP_STYLE)
|
||||
dialog.gamepad_tooltip_timer = QTimer(dialog)
|
||||
stack = QStackedWidget(dialog)
|
||||
stack.setGeometry(0, 0, 600, 400)
|
||||
dialog.favorites_table = QTableWidget(1, 2)
|
||||
advanced = QTableWidget(1, 2)
|
||||
stack.addWidget(dialog.favorites_table)
|
||||
stack.addWidget(advanced)
|
||||
source = QComboBox()
|
||||
source.addItems(["Wine", "WINE-11.18-AMD64-WOW64 " * 3])
|
||||
advanced.setCellWidget(0, 1, source)
|
||||
source.view().installEventFilter(dialog)
|
||||
source.view().setMouseTracking(True)
|
||||
source.view().entered.connect(lambda index: dialog._on_combo_highlighted(index.row(), source))
|
||||
source.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _old: dialog._on_combo_highlighted(index.row(), source),
|
||||
)
|
||||
dialog._add_favorite_combo(0, source)
|
||||
favorite = cast(QComboBox, dialog.favorites_table.cellWidget(0, 1))
|
||||
dialog.resize(600, 400)
|
||||
dialog.show()
|
||||
return dialog, stack, favorite, source
|
||||
|
||||
|
||||
@pytest.mark.parametrize("navigation", ["gamepad", "mouse"])
|
||||
def test_linked_settings_combos_keep_visible_tooltip_during_navigation(
|
||||
linked_settings_combos: tuple, navigation: str,
|
||||
) -> None:
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
|
||||
dialog, stack, favorite, source = linked_settings_combos
|
||||
for page, combo in enumerate((favorite, source)):
|
||||
stack.setCurrentIndex(page)
|
||||
combo.view().setFixedWidth(180)
|
||||
combo.showPopup()
|
||||
QApplication.processEvents()
|
||||
for row in (1, 0):
|
||||
if navigation == "mouse":
|
||||
rect = combo.view().visualRect(combo.model().index(row, 0))
|
||||
point = rect.intersected(combo.view().viewport().rect()).center().toPointF()
|
||||
QApplication.sendEvent(combo.view().viewport(), QMouseEvent(
|
||||
QEvent.Type.MouseMove, point, point, Qt.MouseButton.NoButton,
|
||||
Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier,
|
||||
))
|
||||
else:
|
||||
combo.setCurrentIndex(row)
|
||||
assert dialog.gamepad_tooltip.isVisible() == bool(row), page
|
||||
if row:
|
||||
assert dialog.gamepad_tooltip.text() == combo.itemText(row)
|
||||
assert dialog.rect().contains(dialog.gamepad_tooltip.geometry())
|
||||
assert not dialog.gamepad_tooltip.isWindow()
|
||||
combo.hidePopup()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic", "classic-light"])
|
||||
def test_settings_selected_row_keeps_text_readable(theme_name: str) -> None:
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
@@ -631,3 +761,273 @@ def test_winetricks_centered_checkboxes_and_install_selection(
|
||||
dialog.install_selected(force=True)
|
||||
dialog._start_install_process.assert_called_once_with(["art2kmin"], True)
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic", "classic-light"])
|
||||
def test_settings_table_stops_repainting_when_idle(theme_name: str) -> None:
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
theme = ThemeWrapper(load_theme(theme_name))
|
||||
dialog = cast(ExeSettingsDialog, SimpleNamespace(theme=theme))
|
||||
|
||||
class PaintFilter(QObject):
|
||||
paints = 0
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
if event.type() == QEvent.Type.Paint:
|
||||
self.paints += 1
|
||||
return ExeSettingsDialog.eventFilter(dialog, obj, event)
|
||||
return False
|
||||
|
||||
table = QTableWidget(3, 3)
|
||||
table.resize(800, 400)
|
||||
table.setStyleSheet(theme.WINETRICKS_TABBLE_STYLE)
|
||||
table.setColumnWidth(0, 500)
|
||||
table.setColumnHidden(2, True)
|
||||
table.viewport().setProperty("ppqt_settings_values", True)
|
||||
paint_filter = PaintFilter(table)
|
||||
table.viewport().installEventFilter(paint_filter)
|
||||
for row in range(3):
|
||||
table.setItem(row, 0, QTableWidgetItem("Setting\nDescription " * 5))
|
||||
combo = CustomComboBox(theme=theme)
|
||||
combo.addItem("Value")
|
||||
table.setCellWidget(row, 1, combo)
|
||||
table.show()
|
||||
for selected_row, width in ((0, 500), (1, 300)):
|
||||
table.selectRow(selected_row)
|
||||
table.setColumnWidth(0, width)
|
||||
for _ in range(10):
|
||||
QApplication.processEvents()
|
||||
QTest.qWait(PAINT_SETTLE_MS)
|
||||
paints = paint_filter.paints
|
||||
QTest.qWait(PAINT_SETTLE_MS)
|
||||
assert paint_filter.paints == paints
|
||||
table.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def autosave_dialog(request, monkeypatch, tmp_path) -> tuple[QDialog, list[list[str]], bool]:
|
||||
from PySide6.QtCore import QProcess
|
||||
from portprotonqt.dialogs import settings_dialog as module
|
||||
|
||||
dialog = module.ExeSettingsDialog.__new__(module.ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.saving = False
|
||||
dialog.settings_loading = False
|
||||
dialog.close_after_save = False
|
||||
user_conf = bool(request.param)
|
||||
dialog.user_conf = user_conf
|
||||
dialog.exe_path = str(tmp_path / 'game.exe')
|
||||
dialog.save_timer = QTimer(dialog)
|
||||
dialog.clear_ppdb_button = cast(module.AutoSizeButton, QPushButton(dialog))
|
||||
dialog.original_values = {'TEST': '0'}
|
||||
dialog.original_display_values = {}
|
||||
dialog.current_settings = {}
|
||||
dialog.blocked_keys = set()
|
||||
prefix = CustomComboBox(dialog)
|
||||
prefix.setEditable(True)
|
||||
prefix.setCurrentText('lower prefix')
|
||||
dialog.advanced_widgets = {} if user_conf else {'PW_PREFIX_NAME': prefix}
|
||||
dialog.value_mapping = {}
|
||||
dialog.game_source = ''
|
||||
dialog.gamescope_available = False
|
||||
dialog.settings_table = QTableWidget(1, 1, dialog)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
item = QTableWidgetItem('TEST')
|
||||
item.setData(Qt.ItemDataRole.UserRole, 'TEST')
|
||||
dialog.settings_table.setItem(0, 0, item)
|
||||
checkbox = QCheckBox(dialog)
|
||||
dialog.value_widgets = {(0, 1): checkbox}
|
||||
dialog.mangohud_original_values = {}
|
||||
dialog.vkbasalt_original_values = {}
|
||||
dialog.gamescope_original_values = {}
|
||||
calls: list[list[str]] = []
|
||||
monkeypatch.setattr(dialog, '_collect_mangohud_changes', lambda: [])
|
||||
monkeypatch.setattr(dialog, '_collect_vkbasalt_changes', lambda: [])
|
||||
monkeypatch.setattr(dialog, '_get_process_args', lambda args: ['test-cli', *args])
|
||||
monkeypatch.setattr(QProcess, 'start', lambda _self, program, args: calls.append(args))
|
||||
|
||||
return dialog, calls, user_conf
|
||||
|
||||
|
||||
@pytest.mark.parametrize('autosave_dialog', [False, True], indirect=True)
|
||||
def test_settings_autosave_ignores_download_action(autosave_dialog: tuple) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
widget, calls, user_conf = autosave_dialog
|
||||
dialog = cast(ExeSettingsDialog, widget)
|
||||
combo = CustomComboBox(dialog)
|
||||
combo.addItem("Wine")
|
||||
combo.addItem("Download", "download_wine")
|
||||
combo.setCurrentIndex(1)
|
||||
key = 'PW_DEFAULT_WINE_USE' if user_conf else 'PW_WINE_USE'
|
||||
dialog.advanced_widgets = {key: combo}
|
||||
dialog.original_display_values = {key: "Wine"}
|
||||
dialog.apply_changes()
|
||||
assert calls == []
|
||||
assert not dialog.saving
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('autosave_dialog', [False, True], indirect=True)
|
||||
def test_settings_autosave_serializes_changes(autosave_dialog) -> None:
|
||||
from PySide6.QtCore import QProcess
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
widget, calls, user_conf = autosave_dialog
|
||||
dialog = cast(ExeSettingsDialog, widget)
|
||||
checkbox = dialog.value_widgets[(0, 1)]
|
||||
assert isinstance(checkbox, QCheckBox)
|
||||
checkbox.setChecked(True)
|
||||
dialog.apply_changes()
|
||||
assert dialog.saving
|
||||
assert len(calls) == 1
|
||||
checkbox.setChecked(False)
|
||||
dialog.apply_changes()
|
||||
assert len(calls) == 1
|
||||
process = dialog.user_conf_process if user_conf else dialog.save_process
|
||||
process.finished.emit(0, QProcess.ExitStatus.NormalExit)
|
||||
assert dialog.saving
|
||||
assert len(calls) == 2
|
||||
expected = ['TEST', '0'] if user_conf else ['TEST=0']
|
||||
assert calls[-1][-len(expected):] == expected
|
||||
process = dialog.user_conf_process if user_conf else dialog.save_process
|
||||
process.finished.emit(0, QProcess.ExitStatus.NormalExit)
|
||||
assert not dialog.saving
|
||||
assert dialog.original_values['TEST'] == '0'
|
||||
assert len(calls) == 2
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_settings_autosave_ignores_search_and_initial_loading() -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.settings_loading = True
|
||||
dialog.save_timer = QTimer(dialog)
|
||||
dialog.search_edit = QLineEdit(dialog)
|
||||
checkbox = QCheckBox(dialog)
|
||||
dialog._connect_autosave()
|
||||
checkbox.setChecked(True)
|
||||
assert not dialog.save_timer.isActive()
|
||||
dialog.settings_loading = False
|
||||
dialog.search_edit.setText('search')
|
||||
assert not dialog.save_timer.isActive()
|
||||
checkbox.setChecked(False)
|
||||
assert dialog.save_timer.isActive()
|
||||
dialog.save_timer.stop()
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_settings_close_waits_for_pending_save(monkeypatch) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.save_timer = QTimer(dialog)
|
||||
dialog.saving = True
|
||||
dialog.close_after_save = False
|
||||
monkeypatch.setattr(dialog, 'apply_changes', lambda: None)
|
||||
event = QCloseEvent()
|
||||
dialog.closeEvent(event)
|
||||
assert not event.isAccepted()
|
||||
assert dialog.close_after_save
|
||||
dialog.close_after_save = False
|
||||
dialog.reject()
|
||||
assert dialog.close_after_save
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_run_after_normalization_preserves_duplicates(tmp_path: Path) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
dialog = SimpleNamespace(exe_path=str(tmp_path / "Game.exe"))
|
||||
dialog._resolve_run_after_exe_path = lambda value: ExeSettingsDialog._resolve_run_after_exe_path(cast(ExeSettingsDialog, dialog), value)
|
||||
value = "EVE.exe,EVE.exe,Trainer.exe"
|
||||
assert dialog._resolve_run_after_exe_path(value) == ",".join(
|
||||
str(tmp_path / name) for name in value.split(",")
|
||||
)
|
||||
|
||||
|
||||
def test_run_after_inline_entries_preserve_duplicates(tmp_path: Path) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
owner = cast(Any, QWidget())
|
||||
owner.advanced_table = QTableWidget(2, 3)
|
||||
owner.theme = SimpleNamespace(ACTION_BUTTON_STYLE="", LINE_EDIT_STYLE="")
|
||||
container = QWidget()
|
||||
target = QLineEdit(container)
|
||||
owner.advanced_table.setCellWidget(0, 1, container)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
owner.advanced_table.setItem(1, 0, QTableWidgetItem("next setting"))
|
||||
exe = tmp_path / "Game.exe"
|
||||
exe.touch()
|
||||
target.setText(f"{exe},{exe}")
|
||||
ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
|
||||
assert owner.advanced_table.rowCount() == 4
|
||||
item = owner.advanced_table.item(1, 0)
|
||||
assert item is not None and item.text() == str(exe)
|
||||
item = owner.advanced_table.item(2, 0)
|
||||
assert item is not None and item.text() == str(exe)
|
||||
ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
|
||||
assert owner.advanced_table.rowCount() == 2
|
||||
item = owner.advanced_table.item(1, 0)
|
||||
assert item is not None and item.text() == "next setting"
|
||||
owner.advanced_table.deleteLater()
|
||||
|
||||
|
||||
def test_run_after_entry_paint_preserves_text_and_height() -> None:
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
table = QTableWidget(1, 2)
|
||||
table.viewport().setProperty("ppqt_settings_values", True)
|
||||
table.setItem(0, 0, QTableWidgetItem("Game.exe"))
|
||||
entry = QLineEdit("Game.exe")
|
||||
table.setCellWidget(0, 0, entry)
|
||||
table.resizeRowsToContents()
|
||||
table.show()
|
||||
QApplication.processEvents()
|
||||
height = table.rowHeight(0)
|
||||
|
||||
for _ in range(2):
|
||||
ExeSettingsDialog.eventFilter(
|
||||
cast(ExeSettingsDialog, SimpleNamespace()),
|
||||
table.viewport(), QEvent(QEvent.Type.Paint),
|
||||
)
|
||||
|
||||
assert entry.text() == "Game.exe"
|
||||
item = table.item(0, 0)
|
||||
assert item is not None and item.text() == "Game.exe"
|
||||
assert table.rowHeight(0) == height
|
||||
assert entry.height() >= entry.sizeHint().height()
|
||||
table.close()
|
||||
table.deleteLater()
|
||||
|
||||
|
||||
def test_run_after_removing_only_entry_clears_field(tmp_path: Path) -> None:
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
owner = cast(Any, QWidget())
|
||||
owner.advanced_table = QTableWidget(2, 3)
|
||||
owner.theme = SimpleNamespace(ACTION_BUTTON_STYLE="", LINE_EDIT_STYLE="")
|
||||
owner._toggle_run_after_entries = lambda target: ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
|
||||
container = QWidget()
|
||||
target = QLineEdit(container)
|
||||
owner.advanced_table.setCellWidget(0, 1, container)
|
||||
owner.advanced_table.setItem(1, 0, QTableWidgetItem("next setting"))
|
||||
exe = tmp_path / "Game.exe"
|
||||
exe.touch()
|
||||
target.setText(str(exe))
|
||||
owner._toggle_run_after_entries(target)
|
||||
ExeSettingsDialog._remove_run_after_entry(cast(ExeSettingsDialog, owner), target, 0)
|
||||
assert target.text() == ""
|
||||
assert owner.advanced_table.rowCount() == 2
|
||||
owner.advanced_table.deleteLater()
|
||||
+1222
-49
File diff suppressed because it is too large.
Load diff
@@ -116,3 +116,42 @@ class TestDeleteCachedAppFiles:
|
||||
(tmp_path / "other.txt").write_text("hello")
|
||||
delete_cached_app_files(str(tmp_path), "steam_app_*.json")
|
||||
assert (tmp_path / "other.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["exe", "gog", "egs"])
|
||||
def test_matched_steam_game_keeps_cover_when_app_details_fail(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: str,
|
||||
) -> None:
|
||||
import portprotonqt.steam_api.api as api
|
||||
|
||||
monkeypatch.setattr("portprotonqt.config.cache.CACHE_DIR", tmp_path / "cache")
|
||||
executable = tmp_path / "Praest.exe"
|
||||
executable.touch()
|
||||
exec_line = str(executable) if source == "exe" else f"{source}://launch/graven"
|
||||
app = {"appid": 1371690, "normalized_name": "graven"}
|
||||
monkeypatch.setattr(api.ui_config, "get_economy_mode", lambda: False)
|
||||
monkeypatch.setattr("portprotonqt.steam_api.cache.get_exiftool_data", lambda _exe: {})
|
||||
monkeypatch.setattr(api, "_has_custom_data_for_exe", lambda _name: False)
|
||||
monkeypatch.setattr(api, "get_steam_apps_and_index_async", lambda cb: cb(([app], {"graven": app})))
|
||||
monkeypatch.setattr(api, "fetch_app_info_async", lambda _appid, cb: cb(None))
|
||||
monkeypatch.setattr(api, "get_local_steam_cover", lambda *_args, **_kwargs: "")
|
||||
monkeypatch.setattr(api, "get_weanticheatyet_info_async", lambda _name, cb: cb({}))
|
||||
monkeypatch.setattr(api, "_add_ppdb_info", lambda result, _name, cb: cb(result))
|
||||
results = []
|
||||
api.get_steam_game_info_async("GRAVEN", exec_line, results.append)
|
||||
assert len(results) == 1
|
||||
assert results[0]["appid"] == 1371690
|
||||
assert results[0]["steam_game"] == "true"
|
||||
expected_cover = (
|
||||
"https://steamcdn-a.akamaihd.net/steam/apps/1371690/library_600x900_2x.jpg"
|
||||
if source == "exe" else ""
|
||||
)
|
||||
assert results[0]["cover"] == expected_cover
|
||||
with (
|
||||
patch("portprotonqt.steam_api.cache.get_exiftool_data", side_effect=AssertionError("EXE reread")),
|
||||
patch.object(api, "get_steam_apps_and_index_async", side_effect=AssertionError("Catalog reloaded")),
|
||||
patch.object(api, "search_app", side_effect=AssertionError("AppID searched again")),
|
||||
):
|
||||
api.get_steam_game_info_async("GRAVEN", exec_line, results.append)
|
||||
assert len(results) == 2
|
||||
assert results[1] == results[0]
|
||||
+193
-1
@@ -3,12 +3,15 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import orjson
|
||||
from PySide6.QtCore import QEventLoop, QThread, QTimer
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
from pytest import MonkeyPatch, mark
|
||||
from shiboken6 import delete
|
||||
|
||||
@@ -20,6 +23,7 @@ from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin
|
||||
from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin as GOGMixin
|
||||
import portprotonqt.main_window as main_window_module
|
||||
import portprotonqt.tabs.download_tab as download_tab_module
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
|
||||
@mark.parametrize("source", ("gog", "egs"))
|
||||
@@ -103,6 +107,83 @@ def test_store_dlc_selection_remains_available_during_download(
|
||||
assert window.store_dlc_worker is worker
|
||||
|
||||
|
||||
@mark.parametrize("source", ("gog", "egs"))
|
||||
@mark.parametrize("failed", (False, True))
|
||||
def test_dlc_worker_released_before_modal_and_retry(
|
||||
monkeypatch: MonkeyPatch, source: str, failed: bool
|
||||
) -> None:
|
||||
application = download_tab_module.QApplication.instance() or download_tab_module.QApplication([])
|
||||
loop = QEventLoop()
|
||||
observations = []
|
||||
|
||||
class Window(GOGMixin, QMainWindow):
|
||||
def _on_store_dlcs_loaded(self, source: str, game: dict, dlcs: list[dict]) -> None:
|
||||
observations.append((self.store_dlc_worker, QThread.currentThread()))
|
||||
dialog = download_tab_module.QDialog(self)
|
||||
QTimer.singleShot(0, dialog.reject)
|
||||
dialog.exec()
|
||||
loop.quit()
|
||||
|
||||
window: Any = Window()
|
||||
window.gog_api = cast(Any, object())
|
||||
window.egs_api = cast(Any, object())
|
||||
|
||||
def run(worker: download_tab_module.StoreDLCWorker) -> None:
|
||||
if failed:
|
||||
worker.failed.emit("failed")
|
||||
else:
|
||||
worker.loaded.emit([])
|
||||
|
||||
def warning(*args: Any) -> None:
|
||||
observations.append((window.store_dlc_worker, QThread.currentThread()))
|
||||
loop.quit()
|
||||
|
||||
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
|
||||
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
|
||||
timeout = QTimer()
|
||||
timeout.setSingleShot(True)
|
||||
timeout.timeout.connect(loop.quit)
|
||||
for _attempt in range(2):
|
||||
window._select_store_dlcs(source, {"app_id": "123", "title": "Game"})
|
||||
timeout.start(3000)
|
||||
loop.exec()
|
||||
timeout.stop()
|
||||
assert window.store_dlc_worker is None
|
||||
assert observations == [(None, application.thread())] * 2
|
||||
window.deleteLater()
|
||||
|
||||
|
||||
def test_dlc_worker_shutdown_suppresses_picker(monkeypatch: MonkeyPatch) -> None:
|
||||
application = download_tab_module.QApplication.instance() or download_tab_module.QApplication([])
|
||||
|
||||
class Window(GOGMixin, QMainWindow, MainWindowWorkersMixin):
|
||||
pass
|
||||
|
||||
window: Any = Window()
|
||||
window.gog_api = cast(Any, object())
|
||||
window._on_store_dlcs_loaded = MagicMock()
|
||||
warning = MagicMock()
|
||||
started = Event()
|
||||
|
||||
def run(worker: download_tab_module.StoreDLCWorker) -> None:
|
||||
started.set()
|
||||
while not worker.isInterruptionRequested():
|
||||
Event().wait(0.001)
|
||||
|
||||
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
|
||||
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
|
||||
window._select_store_dlcs("gog", {"app_id": "123", "title": "Game"})
|
||||
worker = window.store_dlc_worker
|
||||
assert started.wait(3)
|
||||
window._stopWorkerThread("store_dlc_worker", None)
|
||||
assert not worker.isRunning()
|
||||
application.processEvents()
|
||||
assert window.store_dlc_worker is None
|
||||
window._on_store_dlcs_loaded.assert_not_called()
|
||||
warning.assert_not_called()
|
||||
window.deleteLater()
|
||||
|
||||
|
||||
def test_store_download_queues_reject_duplicates() -> None:
|
||||
active = {"app_id": "active", "title": "Active"}
|
||||
queued = {"app_id": "queued", "title": "Queued"}
|
||||
@@ -920,15 +1001,19 @@ def test_installed_filter_excludes_uninstalled_gog_games(
|
||||
get_launch_target=lambda app_id: f"/games/{app_id}/game.exe",
|
||||
)
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
|
||||
loaded_metadata = []
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.get_steam_game_info_async",
|
||||
lambda _name, _uri, callback: callback({}),
|
||||
lambda name, _uri, callback: (
|
||||
loaded_metadata.append(name) or callback({})
|
||||
),
|
||||
)
|
||||
results = []
|
||||
|
||||
MainWindow._load_gog_games_async(window, results.append)
|
||||
|
||||
assert [game[3] for game in results[0]] == ["installed"]
|
||||
assert loaded_metadata == ["Installed"]
|
||||
assert results[0][0][5] == "gog://launch/installed"
|
||||
|
||||
def test_installed_filter_disabled_includes_uninstalled_gog_games(
|
||||
@@ -1052,6 +1137,38 @@ def test_egs_library_uses_steam_description_when_epic_has_title_only(
|
||||
|
||||
assert results[0][0][1] == "Witch It — игра в прятки по сети."
|
||||
|
||||
|
||||
def test_installed_filter_skips_uninstalled_egs_metadata(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
library = [
|
||||
{"app_id": "installed", "title": "Installed"},
|
||||
{"app_id": "uninstalled", "title": "Uninstalled"},
|
||||
]
|
||||
installed_data = {"installed": {}}
|
||||
api = SimpleNamespace(
|
||||
load_library=lambda: library,
|
||||
load_installed=lambda: installed_data,
|
||||
is_game_installed=lambda app_id, data: app_id in data,
|
||||
get_launch_target=lambda app_id, _data: f"/games/{app_id}.exe",
|
||||
)
|
||||
loaded_metadata = []
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.get_steam_game_info_async",
|
||||
lambda name, _uri, callback: (
|
||||
loaded_metadata.append(name) or callback({})
|
||||
),
|
||||
)
|
||||
results = []
|
||||
|
||||
MainWindow._load_egs_games_async(
|
||||
cast(MainWindow, SimpleNamespace(egs_api=api)), results.append
|
||||
)
|
||||
|
||||
assert loaded_metadata == ["Installed"]
|
||||
assert [game[3] for game in results[0]] == ["installed"]
|
||||
|
||||
def test_egs_maintenance_uses_legendary_commands() -> None:
|
||||
start_operation = MagicMock()
|
||||
window = cast(MainWindow, SimpleNamespace(_start_egs_operation=start_operation))
|
||||
@@ -1350,3 +1467,78 @@ def test_store_storage_migrates_existing_data(
|
||||
assert not (api.data_dir / "bin").exists()
|
||||
assert not legacy.exists()
|
||||
assert api_class().library_path.read_text() == "[]"
|
||||
|
||||
|
||||
def test_batch_egs_deletion_skips_confirmation(monkeypatch: MonkeyPatch) -> None:
|
||||
message_box = MagicMock()
|
||||
monkeypatch.setattr(main_window_module, "QMessageBox", message_box)
|
||||
window = SimpleNamespace(
|
||||
context_menu_manager=SimpleNamespace(_batch_action=True),
|
||||
egs_api=SimpleNamespace(load_installed=lambda: {}), _start_egs_operation=MagicMock(),
|
||||
)
|
||||
MainWindow._delete_egs_game(cast(Any, window), "Game")
|
||||
message_box.assert_not_called()
|
||||
assert window._start_egs_operation.call_args.args[:2] == ("Game", ["uninstall", "Game", "-y"])
|
||||
|
||||
|
||||
def test_batch_gog_deletion_retains_worker_without_confirmation(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
|
||||
worker = MagicMock()
|
||||
monkeypatch.setattr(download_tab_module, "GOGDeleteWorker", MagicMock(return_value=worker))
|
||||
message_box = MagicMock()
|
||||
monkeypatch.setattr(download_tab_module, "QMessageBox", message_box)
|
||||
window = SimpleNamespace(
|
||||
context_menu_manager=SimpleNamespace(_batch_action=True),
|
||||
gog_api=SimpleNamespace(get_installed_path=lambda app_id: tmp_path),
|
||||
_on_gog_delete_finished=MagicMock(), gogAccountStatus=MagicMock(),
|
||||
)
|
||||
game = {"app_id": "1", "title": "Game"}
|
||||
GOGMixin._delete_gog_game(cast(Any, window), game)
|
||||
message_box.assert_not_called()
|
||||
assert window.gog_delete_worker is worker
|
||||
worker.finished.connect.assert_called_once_with(window._on_gog_delete_finished)
|
||||
worker.start.assert_called_once_with()
|
||||
|
||||
|
||||
@mark.parametrize("failure", [False, True])
|
||||
def test_gog_delete_worker_preserves_records_on_failure(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch, failure: bool,
|
||||
) -> None:
|
||||
from threading import get_ident
|
||||
|
||||
folder = tmp_path / "game"
|
||||
folder.mkdir()
|
||||
(folder / "game.exe").write_text("game")
|
||||
manifest = tmp_path / "heroic_gogdl" / "manifests" / "1"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text("manifest")
|
||||
api = SimpleNamespace(get_installed_path=lambda app_id: folder, config_dir=tmp_path,
|
||||
remove_installed_game=MagicMock())
|
||||
menu = SimpleNamespace(remove_gog_shortcuts=MagicMock())
|
||||
thread_ids: list[int] = []
|
||||
original_rmtree = download_tab_module.shutil.rmtree
|
||||
|
||||
def delete_installation(path: Path) -> None:
|
||||
thread_ids.append(get_ident())
|
||||
if failure:
|
||||
raise PermissionError("denied")
|
||||
original_rmtree(path)
|
||||
|
||||
monkeypatch.setattr(download_tab_module.shutil, "rmtree", delete_installation)
|
||||
worker = download_tab_module.GOGDeleteWorker(cast(Any, api), {"app_id": "1", "title": "Game"}, cast(Any, menu))
|
||||
worker.start()
|
||||
assert worker.wait(3000)
|
||||
assert thread_ids and thread_ids[0] != get_ident()
|
||||
assert bool(worker.error) is failure
|
||||
assert folder.exists() is failure
|
||||
assert manifest.exists() is failure
|
||||
assert api.remove_installed_game.call_count == int(not failure)
|
||||
assert menu.remove_gog_shortcuts.call_count == int(not failure)
|
||||
|
||||
|
||||
def test_failed_store_process_completes_callback_path() -> None:
|
||||
process = MagicMock()
|
||||
GOGMixin._handle_store_process_error(cast(Any, SimpleNamespace()), process,
|
||||
download_tab_module.QProcess.ProcessError.FailedToStart)
|
||||
process.finished.emit.assert_called_once_with(
|
||||
download_tab_module.os.EX_UNAVAILABLE, download_tab_module.QProcess.ExitStatus.CrashExit,
|
||||
)
|
||||
@@ -773,62 +773,6 @@ class TestThemeStylesIntegrity:
|
||||
assert '"glow_base_alpha"' in content
|
||||
assert '"glow_pulse_alpha"' in content
|
||||
|
||||
def test_classic_styles_use_fstrings_not_hardcoded(self):
|
||||
"""Classic QSS style constants should be f-strings, not plain strings
|
||||
(except HINT_BAR_STYLE and %-formatted styles for icon paths)."""
|
||||
content = self._read_theme("classic")
|
||||
tree = ast.parse(content)
|
||||
|
||||
plain_style_count = 0
|
||||
fstring_style_count = 0
|
||||
percent_style_count = 0
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if not isinstance(target, ast.Name):
|
||||
continue
|
||||
if not target.id.endswith("_STYLE"):
|
||||
continue
|
||||
if isinstance(node.value, ast.JoinedStr):
|
||||
fstring_style_count += 1
|
||||
elif isinstance(node.value, ast.BinOp) and isinstance(node.value.op, ast.Mod):
|
||||
percent_style_count += 1
|
||||
elif isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
||||
plain_style_count += 1
|
||||
|
||||
assert fstring_style_count > 10, "Classic should use f-strings for most styles"
|
||||
assert plain_style_count <= 1, f"Classic has {plain_style_count} plain string styles"
|
||||
assert percent_style_count <= 3, f"Classic has {percent_style_count} %-formatted styles"
|
||||
|
||||
def test_classic_light_styles_use_fstrings_not_hardcoded(self):
|
||||
"""Classic-light QSS style constants should be f-strings, not plain strings
|
||||
(except %-formatted styles for icon paths)."""
|
||||
content = self._read_theme("classic-light")
|
||||
tree = ast.parse(content)
|
||||
|
||||
plain_style_count = 0
|
||||
fstring_style_count = 0
|
||||
percent_style_count = 0
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if not isinstance(target, ast.Name):
|
||||
continue
|
||||
if not target.id.endswith("_STYLE"):
|
||||
continue
|
||||
if isinstance(node.value, ast.JoinedStr):
|
||||
fstring_style_count += 1
|
||||
elif isinstance(node.value, ast.BinOp) and isinstance(node.value.op, ast.Mod):
|
||||
percent_style_count += 1
|
||||
elif isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
||||
plain_style_count += 1
|
||||
|
||||
assert fstring_style_count > 10, "Classic-light should use f-strings for most styles"
|
||||
assert plain_style_count == 0, f"Classic-light has {plain_style_count} plain string styles"
|
||||
assert percent_style_count <= 3, f"Classic-light has {percent_style_count} %-formatted styles"
|
||||
|
||||
def test_classic_theme_inherits_standart(self):
|
||||
content = self._read_theme("classic")
|
||||
assert 'THEME_INHERITS = "standart"' in content
|
||||
|
||||
+128
-2
@@ -2,13 +2,139 @@
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
import io
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
from PySide6.QtCore import QEvent, QPointF, Qt
|
||||
import pytest
|
||||
|
||||
from PySide6.QtCore import QEvent, QMimeData, QPointF, Qt, QUrl
|
||||
from PySide6.QtGui import QEnterEvent, QMouseEvent
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QProgressDialog, QWidget
|
||||
from types import MethodType
|
||||
|
||||
from portprotonqt.tabs import theme_store
|
||||
from portprotonqt.tabs.theme_store import ThemeStoreCard, ThemeStoreMixin
|
||||
from portprotonqt.tabs import theme_store_workers
|
||||
from portprotonqt.tabs.library_tab import MainWindowLibraryTabMixin
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["installed", "failed", "wine_archives"])
|
||||
def test_theme_zip_drop_starts_retained_worker(tmp_path, monkeypatch, outcome) -> None:
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
archive_path = tmp_path / "theme.zip"
|
||||
archive_path.touch()
|
||||
mime = QMimeData()
|
||||
mime.setUrls([QUrl.fromLocalFile(str(archive_path))])
|
||||
accepted = []
|
||||
event = SimpleNamespace(
|
||||
mimeData=lambda: mime,
|
||||
acceptProposedAction=lambda: accepted.append(True),
|
||||
ignore=lambda: accepted.append(False),
|
||||
)
|
||||
started = []
|
||||
monkeypatch.setattr(theme_store_workers.ThemeArchiveInstallWorker, "start", lambda self: started.append(self))
|
||||
window = cast(Any, QWidget())
|
||||
window.theme = SimpleNamespace(MESSAGE_BOX_STYLE="", PROGRESS_BAR_STYLE="")
|
||||
window._on_dropped_theme_installed = started.append
|
||||
window._on_dropped_theme_failed = started.append
|
||||
window._on_dropped_wine_archives = started.append
|
||||
window._install_dropped_theme_archives = MethodType(
|
||||
MainWindowLibraryTabMixin._install_dropped_theme_archives, window
|
||||
)
|
||||
MainWindowLibraryTabMixin.dragEnterEvent(cast(Any, window), event)
|
||||
MainWindowLibraryTabMixin.dropEvent(cast(Any, window), event)
|
||||
|
||||
assert accepted == [True, True]
|
||||
assert started == window._themeArchiveWorkers
|
||||
assert started[0].paths == [str(archive_path)]
|
||||
progress = window.findChild(QProgressDialog)
|
||||
assert progress is not None
|
||||
assert progress.isVisible()
|
||||
assert progress.isModal()
|
||||
assert progress.minimum() == progress.maximum() == 0
|
||||
worker = started[0]
|
||||
getattr(worker, outcome).emit("error" if outcome == "failed" else ["custom"])
|
||||
assert progress.isVisible() == (outcome == "installed")
|
||||
worker.finished.emit()
|
||||
assert not progress.isVisible()
|
||||
assert window._themeArchiveWorkers == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"])
|
||||
def test_dropped_theme_archive_installs_and_applies(tmp_path, monkeypatch, archive_format) -> None:
|
||||
archive_path = tmp_path / f"theme.{archive_format}"
|
||||
if archive_format == "zip":
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("custom/styles.py", "color = 'blue'\n")
|
||||
else:
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
content = b"color = 'blue'\n"
|
||||
entry = tarfile.TarInfo("custom/styles.py")
|
||||
entry.size = len(content)
|
||||
archive.addfile(entry, io.BytesIO(content))
|
||||
themes_dir = tmp_path / "themes"
|
||||
monkeypatch.setattr(theme_store_workers, "THEMES_DIRS", [themes_dir])
|
||||
applied = []
|
||||
invalidated = []
|
||||
refreshed = []
|
||||
window = SimpleNamespace(
|
||||
theme_manager=SimpleNamespace(invalidate_theme=invalidated.append),
|
||||
_refresh_theme_combo=refreshed.append,
|
||||
_apply_theme_and_restart=lambda name, variant: applied.append((name, variant)),
|
||||
)
|
||||
monkeypatch.setattr("portprotonqt.tabs.library_tab.ui_config.get_theme_variant", lambda: "dark")
|
||||
worker = theme_store_workers.ThemeArchiveInstallWorker([str(archive_path)])
|
||||
worker.installed.connect(
|
||||
lambda names: MainWindowLibraryTabMixin._on_dropped_theme_installed(cast(Any, window), names)
|
||||
)
|
||||
errors = []
|
||||
worker.failed.connect(errors.append)
|
||||
worker.run()
|
||||
|
||||
assert not errors
|
||||
assert (themes_dir / "custom/styles.py").read_text() == "color = 'blue'\n"
|
||||
assert invalidated == refreshed == ["custom"]
|
||||
assert applied == [("custom", "dark")]
|
||||
|
||||
|
||||
def test_dropped_archives_keep_wine_and_reject_unsafe_theme(tmp_path, monkeypatch) -> None:
|
||||
wine = tmp_path / "wine.tar.gz"
|
||||
with tarfile.open(wine, "w:gz") as archive:
|
||||
archive.addfile(tarfile.TarInfo("wine/bin/wine"))
|
||||
unsafe = tmp_path / "unsafe.zip"
|
||||
with zipfile.ZipFile(unsafe, "w") as archive:
|
||||
archive.writestr("../styles.py", "")
|
||||
monkeypatch.setattr(theme_store_workers, "THEMES_DIRS", [tmp_path / "themes"])
|
||||
worker = theme_store_workers.ThemeArchiveInstallWorker([str(wine), str(unsafe)])
|
||||
wines = []
|
||||
errors = []
|
||||
installed = []
|
||||
worker.wine_archives.connect(wines.append)
|
||||
worker.failed.connect(errors.append)
|
||||
worker.installed.connect(installed.append)
|
||||
worker.run()
|
||||
|
||||
assert wines == [[str(wine)]]
|
||||
assert len(errors) == 1
|
||||
assert installed == []
|
||||
assert not (tmp_path / "styles.py").exists()
|
||||
|
||||
|
||||
def test_shutdown_waits_for_dropped_theme_workers() -> None:
|
||||
worker = SimpleNamespace()
|
||||
stopped = []
|
||||
window = SimpleNamespace(
|
||||
_themeArchiveWorkers=[worker],
|
||||
_stopWorkerThread=lambda *args: None,
|
||||
_stopWorkerThreads=lambda name: MainWindowWorkersMixin._stopWorkerThreads(cast(Any, window), name),
|
||||
_stopWorker=lambda *args: stopped.append(args),
|
||||
)
|
||||
MainWindowWorkersMixin._stopBackgroundWorkers(cast(Any, window))
|
||||
|
||||
assert stopped == [(worker, "_themeArchiveWorkers", None)]
|
||||
assert window._themeArchiveWorkers == []
|
||||
|
||||
|
||||
class FakeSignal:
|
||||
|
||||
@@ -6,7 +6,9 @@ Key regression areas (from git history):
|
||||
- Malformed playtime data robustness (dd65021)
|
||||
- #@_@# space encoding in stat paths
|
||||
"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from portprotonqt.time_utils import (
|
||||
_parse_last_launch_line,
|
||||
@@ -18,9 +20,22 @@ from portprotonqt.time_utils import (
|
||||
parse_playtime_file,
|
||||
get_playtime_for_exe,
|
||||
format_playtime,
|
||||
format_last_launch,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("detail_level", ["detailed", "brief", "steam"])
|
||||
def test_time_formatting_with_c_locale(
|
||||
monkeypatch: pytest.MonkeyPatch, detail_level: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr("portprotonqt.localization.locale.getlocale", lambda: ("c", None))
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_force_english", lambda: False)
|
||||
monkeypatch.setattr("portprotonqt.time_utils.ui_config.get_time_detail_level", lambda: detail_level)
|
||||
|
||||
assert format_last_launch(datetime.now() - timedelta(hours=2))
|
||||
assert format_playtime(7200)
|
||||
|
||||
|
||||
# ── _parse_last_launch_line ──────────────────────────────────────────────────
|
||||
|
||||
class TestParseLastLaunchLine:
|
||||
|
||||
@@ -767,3 +767,11 @@ def test_disabled_steam_proton_scanning_skips_discovery(monkeypatch: pytest.Monk
|
||||
with patch("portprotonqt.steam_api.utils._iter_existing_steam_data_dirs") as discover:
|
||||
assert get_steam_proton_versions() == []
|
||||
discover.assert_not_called()
|
||||
|
||||
|
||||
def test_steam_index_preserves_original_game_when_name_is_reused() -> None:
|
||||
apps = [
|
||||
{"appid": 1371690, "normalized_name": "graven"},
|
||||
{"appid": 5171110, "normalized_name": "graven"},
|
||||
]
|
||||
assert build_index(apps)["graven"]["appid"] == 1371690
|
||||
Reference in new issue
Block a user